@pyreon/runtime-dom 0.24.5 → 0.24.6

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.
Files changed (53) hide show
  1. package/package.json +5 -9
  2. package/src/delegate.ts +0 -98
  3. package/src/devtools.ts +0 -339
  4. package/src/env.d.ts +0 -6
  5. package/src/hydrate.ts +0 -450
  6. package/src/hydration-debug.ts +0 -129
  7. package/src/index.ts +0 -83
  8. package/src/keep-alive-entry.ts +0 -3
  9. package/src/keep-alive.ts +0 -83
  10. package/src/manifest.ts +0 -236
  11. package/src/mount.ts +0 -597
  12. package/src/nodes.ts +0 -896
  13. package/src/props.ts +0 -474
  14. package/src/template.ts +0 -523
  15. package/src/tests/callback-ref-unmount.browser.test.ts +0 -62
  16. package/src/tests/callback-ref-unmount.test.ts +0 -52
  17. package/src/tests/compiler-integration.test.tsx +0 -508
  18. package/src/tests/coverage-gaps.test.ts +0 -3183
  19. package/src/tests/coverage.test.ts +0 -1140
  20. package/src/tests/ctx-stack-growth-repro.test.tsx +0 -158
  21. package/src/tests/dev-gate-pattern.test.ts +0 -46
  22. package/src/tests/dev-gate-treeshake.test.ts +0 -256
  23. package/src/tests/error-boundary-stack-leak-repro.test.tsx +0 -133
  24. package/src/tests/fanout-repro.test.tsx +0 -219
  25. package/src/tests/hydration-integration.test.tsx +0 -540
  26. package/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts +0 -140
  27. package/src/tests/lifecycle-integration.test.tsx +0 -342
  28. package/src/tests/lis-prepend.browser.test.ts +0 -99
  29. package/src/tests/manifest-snapshot.test.ts +0 -85
  30. package/src/tests/mount.test.ts +0 -3529
  31. package/src/tests/native-markers.test.ts +0 -19
  32. package/src/tests/props.test.ts +0 -581
  33. package/src/tests/reactive-props.test.ts +0 -270
  34. package/src/tests/real-world-integration.test.tsx +0 -714
  35. package/src/tests/rs-collapse-dyn-h.browser.test.ts +0 -303
  36. package/src/tests/rs-collapse-dyn.browser.test.ts +0 -316
  37. package/src/tests/rs-collapse-h.browser.test.ts +0 -152
  38. package/src/tests/rs-collapse-h.test.ts +0 -237
  39. package/src/tests/rs-collapse.browser.test.ts +0 -128
  40. package/src/tests/runtime-dom.browser.test.ts +0 -409
  41. package/src/tests/setup.ts +0 -3
  42. package/src/tests/show-context.test.ts +0 -270
  43. package/src/tests/show-of-for-batched-toggle.browser.test.ts +0 -122
  44. package/src/tests/ssr-xss-round-trip.browser.test.ts +0 -93
  45. package/src/tests/style-key-removal.browser.test.ts +0 -54
  46. package/src/tests/style-key-removal.test.ts +0 -88
  47. package/src/tests/template.test.ts +0 -383
  48. package/src/tests/transition-timeout-leak.test.ts +0 -126
  49. package/src/tests/transition.test.ts +0 -568
  50. package/src/tests/verified-correct-probes.test.ts +0 -56
  51. package/src/transition-entry.ts +0 -7
  52. package/src/transition-group.ts +0 -350
  53. package/src/transition.ts +0 -245
@@ -1,303 +0,0 @@
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
- })
@@ -1,316 +0,0 @@
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
- })