Repository: effector/effector (ts)

Issue:
Add `classList` to `spec()` to allow independently changing CSS-classes
## Problem

Sometimes I need to set CSS-classes for single element from different places. For example:

```ts
function Card(fn) {
  h('div', {
    attr: { class: 'padding-4' },
    fn() {
      fn()
    },
  })
}

Card(() => {
  spec({ attr: { class: 'bg-white' } })
})
```

But the second class attr in the spec won't work.

## Proposal

I propose to add support for [`classList`](https://developer.mozilla.org/en-US/docs/Web/API/Element/classList).


1. Object form of classes, allows to enable/disable class via boolean-store value

```ts
spec({
  classList: { 'size-big': $isBig, white: true }
})
```

2. Array form must allow to enable classes provided by store:

```ts
const $size = createStore('size-big');

spec({
  classList: [$size, 'enabled']
})
```

3. Different calls of spec with `classList` should merge (lower one wins?)

```ts
h('div', () => {
  spec({ attr: { class: 'zero' } })
  spec({ classList: { first: true, second: false } })
  spec({ classList: ['second', 'third'] })
})
```

should be rendered as

```html
<div class="zero first second third"></div>
```


Pull request:
Implement `classList` property for forest
Closes #599

Patch:
diff --git a/packages/forest/index.d.ts b/packages/forest/index.d.ts
index a3d3ccbd..3bd34af6 100644
--- a/packages/forest/index.d.ts
+++ b/packages/forest/index.d.ts
@@ -8,6 +8,8 @@ export type StylePropertyMap = Partial<
     [K in keyof CSSStyleDeclaration]: DOMProperty | AttributeStoreInput
   }
 >
+export type ClassListMap = {[cssClass: string]: StoreOrData<boolean>}
+export type ClassListArray = Array<Store<string | null> | string>
 
 export type HandlerMap =
   | Partial<{[K in keyof HTMLElementEventMap]: Event<HTMLElementEventMap[K]>}>
