@pyreon/runtime-dom 0.23.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,316 @@
1
+ import { signal } from '@pyreon/reactivity'
2
+ import { flush } from '@pyreon/test-utils/browser'
3
+ import { afterEach, describe, expect, it } from 'vitest'
4
+ import { _rsCollapseDyn, mount } from '../index'
5
+
6
+ // PR 1 of the dynamic-prop partial-collapse build (next bite after the
7
+ // on*-handler partial-collapse `_rsCollapseH`, open-work-2026-q3.md #1
8
+ // dynamic-prop bucket = 15.3% of all real-corpus sites). Proves only
9
+ // what `_rsCollapseDyn` owns:
10
+ // 1. ONE `_tpl()` cloneNode whose class is reactively bound to BOTH
11
+ // the user's value expression AND the mode accessor
12
+ // 2. Stride-2 value-major class layout: index = 2*value + (isDark?1:0)
13
+ // 3. A value flip OR a mode flip patches className IN PLACE on the
14
+ // SAME node (no remount) — same contract as `_rsCollapse`
15
+ // 4. Children/event binders run alongside class binding and dispose
16
+ // cleanly with the host disposer
17
+ //
18
+ // Like `_rsCollapse`'s suite, this injects CSS via a plain <style> tag
19
+ // to stay layer-pure (no @pyreon/styler import — the styler injection
20
+ // is the EMITTED code's job, not the runtime helper's).
21
+
22
+ describe('_rsCollapseDyn (real browser)', () => {
23
+ const cleanup: Array<() => void> = []
24
+ afterEach(() => {
25
+ for (const u of cleanup.splice(0)) u()
26
+ })
27
+
28
+ function injectCss(css: string): void {
29
+ const el = document.createElement('style')
30
+ el.textContent = css
31
+ document.head.appendChild(el)
32
+ cleanup.push(() => el.remove())
33
+ }
34
+
35
+ function mountInto(node: ReturnType<typeof _rsCollapseDyn>): HTMLElement {
36
+ const root = document.createElement('div')
37
+ document.body.appendChild(root)
38
+ const dispose = mount(node as unknown as Parameters<typeof mount>[0], root)
39
+ cleanup.push(() => {
40
+ dispose()
41
+ root.remove()
42
+ })
43
+ return root
44
+ }
45
+
46
+ // The canonical ternary shape: 2 values × 2 modes = 4 classes.
47
+ const ternaryClasses = (prefix: string): readonly string[] => [
48
+ `${prefix}-v0-light`,
49
+ `${prefix}-v0-dark`,
50
+ `${prefix}-v1-light`,
51
+ `${prefix}-v1-dark`,
52
+ ]
53
+
54
+ it('cold mount picks value=0 + light by default — real CSS resolves', async () => {
55
+ injectCss(`
56
+ .rd1-v0-light{color:rgb(1,1,1)}.rd1-v0-dark{color:rgb(2,2,2)}
57
+ .rd1-v1-light{color:rgb(3,3,3)}.rd1-v1-dark{color:rgb(4,4,4)}
58
+ `)
59
+ const cond = signal(false) // → valueIndex 0
60
+ const isDark = signal(false)
61
+ const root = mountInto(
62
+ _rsCollapseDyn(
63
+ '<button>Save</button>',
64
+ ternaryClasses('rd1'),
65
+ () => (cond() ? 1 : 0),
66
+ () => isDark(),
67
+ ),
68
+ )
69
+ await flush()
70
+ const btn = root.querySelector('button')
71
+ expect(btn).not.toBeNull()
72
+ expect(btn?.className).toBe('rd1-v0-light')
73
+ expect(btn?.textContent).toBe('Save')
74
+ expect(getComputedStyle(btn as Element).color).toBe('rgb(1, 1, 1)')
75
+ })
76
+
77
+ it('value flip swaps class on the SAME node (no remount) — bisect: dispatch matters', async () => {
78
+ // Bisect-load-bearing assertion: if `_rsCollapseDyn` ignored the
79
+ // valueIndex accessor and only dispatched on `isDark` (the
80
+ // pre-existing `_rsCollapse` shape), the className would stay at
81
+ // v0-light here. Toggling `cond` must move us to v1-light.
82
+ injectCss(`
83
+ .rd2-v0-light{color:rgb(10,10,10)}.rd2-v0-dark{color:rgb(20,20,20)}
84
+ .rd2-v1-light{color:rgb(30,30,30)}.rd2-v1-dark{color:rgb(40,40,40)}
85
+ `)
86
+ const cond = signal(false)
87
+ const isDark = signal(false)
88
+ const root = mountInto(
89
+ _rsCollapseDyn(
90
+ '<button>X</button>',
91
+ ternaryClasses('rd2'),
92
+ () => (cond() ? 1 : 0),
93
+ () => isDark(),
94
+ ),
95
+ )
96
+ await flush()
97
+ const before = root.querySelector('button') as HTMLElement
98
+ expect(before.className).toBe('rd2-v0-light')
99
+
100
+ cond.set(true) // valueIndex 0 → 1
101
+ await flush()
102
+ const after = root.querySelector('button') as HTMLElement
103
+ expect(after).toBe(before) // node identity preserved ⇒ reactive patch, not remount
104
+ expect(after.className).toBe('rd2-v1-light')
105
+ expect(getComputedStyle(after).color).toBe('rgb(30, 30, 30)')
106
+ })
107
+
108
+ it('mode flip swaps class on the SAME node (no remount) — preserves `_rsCollapse` mode contract', async () => {
109
+ injectCss(`
110
+ .rd3-v0-light{color:rgb(50,50,50)}.rd3-v0-dark{color:rgb(60,60,60)}
111
+ .rd3-v1-light{color:rgb(70,70,70)}.rd3-v1-dark{color:rgb(80,80,80)}
112
+ `)
113
+ const cond = signal(true) // pin valueIndex to 1
114
+ const isDark = signal(false)
115
+ const root = mountInto(
116
+ _rsCollapseDyn(
117
+ '<button>Y</button>',
118
+ ternaryClasses('rd3'),
119
+ () => (cond() ? 1 : 0),
120
+ () => isDark(),
121
+ ),
122
+ )
123
+ await flush()
124
+ const before = root.querySelector('button') as HTMLElement
125
+ expect(before.className).toBe('rd3-v1-light')
126
+
127
+ isDark.set(true)
128
+ await flush()
129
+ const after = root.querySelector('button') as HTMLElement
130
+ expect(after).toBe(before)
131
+ expect(after.className).toBe('rd3-v1-dark')
132
+ expect(getComputedStyle(after).color).toBe('rgb(80, 80, 80)')
133
+ })
134
+
135
+ it('combined value + mode flip lands on the right (value, mode) class — stride-2 layout proof', async () => {
136
+ // Flips both signals across all 4 combinations in sequence;
137
+ // asserts the stride-2 indexing matches the documented layout
138
+ // `[v0_light, v0_dark, v1_light, v1_dark]`.
139
+ injectCss(`
140
+ .rd4-v0-light{color:rgb(11,11,11)}.rd4-v0-dark{color:rgb(22,22,22)}
141
+ .rd4-v1-light{color:rgb(33,33,33)}.rd4-v1-dark{color:rgb(44,44,44)}
142
+ `)
143
+ const cond = signal(false)
144
+ const isDark = signal(false)
145
+ const root = mountInto(
146
+ _rsCollapseDyn(
147
+ '<button>Z</button>',
148
+ ternaryClasses('rd4'),
149
+ () => (cond() ? 1 : 0),
150
+ () => isDark(),
151
+ ),
152
+ )
153
+ await flush()
154
+ const btn = root.querySelector('button') as HTMLElement
155
+
156
+ // (v=0, dark=0) → index 0
157
+ expect(btn.className).toBe('rd4-v0-light')
158
+
159
+ // (v=0, dark=1) → index 1
160
+ isDark.set(true)
161
+ await flush()
162
+ expect(btn.className).toBe('rd4-v0-dark')
163
+
164
+ // (v=1, dark=1) → index 3
165
+ cond.set(true)
166
+ await flush()
167
+ expect(btn.className).toBe('rd4-v1-dark')
168
+
169
+ // (v=1, dark=0) → index 2
170
+ isDark.set(false)
171
+ await flush()
172
+ expect(btn.className).toBe('rd4-v1-light')
173
+
174
+ // back to (v=0, dark=0) → index 0
175
+ cond.set(false)
176
+ await flush()
177
+ expect(btn.className).toBe('rd4-v0-light')
178
+ })
179
+
180
+ it('out-of-range valueIndex coerces to empty className (no crash)', async () => {
181
+ // Defensive shape: an enumerator-misalign or compiler bug that
182
+ // produces an out-of-range index must NOT throw at mount. The
183
+ // documented runtime contract is "compiler is source of truth";
184
+ // graceful degradation = empty className.
185
+ injectCss(`.rd5-v0-light{color:rgb(5,5,5)}.rd5-v0-dark{color:rgb(6,6,6)}`)
186
+ const isDark = signal(false)
187
+ const root = mountInto(
188
+ _rsCollapseDyn(
189
+ '<button>Bad</button>',
190
+ ['rd5-v0-light', 'rd5-v0-dark'], // only ONE value defined
191
+ () => 7, // BUG-shaped: out of range
192
+ () => isDark(),
193
+ ),
194
+ )
195
+ await flush()
196
+ const btn = root.querySelector('button') as HTMLElement
197
+ expect(btn.className).toBe('') // not 'undefined', not crashed
198
+ expect(btn.textContent).toBe('Bad')
199
+ })
200
+
201
+ it('valueIndex() is called EXACTLY ONCE per re-run (no double-call) — side-effect-safe', async () => {
202
+ // Regression: the original implementation routed through
203
+ // `_bindDirect`'s fallback which calls the source function once
204
+ // (passing the result as `v` to the inner callback), then the
205
+ // inner callback called `valueIndex()` AGAIN — i.e., two calls
206
+ // per re-run. Side-effecting cond expressions (`{(modifyState(),
207
+ // cond) ? 'a' : 'b'}`) would fire their side-effects twice,
208
+ // breaking the original source's call-count contract. The fix is
209
+ // to use `renderEffect` directly so `valueIndex()` runs exactly
210
+ // once per re-run, matching the implicit semantics of the
211
+ // user's JSX call site.
212
+ //
213
+ // Bisect: with the old `_bindDirect(valueIndex, () => valueIndex() ...)`
214
+ // shape this spec records `calls > runs`. With the fix
215
+ // (direct `renderEffect(() => valueIndex() ...)`) calls === runs.
216
+ injectCss(`
217
+ .rdcc-v0-light{color:rgb(1,2,3)}.rdcc-v0-dark{color:rgb(4,5,6)}
218
+ .rdcc-v1-light{color:rgb(7,8,9)}.rdcc-v1-dark{color:rgb(10,11,12)}
219
+ `)
220
+ const cond = signal(false)
221
+ const isDark = signal(false)
222
+ let calls = 0
223
+ const root = mountInto(
224
+ _rsCollapseDyn(
225
+ '<button>Calls</button>',
226
+ ['rdcc-v0-light', 'rdcc-v0-dark', 'rdcc-v1-light', 'rdcc-v1-dark'],
227
+ () => {
228
+ calls++
229
+ return cond() ? 1 : 0
230
+ },
231
+ () => isDark(),
232
+ ),
233
+ )
234
+ await flush()
235
+ // Initial mount: one renderEffect run.
236
+ expect(calls).toBe(1)
237
+ // Value flip: one more run.
238
+ cond.set(true)
239
+ await flush()
240
+ expect(calls).toBe(2)
241
+ // Mode flip: one more run.
242
+ isDark.set(true)
243
+ await flush()
244
+ expect(calls).toBe(3)
245
+ // Combined flip back: one run.
246
+ cond.set(false)
247
+ isDark.set(false)
248
+ await flush()
249
+ // Two writes inside the same microtask coalesce to one effect run
250
+ // (Pyreon's batch semantics). Either 4 or 5 — accept either to
251
+ // avoid coupling to batching internals. The bisect-load-bearing
252
+ // assertion is `calls === runs` (1:1), NOT `calls === N`. Pre-fix
253
+ // the count would be 2× either way.
254
+ expect(calls).toBeGreaterThanOrEqual(4)
255
+ expect(calls).toBeLessThanOrEqual(5)
256
+ // Cleanly land on the right class regardless.
257
+ expect((root.querySelector('button') as HTMLElement).className).toBe('rdcc-v0-light')
258
+ void root
259
+ })
260
+
261
+ it('children bind runs alongside class bind and disposes cleanly with the host', async () => {
262
+ injectCss(`
263
+ .rd6-v0-light{color:rgb(7,7,7)}.rd6-v0-dark{color:rgb(8,8,8)}
264
+ .rd6-v1-light{color:rgb(9,9,9)}.rd6-v1-dark{color:rgb(11,11,11)}
265
+ `)
266
+ const cond = signal(false)
267
+ const isDark = signal(false)
268
+ let childDisposed = false
269
+ const root = mountInto(
270
+ _rsCollapseDyn(
271
+ '<button><span></span></button>',
272
+ ternaryClasses('rd6'),
273
+ () => (cond() ? 1 : 0),
274
+ () => isDark(),
275
+ (el) => {
276
+ const span = el.querySelector('span') as HTMLElement
277
+ span.textContent = 'child'
278
+ return () => {
279
+ childDisposed = true
280
+ }
281
+ },
282
+ ),
283
+ )
284
+ await flush()
285
+ expect((root.querySelector('span') as HTMLElement).textContent).toBe('child')
286
+
287
+ // Disposing the host (via cleanup) must also fire the child binder's
288
+ // disposer — the runtime composes them.
289
+ cleanup.splice(0).forEach((u) => u())
290
+ expect(childDisposed).toBe(true)
291
+ })
292
+
293
+ it('single-value (valueCount=1) reduces to a `_rsCollapse`-equivalent shape', async () => {
294
+ // Useful structural assertion: `_rsCollapseDyn` with one value
295
+ // pair degenerates to the existing collapse contract — guards the
296
+ // generalisation as a strict superset.
297
+ injectCss(`.rd7-only-light{color:rgb(80,80,80)}.rd7-only-dark{color:rgb(90,90,90)}`)
298
+ const isDark = signal(false)
299
+ const root = mountInto(
300
+ _rsCollapseDyn(
301
+ '<i>Solo</i>',
302
+ ['rd7-only-light', 'rd7-only-dark'],
303
+ () => 0, // always one value
304
+ () => isDark(),
305
+ ),
306
+ )
307
+ await flush()
308
+ const i = root.querySelector('i') as HTMLElement
309
+ expect(i.className).toBe('rd7-only-light')
310
+
311
+ isDark.set(true)
312
+ await flush()
313
+ expect(i.className).toBe('rd7-only-dark')
314
+ expect(getComputedStyle(i).color).toBe('rgb(90, 90, 90)')
315
+ })
316
+ })
@@ -0,0 +1,122 @@
1
+ import { For, h, Show } from '@pyreon/core'
2
+ import { signal } from '@pyreon/reactivity'
3
+ import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ // CONTRACT — bug surfaced by `scripts/leak-sweep.ts` against the
7
+ // `domConditionalToggle-1000` journey; now fixed.
8
+ //
9
+ // **Bug shape (pre-fix):**
10
+ //
11
+ // [pyreon] Unhandled effect error: NotFoundError: Failed to execute
12
+ // 'insertBefore' on 'Node': The node before which the new node is
13
+ // to be inserted is not a child of this node.
14
+ // at mountReactive (...)
15
+ // at mountChild (...)
16
+ //
17
+ // Trigger: N `<Show when={signal[i]}>` components inside a `<For>`,
18
+ // then batched signal writes flip every `signal[i]` false → true → false.
19
+ //
20
+ // **Root cause:** `mountReactive` captured `parent` in its setup closure.
21
+ // `mountFor` creates child DOM into a DocumentFragment, then moves the
22
+ // fragment contents to the live parent via
23
+ // `liveParent.insertBefore(frag, tailMarker)`. After the move, every
24
+ // `mountReactive` (e.g. the one created when Show's `when` accessor
25
+ // returned the function child) had a stale `parent` reference pointing
26
+ // at the now-empty fragment, while its marker had been carried with the
27
+ // fragment's contents to `liveParent`. On the next signal flip, the
28
+ // effect re-ran and called `parent.insertBefore(node, marker)` against
29
+ // the stale fragment, throwing NotFoundError because `marker` was no
30
+ // longer a child of `parent`. The throw landed in Pyreon's
31
+ // "unhandled effect error" path → console.error + loss of For's
32
+ // children from the DOM (final count dropped to 0).
33
+ //
34
+ // **Fix:** `mountReactive` now reads `marker.parentNode` at each effect
35
+ // run (with the closure-captured `parent` as a detached-marker
36
+ // fallback). The marker is moved by the same `insertBefore(frag, ...)`
37
+ // as the rest of the fragment contents, so its live `parentNode` is
38
+ // always the correct live parent.
39
+ //
40
+ // Reproducer: `bun run perf:leak-sweep --app perf-dashboard --journeys domConditionalToggle-1000`
41
+
42
+ describe('mountReactive: <Show> inside <For> under batched signal toggles', () => {
43
+ it('single <Show> with function-child handles toggle cycles correctly (sanity, works today)', async () => {
44
+ // 1 Show, 1 signal. Should always work — no For wrapper, no batched
45
+ // multi-signal flush. Proves the bug is specifically the For-of-Show
46
+ // interaction, not Show itself.
47
+ const flag = signal<boolean>(true)
48
+ const { container, unmount } = mountInBrowser(
49
+ h(
50
+ 'div',
51
+ { id: 'root' },
52
+ h(Show, {
53
+ when: () => flag(),
54
+ children: () => h('div', { 'data-id': '0' }, 'Visible'),
55
+ }),
56
+ ),
57
+ )
58
+ await flush()
59
+ expect(container.querySelectorAll('div[data-id]')).toHaveLength(1)
60
+ flag.set(false)
61
+ await flush()
62
+ expect(container.querySelectorAll('div[data-id]')).toHaveLength(0)
63
+ flag.set(true)
64
+ await flush()
65
+ expect(container.querySelectorAll('div[data-id]')).toHaveLength(1)
66
+ unmount()
67
+ })
68
+
69
+ it(
70
+ 'CONTRACT: <For> + <Show> mass-toggle does not throw NotFoundError or lose children',
71
+ async () => {
72
+ // 100 Show items inside a For. Each Show's `when` is its own signal.
73
+ // The function child `{() => <div/>}` exercises the function-child
74
+ // mountReactive path that's the actual failure site.
75
+ const flags = Array.from({ length: 100 }, () => signal<boolean>(true))
76
+ const indices = Array.from({ length: 100 }, (_, i) => i)
77
+
78
+ const { container, unmount } = mountInBrowser(
79
+ h(
80
+ 'div',
81
+ { id: 'root' },
82
+ For({
83
+ each: indices,
84
+ by: (i: number) => i,
85
+ children: (i: number) =>
86
+ h(
87
+ Show,
88
+ {
89
+ when: () => (flags[i] as ReturnType<typeof signal<boolean>>)(),
90
+ children: () => h('div', { 'data-id': String(i) }, `Visible ${i}`),
91
+ },
92
+ ),
93
+ }),
94
+ ),
95
+ )
96
+ await flush()
97
+ try {
98
+ // Sanity: all 100 visible at mount.
99
+ expect(container.querySelectorAll('div[data-id]')).toHaveLength(100)
100
+
101
+ // ONE mass-toggle cycle: false → true.
102
+ for (const f of flags) f.set(false)
103
+ await flush()
104
+ expect(container.querySelectorAll('div[data-id]')).toHaveLength(0)
105
+
106
+ for (const f of flags) f.set(true)
107
+ await flush()
108
+
109
+ // **Bug fires here**: pre-fix the framework throws NotFoundError
110
+ // inside mountReactive's setup, the entire reactive subtree is
111
+ // lost, and the count drops to 0. The CONTRACT assertion is
112
+ // "should be 100"; today the framework returns 0, so `it.fails`
113
+ // is the correct marker. When the real fix lands, this
114
+ // assertion will pass and the test will fail — signal to flip
115
+ // the marker.
116
+ expect(container.querySelectorAll('div[data-id]')).toHaveLength(100)
117
+ } finally {
118
+ unmount()
119
+ }
120
+ },
121
+ )
122
+ })