@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.
- package/README.md +115 -91
- package/lib/_chunks/keep-alive-DznjF_h1.js +1659 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +145 -1823
- package/lib/keep-alive-entry.js +2 -1380
- package/lib/transition-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/ctx-stack-growth-repro.test.tsx +158 -0
- package/src/tests/error-boundary-stack-leak-repro.test.tsx +133 -0
- 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/lib/analysis/keep-alive-entry.js.html +0 -5406
- package/lib/analysis/transition-entry.js.html +0 -5406
|
@@ -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
|
+
})
|