@@ -62,6 +64,7 @@ export function spec(spec: {
   visible?: Store<boolean>
   style?: StylePropertyMap
   styleVar?: PropertyMap
+  classList?: ClassListMap | ClassListArray
   handler?: HandlerMap
 }): void
 export function handler(
@@ -93,6 +96,7 @@ export function h(
     visible?: Store<boolean>
     style?: StylePropertyMap
     styleVar?: PropertyMap
+    classList?: ClassListMap | ClassListArray
     handler?: HandlerMap
     fn?: () => void
   },
diff --git a/src/forest/bindings.ts b/src/forest/bindings.ts
index a0876ff8..05d7e53e 100644
--- a/src/forest/bindings.ts
+++ b/src/forest/bindings.ts
@@ -5,6 +5,7 @@ const defMap = {
   data: applyDataAttr,
   style: applyStyle,
   styleVar: applyStyleVar,
+  classList: applyClassList,
 }
 
 export function escapeTag(value: string) {
@@ -69,6 +70,23 @@ export function applyStyle(
   }
 }
 
+export function applyClassList(
+  element: DOMElement,
+  field: string,
+  value: string | number | boolean | null,
+) {
+  if (field && field.trim().length > 0) {
+    if (value) {
+      element.classList.add(field)
+    } else {
+      element.classList.remove(field)
+      if (element.classList.length === 0) {
+        element.removeAttribute('class')
+      }
+    }
+  }
+}
+
 export function applyDataAttr(
   element: DOMElement,
   field: string,
diff --git a/src/forest/index.h.ts b/src/forest/index.h.ts
index 2edfe160..3b51537e 100644
--- a/src/forest/index.h.ts
+++ b/src/forest/index.h.ts
@@ -43,6 +43,13 @@ export type Template = {
   handlers: TemplateHandlers
 }
 
+export type ClassListMap = {[cssClass: string]: StoreOrData<boolean>}
+export type ClassListArray = Array<Store<string | null> | string>
+export type ClassListProperty = {
+  name: StoreOrData<string>
+  enabled: StoreOrData<boolean>
+}
+
 export type DOMProperty = string | number | null | boolean
 export type PropertyMap = {[field: string]: StoreOrData<DOMProperty>}
 export type StylePropertyMap = Partial<
@@ -90,6 +97,11 @@ export type StaticOperationDef =
       field: string
       value: DOMProperty
     }
+  | {
+      type: 'classList'
+      field: string
+      value: DOMProperty
+    }
 
 export type OperationDef =
   | {
@@ -116,6 +128,13 @@ export type OperationDef =
       field: string
       value: Store<DOMProperty>
     }
+  | {
+      type: 'classList'
+      // name
+      field: Store<string>
+      // enabled
+      value: Store<boolean>
+    }
   | {
       type: 'staticText'
       value: string
@@ -145,7 +164,12 @@ export type PropertyOperationDef = Extract<
   OperationDef,
   {type: PropertyOperationKind}
 >
-export type PropertyOperationKind = 'attr' | 'data' | 'style' | 'styleVar'
+export type PropertyOperationKind =
+  | 'attr'
+  | 'data'
+  | 'style'
+  | 'styleVar'
+  | 'classList'
 
 export type Env = {
   document: Document
@@ -314,6 +338,7 @@ export type ElementDraft = BindingsDraft & {
   }>
   style: StylePropertyMap[]
   styleVar: PropertyMap[]
+  classList: ClassListProperty[]
   visible?: Store<boolean>
   node: Array<(node: DOMElement) => (() => void) | void>
   handler: HandlerRecord[]
diff --git a/src/forest/method/h.ts b/src/forest/method/h.ts
index 6df3ee75..7d7ba850 100644
--- a/src/forest/method/h.ts
+++ b/src/forest/method/h.ts
@@ -1,4 +1,14 @@
-import {Store, Event, is, launch, createEvent, sample, merge} from 'effector'
+import {
+  Store,
+  Event,
+  is,
+  launch,
+  createEvent,
+  sample,
+  merge,
+  combine,
+  createStore,
+} from 'effector'
 
 import type {StateRef} from '../../effector/index.h'
 
@@ -16,6 +26,9 @@ import type {
   HandlerRecord,
   PropertyOperationDef,
   PropertyOperationKind,
+  ClassListProperty,
+  ClassListMap,
+  ClassListArray,
 } from '../index.h'
 
 import type {ElementBlock, TextBlock} from '../relation.h'
@@ -29,6 +42,7 @@ import {
   applyAttr,
   applyText,
   applyStaticOps,
+  applyClassList,
 } from '../bindings'
 import {createTemplate, currentTemplate} from '../template'
 import {
@@ -64,6 +78,7 @@ function createPropsOp<T, S>(
 ) {
   const opID = draft.opsAmount++
   onMount.watch(({value, leaf}) => {
+    const ctx = initCtx(value, leaf)
     const op = createOp({
       value,
       priority: 'props',
@@ -73,7 +88,6 @@ function createPropsOp<T, S>(
       group: leaf.root.leafOps[leaf.fullID].group,
     })
     leaf.root.leafOps[leaf.fullID].group.ops[opID] = op
-    const ctx = initCtx(value, leaf)
   })
   onState.watch(({value, leaf}) => {
     pushOpToQueue(value, leaf.root.leafOps[leaf.fullID].group.ops[opID])
@@ -102,6 +116,7 @@ const propertyOperationBinding: Record<
   data: applyDataAttr,
   style: applyStyle,
   styleVar: applyStyleVar,
+  classList: applyClassList,
 }
 
 const readElement = (leaf: Leaf) => (leaf.data as LeafDataElement).block.value
@@ -199,6 +214,7 @@ export function h(
     visible?: Store<boolean>
     style?: StylePropertyMap
     styleVar?: PropertyMap
+    classList?: ClassListMap | ClassListArray
     handler?:
       | {
           config?: {
@@ -267,6 +283,7 @@ export function h(tag: string, opts?: any) {
     text: [],
     style: [],
     styleVar: [],
+    classList: [],
     handler: [],
     stencil,
     seq: [],
@@ -335,6 +352,32 @@ export function h(tag: string, opts?: any) {
           }
         })
       })
+      draft.classList.forEach(property => {
+        // We know if `name` is store, `enabled` also is store
+        if (is.unit(property.name) || is.unit(property.enabled)) {
+          const $name = is.unit(property.name)
+            ? property.name
+            : createStore(property.name)
+          const $enabled = is.unit(property.enabled)
+            ? property.enabled
+            : createStore(property.enabled)
+
+          draft.seq.push({
+            type: 'classList',
+            field: $name,
+            value: $enabled,
+          })
+
+          processStoreRef($name)
+          processStoreRef($enabled)
+        } else if (!is.unit(property.name)) {
+          draft.staticSeq.push({
+            type: 'classList',
+            field: property.name,
+            value: true,
+          })
+        }
+      })
       draft.text.forEach(item => {
         if (item.value === null) return
         if (is.unit(item.value)) {
@@ -478,6 +521,35 @@ export function h(tag: string, opts?: any) {
             }
             break
           }
+          case 'classList': {
+            const classOperationBinding = propertyOperationBinding.classList
+            const fieldTrack = createStore({
+              prev: '',
+              curr: item.field.getState(),
+            }).on(item.field, ({curr: prev}, curr) => ({prev, curr}))
+
+            const hooks = mutualSample({
+              mount: domElementCreated,
+              state: combine({name: fieldTrack, enabled: item.value}),
+              onMount: (value, leaf) => ({leaf, value}),
+              onState: (leaf, value) => ({leaf, value}),
+            })
+            createPropsOp(draft, {
+              initCtx(value, leaf) {
+                const element = readElement(leaf)
+                classOperationBinding(element, value.name.curr, value.enabled)
+                return element
+              },
+              runOp(value, element: DOMElement) {
+                if (value.name.prev !== value.name.curr) {
+                  classOperationBinding(element, value.name.prev, false)
+                }
+                classOperationBinding(element, value.name.curr, value.enabled)
+              },
+              hooks,
+            })
+            break
+          }
           case 'dynamicText':
             createPropsOp(draft, {
               initCtx(value: string, leaf) {
diff --git a/src/forest/method/spec.ts b/src/forest/method/spec.ts
index 722d30f8..b863c0c7 100644
--- a/src/forest/method/spec.ts
+++ b/src/forest/method/spec.ts
@@ -5,12 +5,16 @@ import type {
   StoreOrData,
   DOMProperty,
   StylePropertyMap,
+  ClassListArray,
+  ClassListMap,
+  ClassListProperty,
 } from '../index.h'
 
 import {escapeTag} from '../bindings'
 import {currentTemplate} from '../template'
 import {assertClosure} from '../assert'
 import {handler} from './handler'
+import {forIn} from '../forIn'
 
 export function spec(config: {
   attr?: PropertyMap
@@ -18,6 +22,7 @@ export function spec(config: {
   text?: StoreOrData<DOMProperty> | Array<StoreOrData<DOMProperty>>
   style?: StylePropertyMap
   styleVar?: PropertyMap
+  classList?: ClassListArray | ClassListMap
   visible?: Store<boolean>
   handler?:
     | {
@@ -78,6 +83,11 @@ export function spec(config: {
     }
     draft.style.push(escaped)
   }
+  if (config.classList) {
+    normalizeClassList(config.classList).forEach(property =>
+      draft.classList.push(property),
+    )
+  }
   if (config.styleVar) draft.styleVar.push(config.styleVar)
   if (config.visible) draft.visible = config.visible
   if (config.handler) {
@@ -95,3 +105,30 @@ export function spec(config: {
     spec(config.ɔ)
   }
 }
+
+function normalizeClassList(
+  classList: ClassListMap | ClassListArray,
+): ClassListProperty[] {
+  const properties: ClassListProperty[] = []
+
+  if (Array.isArray(classList)) {
+    classList.forEach(className => {
+      const name =
+        typeof className === 'string'
+          ? className
+          : className.map(optionalClass => optionalClass || '')
+      const enabled =
+        typeof className === 'string'
+          ? true
+          : className.map(optionalClass => optionalClass !== null)
+
+      properties.push({name, enabled})
+    })
+  } else {
+    forIn(classList, (enabled, name) => {
+      properties.push({name, enabled})
+    })
+  }
+
+  return properties
+}
diff --git a/src/forest/server/customDocument.ts b/src/forest/server/customDocument.ts
index 239ad2e4..b8c93a83 100644
--- a/src/forest/server/customDocument.ts
+++ b/src/forest/server/customDocument.ts
@@ -30,6 +30,7 @@ class DOMNode {
   nodeName = ''
   namespaceURI = 'http://www.w3.org/1999/xhtml' as const
   dataset = Object.create(null)
+  classList = new ClassList()
   style = new CSSStyle()
   value?: string
   firstChild: DOMNode | null = null
@@ -45,6 +46,7 @@ class DOMNode {
     result.namespaceURI = this.namespaceURI
     Object.assign(result.dataset, this.dataset)
     Object.assign(result.style, this.style)
+    result.classList = this.classList._clone()
     result.value = this.value
     result.isFragment = this.isFragment
     Object.assign(result.attributes, this.attributes)
@@ -151,6 +153,23 @@ class DOMNode {
     return node
   }
 }
+class ClassList {
+  classes: Set<string> = new Set()
+  add(className: string) {
+    this.classes.add(className)
+  }
+  remove(className: string) {
+    this.classes.delete(className)
+  }
+  _clone() {
+    const copy = new ClassList()
+    copy.classes = new Set(this.classes)
+    return copy
+  }
+  _toString() {
+    return Array.from(this.classes).join(' ')
+  }
+}
 class CSSStyle {
   setProperty(property: string, value: string): void {
     //@ts-expect-error
@@ -253,8 +272,22 @@ function renderPart(node: DOMNode, parts: string[]) {
     return
   }
   parts.push('<', node.tagName)
+  let classListRendered = false
   for (const key in node.attributes) {
-    parts.push(' ', key, '=', '"', escapeTagValue(node.attributes[key]), '"')
+    const attributeValue = node.attributes[key]
+    let tagValue: string = ''
+    if (key === 'class' && node.classList.classes.size > 0) {
+      const appendClasses = node.classList._toString()
+      tagValue = escapeTagValue(`${attributeValue} ${appendClasses}`)
+      classListRendered = true
+    } else {
+      tagValue = escapeTagValue(attributeValue)
+    }
+    parts.push(' ', key, '=', '"', tagValue, '"')
+  }
+  if (!classListRendered && node.classList.classes.size > 0) {
+    const classes = node.classList._toString()
+    parts.push(' class=', '"', escapeTagValue(classes), '"')
   }
   for (const key in node.dataset) {
     parts.push(


Test patch:
diff --git a/src/forest/__tests__/classList.test.ts b/src/forest/__tests__/classList.test.ts
new file mode 100644
index 00000000..42e4b93e
--- /dev/null
+++ b/src/forest/__tests__/classList.test.ts
@@ -0,0 +1,349 @@
+import type {BrowserObject} from 'webdriverio'
+import {createEvent, restore} from 'effector'
+import {h, spec, using} from 'forest'
+
+// let addGlobals: Function
+declare const act: (cb?: () => any) => Promise<void>
+declare const initBrowser: () => Promise<void>
+declare const el: HTMLElement
+// let execFun: <T>(cb: (() => Promise<T> | T) | string) => Promise<T>
+// let readHTML: () => string
+declare const browser: BrowserObject
+declare const exec: (cb: () => any) => Promise<string[]>
+declare const execFunc: <T>(cb: () => Promise<T>) => Promise<T>
+
+beforeEach(async () => {
+  await initBrowser()
+}, 10e3)
+
+it('supports setting static object class without class attr', async () => {
+  const [s1] = await exec(async () => {
+    using(el, () => {
+      h('div', {
+        text: 'content',
+        classList: {example: true, another: true},
+      })
+    })
+    await act()
+  })
+  expect(s1).toMatchInlineSnapshot(`
+      "
+      <div class='example another'>content</div>
+      "
+    `)
+})
+
+it('supports setting static object class without class attr', async () => {
+  const [s1] = await exec(async () => {
+    using(el, () => {
+      h('div', {
+        text: 'content',
+        classList: ['example', 'another'],
+      })
+    })
+    await act()
+  })
+  expect(s1).toMatchInlineSnapshot(`
+      "
+      <div class='example another'>content</div>
+      "
+    `)
+})
+it('supports setting static object class with class attr', async () => {
+  const [s1] = await exec(async () => {
+    using(el, () => {
+      h('div', {
+        text: 'content',
+        classList: {example: true, another: true},
+        attr: {class: 'foreign'},
+      })
+    })
+    await act()
+  })
+  expect(s1).toMatchInlineSnapshot(`
+      "
+      <div class='foreign example another'>content</div>
+      "
+    `)
+})
+it('supports setting static object class with class attr', async () => {
+  const [s1] = await exec(async () => {
+    using(el, () => {
+      h('div', {
+        text: 'content',
+        classList: ['example', 'another'],
+        attr: {class: 'foreign'},
+      })
+    })
+    await act()
+  })
+  expect(s1).toMatchInlineSnapshot(`
+      "
+      <div class='foreign example another'>content</div>
+      "
+    `)
+})
+it('supports setting dynamic object class', async () => {
+  const [s1, s2] = await exec(async () => {
+    const setClass = createEvent<boolean>()
+    const $class = restore(setClass, false)
+    using(el, () => {
+      h('div', {
+        text: 'content',
+        classList: {example: $class},
+        attr: {class: 'foreign'},
+      })
+    })
+    await act()
+    await act(() => {
+      setClass(true)
+    })
+  })
+  expect(s1).toMatchInlineSnapshot(`
+      "
+      <div class='foreign'>content</div>
+      "
+    `)
+  expect(s2).toMatchInlineSnapshot(`
+      "
+      <div class='foreign example'>content</div>
+      "
+    `)
+})
+it('supports setting dynamic array class', async () => {
+  const [s1, s2] = await exec(async () => {
+    const setClass = createEvent<string>()
+    const $class = restore(setClass, null)
+    using(el, () => {
+      h('div', {
+        text: 'content',
+        classList: [$class],
+        attr: {class: 'foreign'},
+      })
+    })
+    await act()
+    await act(() => {
+      setClass('example')
+    })
+  })
+  expect(s1).toMatchInlineSnapshot(`
+      "
+      <div class='foreign'>content</div>
+      "
+    `)
+  expect(s2).toMatchInlineSnapshot(`
+      "
+      <div class='foreign example'>content</div>
+      "
+    `)
+})
+it('supports setting dynamic object class without class property', async () => {
+  const [s1, s2] = await exec(async () => {
+    const setClass = createEvent<boolean>()
+    const $class = restore(setClass, false)
+    using(el, () => {
+      h('div', {
+        text: 'content',
+        classList: {example: $class},
+      })
+    })
+    await act()
+    await act(() => {
+      setClass(true)
+    })
+  })
+  expect(s1).toMatchInlineSnapshot(`
+      "
+      <div>content</div>
+      "
+    `)
+  expect(s2).toMatchInlineSnapshot(`
+      "
+      <div class='example'>content</div>
+      "
+    `)
+})
+it('supports setting dynamic array class without class property', async () => {
+  const [s1, s2, s3, s4] = await exec(async () => {
+    const setClass = createEvent<string | null>()
+    const $class = restore(setClass, null)
+    using(el, () => {
+      h('div', {
+        text: 'content',
+        classList: [$class],
+      })
+    })
+    await act()
+    await act(() => {
+      setClass('example')
+    })
+    await act(() => {
+      setClass('another')
+    })
+    await act(() => {
+      setClass(null)
+    })
+  })
+  expect(s1).toMatchInlineSnapshot(`
+      "
+      <div>content</div>
+      "
+    `)
+  expect(s2).toMatchInlineSnapshot(`
+      "
+      <div class='example'>content</div>
+      "
+    `)
+  expect(s3).toMatchInlineSnapshot(`
+      "
+      <div class='another'>content</div>
+      "
+    `)
+  expect(s4).toMatchInlineSnapshot(`
+      "
+      <div>content</div>
+      "
+    `)
+})
+
+it('supports merging static classList h with spec', async () => {
+  const [s1] = await exec(async () => {
+    using(el, () => {
+      h('div', {
+        text: 'content',
+        classList: ['first'],
+        fn() {
+          spec({classList: ['second']})
+        },
+      })
+    })
+    await act()
+  })
+  expect(s1).toMatchInlineSnapshot(`
+      "
+      <div class='second first'>content</div>
+      "
+    `)
+})
+
+it('supports merging static classList h with spec of different types', async () => {
+  const [s1] = await exec(async () => {
+    using(el, () => {
+      h('div', {
+        text: 'content',
+        classList: ['first'],
+        fn() {
+          spec({classList: ['second']})
+          spec({classList: {third: true}})
+        },
+      })
+    })
+    await act()
+  })
+  expect(s1).toMatchInlineSnapshot(`
+      "
+      <div class='second third first'>content</div>
+      "
+    `)
+})
+
+it('supports merging dynamic spec classList', async () => {
+  const [s1, s2, s3] = await exec(async () => {
+    const setClassA = createEvent<string>()
+    const $classA = restore(setClassA, null)
+    const setClassB = createEvent<boolean>()
+    const $classB = restore(setClassB, false)
+    using(el, () => {
+      h('div', {
+        text: 'content',
+        fn() {
+          spec({classList: [$classA]})
+          spec({classList: {third: $classB}})
+        },
+      })
+    })
+    await act()
+    await act(() => {
+      setClassA('demo')
+    })
+    await act(() => {
+      setClassB(true)
+    })
+  })
+  expect(s1).toMatchInlineSnapshot(`
+      "
+      <div>content</div>
+      "
+    `)
+  expect(s2).toMatchInlineSnapshot(`
+      "
+      <div class='demo'>content</div>
+      "
+    `)
+  expect(s3).toMatchInlineSnapshot(`
+      "
+      <div class='demo third'>content</div>
+      "
+    `)
+})
+
+it('supports merging dynamic and static spec classList', async () => {
+  const [s1, s2, s3, s4, s5] = await exec(async () => {
+    const cleanup = createEvent()
+    const setClass3 = createEvent<boolean>()
+    const $class3 = restore(setClass3, false).reset(cleanup)
+    const setClass4 = createEvent<string>()
+    const $class4 = restore(setClass4, null).reset(cleanup)
+    const setClass5 = createEvent<boolean>()
+    const $class5 = restore(setClass5, false).reset(cleanup)
+    using(el, () => {
+      h('div', {
+        text: 'content',
+        attr: {class: 'first'},
+        classList: {second: true, third: $class3},
+        fn() {
+          spec({classList: [$class4]})
+          spec({classList: {fifth: $class5}})
+        },
+      })
+    })
+    await act()
+    await act(() => {
+      setClass3(true)
+    })
+    await act(() => {
+      setClass4('fourth')
+    })
+    await act(() => {
+      setClass5(true)
+    })
+    await act(() => {
+      cleanup()
+    })
+  })
+  expect(s1).toMatchInlineSnapshot(`
+      "
+      <div class='first second'>content</div>
+      "
+    `)
+  expect(s2).toMatchInlineSnapshot(`
+      "
+      <div class='first second third'>content</div>
+      "
+    `)
+  expect(s3).toMatchInlineSnapshot(`
+      "
+      <div class='first second third fourth'>content</div>
+      "
+    `)
+  expect(s4).toMatchInlineSnapshot(`
+      "
+      <div class='first second third fourth fifth'>content</div>
+      "
+    `)
+  expect(s5).toMatchInlineSnapshot(`
+      "
+      <div class='first second'>content</div>
+      "
+    `)
+})
diff --git a/src/forest/__tests__/index.test.ts b/src/forest/__tests__/index.test.ts
index 0564f8f4..1a17d64d 100644
--- a/src/forest/__tests__/index.test.ts
+++ b/src/forest/__tests__/index.test.ts
@@ -1,18 +1,6 @@
 import type {BrowserObject} from 'webdriverio'
-import {createStore, createEvent, restore, combine, sample} from 'effector'
-import {
-  h,
-  using,
-  list,
-  remap,
-  spec,
-  variant,
-  node,
-  handler,
-  rec,
-  block,
-  text,
-} from 'forest'
+import {createEvent, createStore, restore, sample} from 'effector'
+import {block, h, list, node, rec, remap, spec, text, using} from 'forest'
 
 // let addGlobals: Function
 declare const act: (cb?: () => any) => Promise<void>
@@ -104,15 +92,15 @@ it('support reactive style properties', async () => {
     })
   })
   expect(s1).toMatchInlineSnapshot(`
-"
-<div style='justify-self: start'>content</div>
-"
-`)
+    "
+    <div style='justify-self: start'>content</div>
+    "
+  `)
   expect(s2).toMatchInlineSnapshot(`
-"
-<div style='justify-self: center'>content</div>
-"
-`)
+    "
+    <div style='justify-self: center'>content</div>
+    "
+  `)
 })
 
 describe('node(event) + upward store update', () => {
@@ -195,19 +183,19 @@ it('support reactive style variables', async () => {
     })
   })
   expect(s1).toMatchInlineSnapshot(`
-"
-<div style='justify-self: var(--align); --align: start'>
-  content
-</div>
-"
-`)
+    "
+    <div style='justify-self: var(--align); --align: start'>
+      content
+    </div>
+    "
+  `)
   expect(s2).toMatchInlineSnapshot(`
-"
-<div style='justify-self: var(--align); --align: center'>
-  content
-</div>
-"
-`)
+    "
+    <div style='justify-self: var(--align); --align: center'>
+      content
+    </div>
+    "
+  `)
 })
 
 it.skip('remove watch calls after node removal', async () => {
diff --git a/src/types/__tests__/forest/index.test.ts b/src/types/__tests__/forest/index.test.ts
index 720fe418..186c8341 100644
--- a/src/types/__tests__/forest/index.test.ts
+++ b/src/types/__tests__/forest/index.test.ts
@@ -32,7 +32,7 @@ describe('text', () => {
     expect(typecheck).toMatchInlineSnapshot(`
       "
       No overload matches this call.
-        Overload 1 of 2, '(tag: DOMTag, spec: { attr?: PropertyMap | undefined; data?: PropertyMap | undefined; text?: DOMProperty | AttributeStoreInput | (DOMProperty | AttributeStoreInput)[] | undefined; ... 4 more ...; fn?: (() => void) | undefined; }): void', gave the following error.
+        Overload 1 of 2, '(tag: DOMTag, spec: { attr?: PropertyMap | undefined; data?: PropertyMap | undefined; text?: DOMProperty | AttributeStoreInput | (DOMProperty | AttributeStoreInput)[] | undefined; ... 5 more ...; fn?: (() => void) | undefined; }): void', gave the following error.
           Type 'Store<{ value: string; } | null>' is not assignable to type 'DOMProperty | AttributeStoreInput | (DOMProperty | AttributeStoreInput)[] | undefined'.
             Type 'Store<{ value: string; } | null>' is not assignable to type 'AttributeStoreInput'.
               Types of property 'map' are incompatible.
@@ -87,7 +87,7 @@ describe('dom tag typecheck', () => {
     expect(typecheck).toMatchInlineSnapshot(`
       "
       No overload matches this call.
-        Overload 1 of 2, '(tag: DOMTag, spec: { attr?: PropertyMap | undefined; data?: PropertyMap | undefined; text?: DOMProperty | AttributeStoreInput | (DOMProperty | AttributeStoreInput)[] | undefined; ... 4 more ...; fn?: (() => void) | undefined; }): void', gave the following error.
+        Overload 1 of 2, '(tag: DOMTag, spec: { attr?: PropertyMap | undefined; data?: PropertyMap | undefined; text?: DOMProperty | AttributeStoreInput | (DOMProperty | AttributeStoreInput)[] | undefined; ... 5 more ...; fn?: (() => void) | undefined; }): void', gave the following error.
           Argument of type '\\"damn\\"' is not assignable to parameter of type 'DOMTag'.
         Overload 2 of 2, '(tag: DOMTag, cb: () => void): void', gave the following error.
           Argument of type '\\"damn\\"' is not assignable to parameter of type 'DOMTag'.