@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.
- package/package.json +5 -9
- package/src/delegate.ts +0 -98
- package/src/devtools.ts +0 -339
- package/src/env.d.ts +0 -6
- package/src/hydrate.ts +0 -450
- package/src/hydration-debug.ts +0 -129
- package/src/index.ts +0 -83
- package/src/keep-alive-entry.ts +0 -3
- package/src/keep-alive.ts +0 -83
- package/src/manifest.ts +0 -236
- package/src/mount.ts +0 -597
- package/src/nodes.ts +0 -896
- package/src/props.ts +0 -474
- package/src/template.ts +0 -523
- package/src/tests/callback-ref-unmount.browser.test.ts +0 -62
- package/src/tests/callback-ref-unmount.test.ts +0 -52
- package/src/tests/compiler-integration.test.tsx +0 -508
- package/src/tests/coverage-gaps.test.ts +0 -3183
- package/src/tests/coverage.test.ts +0 -1140
- package/src/tests/ctx-stack-growth-repro.test.tsx +0 -158
- package/src/tests/dev-gate-pattern.test.ts +0 -46
- package/src/tests/dev-gate-treeshake.test.ts +0 -256
- package/src/tests/error-boundary-stack-leak-repro.test.tsx +0 -133
- package/src/tests/fanout-repro.test.tsx +0 -219
- package/src/tests/hydration-integration.test.tsx +0 -540
- package/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts +0 -140
- package/src/tests/lifecycle-integration.test.tsx +0 -342
- package/src/tests/lis-prepend.browser.test.ts +0 -99
- package/src/tests/manifest-snapshot.test.ts +0 -85
- package/src/tests/mount.test.ts +0 -3529
- package/src/tests/native-markers.test.ts +0 -19
- package/src/tests/props.test.ts +0 -581
- package/src/tests/reactive-props.test.ts +0 -270
- package/src/tests/real-world-integration.test.tsx +0 -714
- package/src/tests/rs-collapse-dyn-h.browser.test.ts +0 -303
- package/src/tests/rs-collapse-dyn.browser.test.ts +0 -316
- package/src/tests/rs-collapse-h.browser.test.ts +0 -152
- package/src/tests/rs-collapse-h.test.ts +0 -237
- package/src/tests/rs-collapse.browser.test.ts +0 -128
- package/src/tests/runtime-dom.browser.test.ts +0 -409
- package/src/tests/setup.ts +0 -3
- package/src/tests/show-context.test.ts +0 -270
- package/src/tests/show-of-for-batched-toggle.browser.test.ts +0 -122
- package/src/tests/ssr-xss-round-trip.browser.test.ts +0 -93
- package/src/tests/style-key-removal.browser.test.ts +0 -54
- package/src/tests/style-key-removal.test.ts +0 -88
- package/src/tests/template.test.ts +0 -383
- package/src/tests/transition-timeout-leak.test.ts +0 -126
- package/src/tests/transition.test.ts +0 -568
- package/src/tests/verified-correct-probes.test.ts +0 -56
- package/src/transition-entry.ts +0 -7
- package/src/transition-group.ts +0 -350
- 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
|
-
})
|