@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.
- 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
|
@@ -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
|
+
})
|