@pyreon/runtime-dom 0.23.0 → 0.24.1

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.
package/src/template.ts CHANGED
@@ -280,7 +280,192 @@ export function _rsCollapseH(
280
280
  el.className = v ? darkClass : lightClass
281
281
  })
282
282
  const handlerDisposers: (() => void)[] = []
283
- for (const key in handlers) {
283
+ // `Object.keys` (not `for...in`) so an attacker who pollutes
284
+ // `Object.prototype` can't inject a fake handler via inherited
285
+ // enumerable properties. Defense-in-depth — the compiler emits a
286
+ // clean object literal so this matters defensively, not in
287
+ // practice, but the cost is zero.
288
+ for (const key of Object.keys(handlers)) {
289
+ const d = _bindEvent(el, key, handlers[key])
290
+ if (d) handlerDisposers.push(d)
291
+ }
292
+ const disposeChildren = bind ? bind(el) : null
293
+ return () => {
294
+ disposeClass()
295
+ for (const d of handlerDisposers) d()
296
+ if (disposeChildren) disposeChildren()
297
+ }
298
+ })
299
+ }
300
+
301
+ /**
302
+ * Compiler-emitted DYNAMIC-prop collapsed rocketstyle call site — PR 1
303
+ * of the dynamic-prop partial-collapse build (next bite after the
304
+ * `on*`-handler partial-collapse `_rsCollapseH`, `.claude/plans/open-work-2026-q3.md`
305
+ * → #1 dynamic-prop bucket = 15.3% of all real-corpus sites).
306
+ *
307
+ * Generalises {@link _rsCollapse}'s 2-class (light/dark) dispatch to an
308
+ * N-class dispatch for sites where one dimension prop is an enumerable
309
+ * dynamic expression (e.g. `<Button state={cond ? 'primary' : 'secondary'}>`).
310
+ * The compiler resolves EVERY value of that prop through the existing
311
+ * SSR-render resolver (so each value gets its own light + dark class
312
+ * baked in, byte-identical to a `_rsCollapse` site for that value), and
313
+ * the runtime picks the right `(value × mode)` class via the user's
314
+ * expression.
315
+ *
316
+ * Class layout in `classes` is **stride-2, value-major**: index
317
+ * `2 * valueIndex + (isDark ? 1 : 0)`. For the canonical ternary case:
318
+ *
319
+ * ```
320
+ * <Button state={cond ? 'primary' : 'secondary'}>Save</Button>
321
+ * →
322
+ * __rsCollapseDyn(
323
+ * "<button>Save</button>",
324
+ * ["btn-primary-light", "btn-primary-dark", "btn-secondary-light", "btn-secondary-dark"],
325
+ * () => cond ? 0 : 1,
326
+ * () => __pyrMode() === "dark"
327
+ * )
328
+ * ```
329
+ *
330
+ * Both the value expression AND the mode accessor are reactive: a change
331
+ * to either re-runs ONLY this className assignment, no remount (same
332
+ * contract as `_rsCollapse`'s mode flip). Both dispatches share a single
333
+ * `_bindDirect` so reading both inside one effect subscribes once per
334
+ * source — Pyreon's effect dedupe handles the rest.
335
+ *
336
+ * The structural HTML template is shared across every value (asserted
337
+ * by the resolver — divergent markup between values bails the collapse).
338
+ * Mirrors `_rsCollapse`'s mode-divergence-bails invariant.
339
+ *
340
+ * `bind` follows the same contract as `_rsCollapse` — standard `_tpl`
341
+ * child/event binder, runs after class binding, disposers chained.
342
+ *
343
+ * @param html static element HTML WITHOUT the root `class=` attr
344
+ * @param classes flat array of `2 × valueCount` class strings,
345
+ * indexed `[v0_light, v0_dark, v1_light, v1_dark, ...]`. The runtime
346
+ * does no validation — the compiler is the source of truth (an
347
+ * out-of-range `valueIndex()` would coerce to `undefined` className,
348
+ * which is correct-for-zero-style — never crashes)
349
+ * @param valueIndex user expression returning 0..valueCount-1 — reactive
350
+ * @param isDark app mode accessor — reactive
351
+ * @param bind standard _tpl binder for children/events (or null)
352
+ */
353
+ export function _rsCollapseDyn(
354
+ html: string,
355
+ classes: readonly string[],
356
+ valueIndex: () => number,
357
+ isDark: () => boolean,
358
+ bind?: ((el: HTMLElement) => (() => void) | null) | null,
359
+ ): NativeItem {
360
+ return _tpl(html, (el) => {
361
+ // One `renderEffect` drives the className from both accessors;
362
+ // reading `valueIndex()` AND `isDark()` inside the callback
363
+ // subscribes to BOTH live signals via Pyreon's tracking — a change
364
+ // to EITHER re-runs only this className assignment, no remount.
365
+ //
366
+ // Direct `renderEffect` (vs the `_bindDirect` indirection used by
367
+ // `_rsCollapse`): the `_bindDirect` fallback path calls the source
368
+ // function ONCE per re-run and passes the result to the callback.
369
+ // We were ignoring that result and calling `valueIndex()` again
370
+ // inside — i.e., a double call per re-run. Side-effecting cond
371
+ // expressions (`{(modifyState(), cond) ? 'a' : 'b'}`) would fire
372
+ // their side-effects twice. Direct `renderEffect` calls
373
+ // `valueIndex()` exactly once per re-run, matching the original
374
+ // source's call-count contract.
375
+ const disposeClass = renderEffect(() => {
376
+ const idx = (valueIndex() << 1) | (isDark() ? 1 : 0)
377
+ el.className = classes[idx] ?? ''
378
+ })
379
+ const disposeChildren = bind ? bind(el) : null
380
+ if (!disposeChildren) return disposeClass
381
+ return () => {
382
+ disposeClass()
383
+ disposeChildren()
384
+ }
385
+ })
386
+ }
387
+
388
+ /**
389
+ * Compiler-emitted DYNAMIC-prop + HANDLER collapsed rocketstyle call
390
+ * site — closes the largest remaining real-corpus dynamic-collapse
391
+ * gap (`.claude/plans/open-work-2026-q3.md` → #1 dynamic-prop bucket
392
+ * = 15.4% of all real-corpus sites; the strict no-handler subset was
393
+ * only 0.2% measured; this helper unlocks the handler-combined slice
394
+ * that was bailed by `tryDynamicCollapse` in PR #767 by design).
395
+ *
396
+ * Combines {@link _rsCollapseDyn}'s value-major class dispatch with
397
+ * {@link _rsCollapseH}'s handler re-attachment. Handlers are orthogonal
398
+ * to both the SSR-resolved styler class AND the value dispatcher (a
399
+ * `state={cond ? 'a' : 'b'} onClick={h}` site's onClick is identical
400
+ * for both `state="a"` and `state="b"` resolutions — the styler class
401
+ * varies, the handler does not). So this helper is structurally the
402
+ * union of the two, no new behavior:
403
+ *
404
+ * ```
405
+ * <Button state={cond ? 'primary' : 'secondary'} onClick={go}>Save</Button>
406
+ * →
407
+ * __rsCollapseDynH(
408
+ * "<button>Save</button>",
409
+ * ["pri-L", "pri-D", "sec-L", "sec-D"],
410
+ * () => cond ? 0 : 1,
411
+ * () => __pyrMode() === "dark",
412
+ * { onClick: go }
413
+ * )
414
+ * ```
415
+ *
416
+ * Class layout matches `_rsCollapseDyn` (stride-2 value-major):
417
+ * `index = 2 * valueIndex + (isDark ? 1 : 0)`. Handler attachment
418
+ * matches `_rsCollapseH` — routed through the canonical `_bindEvent`
419
+ * → `applyEventProp` path (delegation + batching + name
420
+ * normalization). All three reactives (valueIndex, isDark, handlers
421
+ * — though handler identity is captured at the call site) compose
422
+ * cleanly: a value flip OR a mode flip patches className IN PLACE
423
+ * on the SAME node, handlers stay attached across both.
424
+ *
425
+ * Layer-pure: no styler / ui-core imports (the styler injection is
426
+ * the emitted code's job via `__rsSheet.injectRules`).
427
+ *
428
+ * @param html static element HTML WITHOUT the root `class=` attr
429
+ * @param classes flat array of `2 × valueCount` class strings, indexed
430
+ * `[v0_L, v0_D, v1_L, v1_D, …]`
431
+ * @param valueIndex user expression returning 0..valueCount-1 — reactive
432
+ * @param isDark app mode accessor — reactive
433
+ * @param handlers `{ onClick: fn, onPointerEnter: fn, … }` — the
434
+ * residual handlers peeled off the call site by the
435
+ * compiler's emit (sliced source spans re-emitted
436
+ * verbatim, paren-wrapped to keep arrow / sequence
437
+ * expressions a single value)
438
+ * @param bind standard _tpl binder for children/events (or null)
439
+ */
440
+ export function _rsCollapseDynH(
441
+ html: string,
442
+ classes: readonly string[],
443
+ valueIndex: () => number,
444
+ isDark: () => boolean,
445
+ handlers: Record<string, unknown>,
446
+ bind?: ((el: HTMLElement) => (() => void) | null) | null,
447
+ ): NativeItem {
448
+ return _tpl(html, (el) => {
449
+ // Reactive class — identical shape to `_rsCollapseDyn`: one
450
+ // `renderEffect` reads both accessors, subscribing to both signals;
451
+ // a change to EITHER re-runs only this className assignment, no
452
+ // remount. Direct `renderEffect` (not via `_bindDirect`) so
453
+ // `valueIndex()` runs exactly once per re-run — see the
454
+ // corresponding comment in `_rsCollapseDyn`.
455
+ const disposeClass = renderEffect(() => {
456
+ const idx = (valueIndex() << 1) | (isDark() ? 1 : 0)
457
+ el.className = classes[idx] ?? ''
458
+ })
459
+ // Handler attachment — identical to `_rsCollapseH`: routes through
460
+ // the canonical `_bindEvent` path so delegation / batching / name
461
+ // normalization behave byte-identically to the 5-layer mount.
462
+ // `Object.keys` (not `for...in`) so an attacker who pollutes
463
+ // `Object.prototype` can't inject a fake handler via inherited
464
+ // enumerable properties — only OWN keys count. The compiler emits
465
+ // a clean object literal so this matters defensively, not in
466
+ // practice, but the cost is zero.
467
+ const handlerDisposers: (() => void)[] = []
468
+ for (const key of Object.keys(handlers)) {
284
469
  const d = _bindEvent(el, key, handlers[key])
285
470
  if (d) handlerDisposers.push(d)
286
471
  }
@@ -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
+ })