@pyreon/runtime-dom 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * REPRODUCTION + REGRESSION: the `_errorBoundaryStack.pop()` cleanup is
3
+ * position-based — same bug class as the `popContext()` bug fixed in #725.
4
+ *
5
+ * Scenario: two or more sibling `<ErrorBoundary>` boundaries. When a NON-LAST
6
+ * boundary unmounts (keyed `<For>` removing the first item, `<Show>` flipping
7
+ * the first of several siblings, route nav unmounting the outer of nested
8
+ * routes, etc.), its `onUnmount` calls `popErrorBoundary()` → `stack.pop()`
9
+ * → pops the LAST (innermost) boundary's handler — the wrong one.
10
+ *
11
+ * Outcome:
12
+ * - Subsequent errors in the SURVIVING boundary's children route to whatever
13
+ * handler is now at `stack[length-1]`, which is the stale handler of an
14
+ * ALREADY-UNMOUNTED boundary. Calling `error.set(err)` on that handler's
15
+ * captured signal is a no-op → the error is silently swallowed AND the
16
+ * surviving boundary's fallback never renders.
17
+ *
18
+ * Fix (#725-class): `popErrorBoundary(handler)` uses `lastIndexOf + splice`
19
+ * to remove by IDENTITY. Each ErrorBoundary's `onUnmount` passes its own
20
+ * handler reference, so unmount in any order correctly removes the right
21
+ * handler.
22
+ */
23
+ import type { VNodeChild } from '@pyreon/core'
24
+ import { ErrorBoundary, h, Show } from '@pyreon/core'
25
+ import { signal } from '@pyreon/reactivity'
26
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
27
+ import { mount } from '..'
28
+
29
+ describe('ErrorBoundary — module-level stack cleanup is identity-safe (#725 class)', () => {
30
+ let container: HTMLElement
31
+ let errorSpy: ReturnType<typeof vi.spyOn>
32
+
33
+ beforeEach(() => {
34
+ container = document.createElement('div')
35
+ document.body.appendChild(container)
36
+ errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
37
+ })
38
+ afterEach(() => {
39
+ container.remove()
40
+ errorSpy.mockRestore()
41
+ })
42
+
43
+ // Tiny `h`-builder helpers so the tests stay readable.
44
+ const eb = (testId: string, ...children: VNodeChild[]) =>
45
+ h(
46
+ ErrorBoundary,
47
+ {
48
+ fallback: (err: unknown) =>
49
+ h('div', { 'data-testid': `fb-${testId}` }, `caught(${testId}): ${String(err)}`),
50
+ children: children.length === 1 ? children[0] : children,
51
+ },
52
+ )
53
+
54
+ const showWhen = (when: () => boolean, child: () => VNodeChild) =>
55
+ h(Show, { when, children: child })
56
+
57
+ it('REGRESSION: surviving sibling boundary still catches errors after a sibling unmounts (FIRST unmounted)', () => {
58
+ const showA = signal(false)
59
+ const showB = signal(false)
60
+ const aliveA = signal(true)
61
+
62
+ function Bomb({ name }: { name: string }): never {
63
+ throw new Error(`boom-${name}`)
64
+ }
65
+
66
+ const App = () =>
67
+ h(
68
+ 'div',
69
+ null,
70
+ // Boundary A — wrapped in Show so we can UNMOUNT it without
71
+ // touching boundary B.
72
+ showWhen(
73
+ () => aliveA(),
74
+ () => eb('A', showWhen(() => showA(), () => h(Bomb, { name: 'A' }))),
75
+ ),
76
+ // Boundary B — always mounted.
77
+ eb('B', showWhen(() => showB(), () => h(Bomb, { name: 'B' }))),
78
+ )
79
+
80
+ const unmount = mount(h(App, null), container)
81
+
82
+ expect(container.querySelector('[data-testid="fb-A"]')).toBeNull()
83
+ expect(container.querySelector('[data-testid="fb-B"]')).toBeNull()
84
+
85
+ // UNMOUNT boundary A (FIRST sibling). Pre-fix: popErrorBoundary() pops
86
+ // the LAST frame — B's handler — instead of A's.
87
+ aliveA.set(false)
88
+
89
+ // Now trigger a throw inside B's children. With B's handler correctly
90
+ // still on the stack (post-fix), B's fallback should render.
91
+ showB.set(true)
92
+
93
+ const fbB = container.querySelector('[data-testid="fb-B"]')
94
+ expect(fbB).toBeTruthy()
95
+ expect(fbB?.textContent).toContain('caught(B): Error: boom-B')
96
+
97
+ // And the throw must NOT have been routed to A's (stale) fallback.
98
+ expect(container.querySelector('[data-testid="fb-A"]')).toBeNull()
99
+
100
+ unmount()
101
+ })
102
+
103
+ it('LIFO case: surviving FIRST boundary catches errors after a LATER sibling unmounts', () => {
104
+ // Pre-fix this case worked (LIFO held for last-unmount). Included
105
+ // as a guard against the fix regressing the LIFO case.
106
+ const showA = signal(false)
107
+ const aliveB = signal(true)
108
+
109
+ function Bomb({ name }: { name: string }): never {
110
+ throw new Error(`boom-${name}`)
111
+ }
112
+
113
+ const App = () =>
114
+ h(
115
+ 'div',
116
+ null,
117
+ eb('A', showWhen(() => showA(), () => h(Bomb, { name: 'A' }))),
118
+ showWhen(() => aliveB(), () => eb('B', null)),
119
+ )
120
+
121
+ const unmount = mount(h(App, null), container)
122
+
123
+ // Unmount B — LIFO case (last sibling). Both pre- and post-fix correct.
124
+ aliveB.set(false)
125
+
126
+ showA.set(true)
127
+ const fbA = container.querySelector('[data-testid="fb-A"]')
128
+ expect(fbA).toBeTruthy()
129
+ expect(fbA?.textContent).toContain('caught(A): Error: boom-A')
130
+
131
+ unmount()
132
+ })
133
+ })
@@ -0,0 +1,140 @@
1
+ import { For, h } from '@pyreon/core'
2
+ import type { ForProps, VNode } from '@pyreon/core'
3
+ import { signal } from '@pyreon/reactivity'
4
+ import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
5
+ import { describe, expect, it } from 'vitest'
6
+
7
+ // Regression — same closure-captured-parent bug class as the For-of-Show
8
+ // fix in #776, but exercising the SIBLING reactive entry point
9
+ // `mountKeyedList` (the inline reactive keyed array shape).
10
+ //
11
+ // **Bug class (recap from #776):** any reactive mount loop that captures
12
+ // `parent` in its setup closure breaks when a containing reconciler
13
+ // creates the subtree in a `DocumentFragment` and then moves it via
14
+ // `liveParent.insertBefore(frag, tailMarker)`. The markers move with
15
+ // the fragment contents; the captured `parent` becomes a stale
16
+ // reference to the now-empty fragment. Next signal flip → throw +
17
+ // child-loss.
18
+ //
19
+ // **`mountKeyedList`'s exposure**: three sites in `nodes.ts:mountKeyedList`
20
+ // use closure-captured `parent`:
21
+ // 1. `parent.insertBefore(anchor, tailMarker)` in mountNewEntries
22
+ // 2. `mountVNode(vnode, parent, tailMarker)` immediately after
23
+ // 3. `keyedListReorder(..., parent, tailMarker)` → applyKeyedMoves
24
+ // → moveEntryBefore → `parent.insertBefore(node, before)`
25
+ //
26
+ // All three run inside the `effect(() => ...)` body, so any post-setup
27
+ // move of the markers (via a containing mountFor's frag-then-move) plus
28
+ // a subsequent signal-driven re-run (mount new entries OR reorder)
29
+ // throws NotFoundError.
30
+ //
31
+ // **Trigger requires the keyed array to land DIRECTLY in the For's
32
+ // fragment** (no wrapping Element). The cleanest shape: For children
33
+ // returns a function `(i) => () => signal().map(...)` — mountFor's
34
+ // renderInto calls `mountChild(fn, frag, before)`, mountChild's
35
+ // function branch samples the result, sees a keyed array, and creates
36
+ // `mountKeyedList(fn, frag, before, ...)`. Now `parent === frag` in
37
+ // the closure, and the bug fires identically to the For-of-Show case.
38
+ // A `<div>`-wrapped keyed array would NOT trigger the bug — the div
39
+ // is the element parent, isolating mountKeyedList from the frag move.
40
+
41
+ describe('mountKeyedList: inline keyed array as direct For child under batched signal additions', () => {
42
+ it('sanity: top-level inline keyed array handles add/reorder cycles correctly', async () => {
43
+ // No For wrapper — mountKeyedList is the top-level reconciler.
44
+ // Should always work — no frag-then-move pressure on the captured parent.
45
+ const items = signal<{ id: number }[]>([{ id: 0 }])
46
+ const { container, unmount } = mountInBrowser(
47
+ h(
48
+ 'div',
49
+ { id: 'root' },
50
+ () => items().map((v) => h('span', { key: v.id, 'data-id': String(v.id) }, String(v.id))),
51
+ ),
52
+ )
53
+ await flush()
54
+ expect(container.querySelectorAll('span[data-id]')).toHaveLength(1)
55
+
56
+ items.set([{ id: 0 }, { id: 1 }, { id: 2 }])
57
+ await flush()
58
+ expect(container.querySelectorAll('span[data-id]')).toHaveLength(3)
59
+
60
+ // Reorder — exercises mountKeyedList's keyedListReorder path
61
+ items.set([{ id: 2 }, { id: 0 }, { id: 1 }])
62
+ await flush()
63
+ expect(container.querySelectorAll('span[data-id]')).toHaveLength(3)
64
+
65
+ items.set([])
66
+ await flush()
67
+ expect(container.querySelectorAll('span[data-id]')).toHaveLength(0)
68
+ unmount()
69
+ })
70
+
71
+ it('CONTRACT: <For> + DIRECT keyed-array function child does not throw NotFoundError or lose children', async () => {
72
+ // 10 For rows. Each row's children function returns a FUNCTION
73
+ // `() => signal().map(...)` directly — NOT wrapped in a `<div>`.
74
+ // mountFor's renderInto then calls `mountChild(fn, frag, before)`,
75
+ // mountChild creates `mountKeyedList(fn, frag, before, ...)` —
76
+ // capturing the For's DocumentFragment as `parent`. Once the frag
77
+ // moves to the live parent, the closure-captured `parent` is stale.
78
+ //
79
+ // The initial signal value MUST be a non-empty keyed array for
80
+ // `isKeyedArray(sample)` (mount.ts) to route into mountKeyedList;
81
+ // an empty initial array would route into mountReactive instead
82
+ // (already covered by the For-of-Show fix in #776). Each row starts
83
+ // with one item so we land on mountKeyedList, then we grow each
84
+ // row's array — mountKeyedList's mountNewEntries calls
85
+ // `parent.insertBefore(anchor, tailMarker)` which throws against
86
+ // the stale frag unless the live-parent fix is applied.
87
+ const itemSignals = Array.from({ length: 10 }, (_, rowIdx) =>
88
+ signal<{ id: number }[]>([{ id: rowIdx * 100 }]),
89
+ )
90
+ const indices = Array.from({ length: 10 }, (_, i) => i)
91
+
92
+ // Cast: ForProps.children narrows to `(item: T) => VNode | NativeItem`,
93
+ // but the runtime ALSO accepts a function return (mount.ts's mountChild
94
+ // function branch handles it). This shape is exactly what triggers
95
+ // mountKeyedList with frag-as-parent — the public type doesn't expose
96
+ // it, so we cast through an explicit ForProps<number> shape.
97
+ const forProps: ForProps<number> = {
98
+ each: indices,
99
+ by: (i: number) => i,
100
+ // children returns a FUNCTION (not a VNode). That function returns
101
+ // a keyed array — mountChild's function branch routes it to
102
+ // mountKeyedList with frag as parent.
103
+ children: ((rowIdx: number) =>
104
+ () =>
105
+ (itemSignals[rowIdx] as ReturnType<typeof signal<{ id: number }[]>>)().map((v) =>
106
+ h('span', { key: v.id, 'data-rowitem': `${rowIdx}-${v.id}` }, String(v.id)),
107
+ )) as unknown as (item: number) => VNode,
108
+ }
109
+ const { container, unmount } = mountInBrowser(
110
+ h('div', { id: 'root' }, For(forProps)),
111
+ )
112
+ await flush()
113
+ try {
114
+ // Sanity: one item per row at mount, 10 total.
115
+ expect(container.querySelectorAll('span[data-rowitem]')).toHaveLength(10)
116
+
117
+ // Grow every row's array to 5 items each — 10 rows × 5 = 50.
118
+ // Bug fires HERE on EVERY row: mountKeyedList's mountNewEntries
119
+ // calls `parent.insertBefore(anchor, tailMarker)` against the
120
+ // stale frag captured at the For's initial mount.
121
+ for (let r = 0; r < 10; r++) {
122
+ const s = itemSignals[r] as ReturnType<typeof signal<{ id: number }[]>>
123
+ s.set([0, 1, 2, 3, 4].map((id) => ({ id: r * 100 + id })))
124
+ }
125
+ await flush()
126
+
127
+ expect(container.querySelectorAll('span[data-rowitem]')).toHaveLength(50)
128
+
129
+ // Reorder one row — exercises mountKeyedList's reorder path
130
+ // (keyedListReorder → applyKeyedMoves → moveEntryBefore → stale
131
+ // `parent.insertBefore(startNode, before)`). All items must remain.
132
+ const s0 = itemSignals[0] as ReturnType<typeof signal<{ id: number }[]>>
133
+ s0.set([4, 0, 2, 1, 3].map((id) => ({ id: id })))
134
+ await flush()
135
+ expect(container.querySelectorAll('span[data-rowitem]')).toHaveLength(50)
136
+ } finally {
137
+ unmount()
138
+ }
139
+ })
140
+ })
@@ -0,0 +1,303 @@
1
+ import { signal } from '@pyreon/reactivity'
2
+ import { flush } from '@pyreon/test-utils/browser'
3
+ import { afterEach, describe, expect, it } from 'vitest'
4
+ import { _rsCollapseDynH, mount } from '../index'
5
+
6
+ // Runtime helper for the HANDLER-COMBINED dynamic-collapse slice — the
7
+ // follow-up to the strict no-handler PR sequence (#765/#766/#767/#771).
8
+ // Closes the largest remaining real-corpus dynamic-collapse gap: the
9
+ // `<Button state={cond ? 'a' : 'b'} onClick={h}>` shape that PR #767's
10
+ // `tryDynamicCollapse` BAILED by design ("PR 3 scope: no-handler only").
11
+ //
12
+ // Structurally the union of:
13
+ // - `_rsCollapseDyn`'s stride-2 value-major class dispatch
14
+ // - `_rsCollapseH`'s handler re-attachment via `_bindEvent`
15
+ //
16
+ // Proves what `_rsCollapseDynH` owns:
17
+ // 1. Class dispatch identical to `_rsCollapseDyn` (value flip / mode
18
+ // flip / combined flip all patch className IN PLACE on SAME node)
19
+ // 2. Handlers attached + dispatched correctly across value flips
20
+ // (handler identity preserved; same el; click fires once per click)
21
+ // 3. Both dispatchers + handlers compose without interference —
22
+ // handler stays attached after a value flip, value dispatch keeps
23
+ // working after handler invocation, mode flip doesn't break handlers
24
+ // 4. Disposers chained — class, all handlers, children all cleaned up
25
+ //
26
+ // Layer-pure: no @pyreon/styler import — CSS via plain <style> just
27
+ // like the existing rs-collapse-dyn / rs-collapse-h suites.
28
+
29
+ describe('_rsCollapseDynH (real browser)', () => {
30
+ const cleanup: Array<() => void> = []
31
+ afterEach(() => {
32
+ for (const u of cleanup.splice(0)) u()
33
+ })
34
+
35
+ function injectCss(css: string): void {
36
+ const el = document.createElement('style')
37
+ el.textContent = css
38
+ document.head.appendChild(el)
39
+ cleanup.push(() => el.remove())
40
+ }
41
+
42
+ function mountInto(node: ReturnType<typeof _rsCollapseDynH>): HTMLElement {
43
+ const root = document.createElement('div')
44
+ document.body.appendChild(root)
45
+ const dispose = mount(node as unknown as Parameters<typeof mount>[0], root)
46
+ cleanup.push(() => {
47
+ dispose()
48
+ root.remove()
49
+ })
50
+ return root
51
+ }
52
+
53
+ const ternaryClasses = (prefix: string): readonly string[] => [
54
+ `${prefix}-v0-light`,
55
+ `${prefix}-v0-dark`,
56
+ `${prefix}-v1-light`,
57
+ `${prefix}-v1-dark`,
58
+ ]
59
+
60
+ it('cold mount picks value=0 + light + attaches handlers', async () => {
61
+ injectCss(`
62
+ .rdh1-v0-light{color:rgb(1,1,1)}.rdh1-v0-dark{color:rgb(2,2,2)}
63
+ .rdh1-v1-light{color:rgb(3,3,3)}.rdh1-v1-dark{color:rgb(4,4,4)}
64
+ `)
65
+ const cond = signal(false)
66
+ const isDark = signal(false)
67
+ let clicks = 0
68
+ const root = mountInto(
69
+ _rsCollapseDynH(
70
+ '<button>Save</button>',
71
+ ternaryClasses('rdh1'),
72
+ () => (cond() ? 1 : 0),
73
+ () => isDark(),
74
+ { onClick: () => clicks++ },
75
+ ),
76
+ )
77
+ await flush()
78
+ const btn = root.querySelector('button') as HTMLElement
79
+ expect(btn.className).toBe('rdh1-v0-light')
80
+ btn.click()
81
+ expect(clicks).toBe(1)
82
+ })
83
+
84
+ it('value flip swaps class on SAME node — handler stays attached', async () => {
85
+ injectCss(`
86
+ .rdh2-v0-light{color:rgb(10,10,10)}.rdh2-v0-dark{color:rgb(20,20,20)}
87
+ .rdh2-v1-light{color:rgb(30,30,30)}.rdh2-v1-dark{color:rgb(40,40,40)}
88
+ `)
89
+ const cond = signal(false)
90
+ const isDark = signal(false)
91
+ let clicks = 0
92
+ const root = mountInto(
93
+ _rsCollapseDynH(
94
+ '<button>X</button>',
95
+ ternaryClasses('rdh2'),
96
+ () => (cond() ? 1 : 0),
97
+ () => isDark(),
98
+ { onClick: () => clicks++ },
99
+ ),
100
+ )
101
+ await flush()
102
+ const before = root.querySelector('button') as HTMLElement
103
+ before.click()
104
+ expect(clicks).toBe(1)
105
+
106
+ cond.set(true)
107
+ await flush()
108
+ const after = root.querySelector('button') as HTMLElement
109
+ expect(after).toBe(before) // node identity preserved
110
+ expect(after.className).toBe('rdh2-v1-light')
111
+
112
+ // Handler still attached post-value-flip — same closure, same callback identity
113
+ after.click()
114
+ expect(clicks).toBe(2)
115
+ })
116
+
117
+ it('mode flip swaps class on SAME node — handler stays attached (preserves _rsCollapseH contract)', async () => {
118
+ injectCss(`
119
+ .rdh3-v0-light{color:rgb(50,50,50)}.rdh3-v0-dark{color:rgb(60,60,60)}
120
+ .rdh3-v1-light{color:rgb(70,70,70)}.rdh3-v1-dark{color:rgb(80,80,80)}
121
+ `)
122
+ const cond = signal(true) // pin valueIndex to 1
123
+ const isDark = signal(false)
124
+ let clicks = 0
125
+ const root = mountInto(
126
+ _rsCollapseDynH(
127
+ '<button>Y</button>',
128
+ ternaryClasses('rdh3'),
129
+ () => (cond() ? 1 : 0),
130
+ () => isDark(),
131
+ { onClick: () => clicks++ },
132
+ ),
133
+ )
134
+ await flush()
135
+ const before = root.querySelector('button') as HTMLElement
136
+ expect(before.className).toBe('rdh3-v1-light')
137
+
138
+ isDark.set(true)
139
+ await flush()
140
+ const after = root.querySelector('button') as HTMLElement
141
+ expect(after).toBe(before)
142
+ expect(after.className).toBe('rdh3-v1-dark')
143
+ after.click()
144
+ expect(clicks).toBe(1)
145
+ })
146
+
147
+ it('combined value + mode flip lands on the right class — handler invariant across all 4 combinations', async () => {
148
+ injectCss(`
149
+ .rdh4-v0-light{color:rgb(11,11,11)}.rdh4-v0-dark{color:rgb(22,22,22)}
150
+ .rdh4-v1-light{color:rgb(33,33,33)}.rdh4-v1-dark{color:rgb(44,44,44)}
151
+ `)
152
+ const cond = signal(false)
153
+ const isDark = signal(false)
154
+ let clicks = 0
155
+ const root = mountInto(
156
+ _rsCollapseDynH(
157
+ '<button>Z</button>',
158
+ ternaryClasses('rdh4'),
159
+ () => (cond() ? 1 : 0),
160
+ () => isDark(),
161
+ { onClick: () => clicks++ },
162
+ ),
163
+ )
164
+ await flush()
165
+ const btn = root.querySelector('button') as HTMLElement
166
+
167
+ expect(btn.className).toBe('rdh4-v0-light')
168
+ btn.click()
169
+ isDark.set(true)
170
+ await flush()
171
+ expect(btn.className).toBe('rdh4-v0-dark')
172
+ btn.click()
173
+ cond.set(true)
174
+ await flush()
175
+ expect(btn.className).toBe('rdh4-v1-dark')
176
+ btn.click()
177
+ isDark.set(false)
178
+ await flush()
179
+ expect(btn.className).toBe('rdh4-v1-light')
180
+ btn.click()
181
+ // 4 clicks across 4 combinations, all on the SAME node
182
+ expect(clicks).toBe(4)
183
+ })
184
+
185
+ it('multiple handlers — all attach + survive value/mode flips', async () => {
186
+ injectCss(`.rdh5-v0-light{color:rgb(1,1,1)}.rdh5-v0-dark{color:rgb(2,2,2)}.rdh5-v1-light{color:rgb(3,3,3)}.rdh5-v1-dark{color:rgb(4,4,4)}`)
187
+ const cond = signal(false)
188
+ const isDark = signal(false)
189
+ let clicks = 0
190
+ let enters = 0
191
+ const root = mountInto(
192
+ _rsCollapseDynH(
193
+ '<button>M</button>',
194
+ ternaryClasses('rdh5'),
195
+ () => (cond() ? 1 : 0),
196
+ () => isDark(),
197
+ {
198
+ onClick: () => clicks++,
199
+ onPointerEnter: () => enters++,
200
+ },
201
+ ),
202
+ )
203
+ await flush()
204
+ const btn = root.querySelector('button') as HTMLElement
205
+ btn.click()
206
+ btn.dispatchEvent(new PointerEvent('pointerenter'))
207
+ expect(clicks).toBe(1)
208
+ expect(enters).toBe(1)
209
+
210
+ cond.set(true)
211
+ await flush()
212
+ btn.click()
213
+ btn.dispatchEvent(new PointerEvent('pointerenter'))
214
+ expect(clicks).toBe(2)
215
+ expect(enters).toBe(2)
216
+ })
217
+
218
+ it('out-of-range valueIndex coerces to empty className — handlers still work', async () => {
219
+ // Same graceful-degradation contract as `_rsCollapseDyn`: an out-
220
+ // of-range index is documented as "compiler is source of truth";
221
+ // empty className is the runtime fallback, never a crash. Handlers
222
+ // are orthogonal to class dispatch and must keep working.
223
+ injectCss(`.rdh6-v0-light{color:rgb(5,5,5)}.rdh6-v0-dark{color:rgb(6,6,6)}`)
224
+ let clicks = 0
225
+ const root = mountInto(
226
+ _rsCollapseDynH(
227
+ '<button>Bad</button>',
228
+ ['rdh6-v0-light', 'rdh6-v0-dark'], // only ONE value
229
+ () => 7, // BUG-shaped: out of range
230
+ () => false,
231
+ { onClick: () => clicks++ },
232
+ ),
233
+ )
234
+ await flush()
235
+ const btn = root.querySelector('button') as HTMLElement
236
+ expect(btn.className).toBe('') // graceful
237
+ btn.click()
238
+ expect(clicks).toBe(1) // handler unaffected
239
+ })
240
+
241
+ it('children binder runs alongside class + handlers; ALL three dispose with the host', async () => {
242
+ injectCss(`.rdh7-v0-light{color:rgb(7,7,7)}.rdh7-v0-dark{color:rgb(8,8,8)}.rdh7-v1-light{color:rgb(9,9,9)}.rdh7-v1-dark{color:rgb(11,11,11)}`)
243
+ const cond = signal(false)
244
+ const isDark = signal(false)
245
+ let clicks = 0
246
+ let childDisposed = false
247
+ const root = mountInto(
248
+ _rsCollapseDynH(
249
+ '<button><span></span></button>',
250
+ ternaryClasses('rdh7'),
251
+ () => (cond() ? 1 : 0),
252
+ () => isDark(),
253
+ { onClick: () => clicks++ },
254
+ (el) => {
255
+ const span = el.querySelector('span') as HTMLElement
256
+ span.textContent = 'child'
257
+ return () => {
258
+ childDisposed = true
259
+ }
260
+ },
261
+ ),
262
+ )
263
+ await flush()
264
+ expect((root.querySelector('span') as HTMLElement).textContent).toBe('child')
265
+ ;(root.querySelector('button') as HTMLElement).click()
266
+ expect(clicks).toBe(1)
267
+
268
+ // Disposing the host (via cleanup) must fire ALL three disposers:
269
+ // class binding, handler bindings, child binder. Bisect-load-bearing
270
+ // for the disposer-chain composition shape.
271
+ cleanup.splice(0).forEach((u) => u())
272
+ expect(childDisposed).toBe(true)
273
+ })
274
+
275
+ it('zero handlers (empty {}) degenerates to `_rsCollapseDyn`-equivalent shape', async () => {
276
+ // Useful structural assertion: `_rsCollapseDynH` with no handlers
277
+ // behaves identically to `_rsCollapseDyn`. Guards the union as a
278
+ // strict superset — emit can always route to DynH; lighter-weight
279
+ // Dyn is just the no-handler optimization.
280
+ injectCss(`.rdh8-v0-light{color:rgb(80,80,80)}.rdh8-v0-dark{color:rgb(90,90,90)}.rdh8-v1-light{color:rgb(100,100,100)}.rdh8-v1-dark{color:rgb(110,110,110)}`)
281
+ const cond = signal(false)
282
+ const isDark = signal(false)
283
+ const root = mountInto(
284
+ _rsCollapseDynH(
285
+ '<button>Solo</button>',
286
+ ternaryClasses('rdh8'),
287
+ () => (cond() ? 1 : 0),
288
+ () => isDark(),
289
+ {}, // no handlers
290
+ ),
291
+ )
292
+ await flush()
293
+ const btn = root.querySelector('button') as HTMLElement
294
+ expect(btn.className).toBe('rdh8-v0-light')
295
+
296
+ cond.set(true)
297
+ await flush()
298
+ expect(btn.className).toBe('rdh8-v1-light')
299
+ isDark.set(true)
300
+ await flush()
301
+ expect(btn.className).toBe('rdh8-v1-dark')
302
+ })
303
+ })