@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/lib/_chunks/{keep-alive-BM7bn3W9.js → keep-alive-DznjF_h1.js} +9 -7
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +138 -3
- package/lib/keep-alive-entry.js +1 -1
- package/lib/types/index.d.ts +107 -1
- package/package.json +6 -6
- package/src/index.ts +2 -0
- package/src/nodes.ts +27 -6
- package/src/template.ts +186 -1
- package/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts +140 -0
- package/src/tests/rs-collapse-dyn-h.browser.test.ts +303 -0
- package/src/tests/rs-collapse-dyn.browser.test.ts +316 -0
- package/src/tests/show-of-for-batched-toggle.browser.test.ts +122 -0
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
|
-
|
|
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
|
+
})
|