@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,409 +0,0 @@
|
|
|
1
|
-
import { For, h, Portal, _rp } from '@pyreon/core'
|
|
2
|
-
import { signal } from '@pyreon/reactivity'
|
|
3
|
-
import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
|
|
4
|
-
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
-
import { hydrateRoot, mount, Transition } from '../index'
|
|
6
|
-
|
|
7
|
-
// Real-Chromium smoke suite for @pyreon/runtime-dom. Catches environment-
|
|
8
|
-
// divergence bugs that happy-dom hides: SVG namespace property setters,
|
|
9
|
-
// real PointerEvent sequencing, `import.meta.env.DEV` literal-replacement,
|
|
10
|
-
// and the keyed reconciler under live signal updates.
|
|
11
|
-
|
|
12
|
-
describe('runtime-dom in real browser', () => {
|
|
13
|
-
afterEach(() => {
|
|
14
|
-
vi.restoreAllMocks()
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
it('mounts and patches DOM when a signal updates', async () => {
|
|
18
|
-
const count = signal(0)
|
|
19
|
-
const { container, unmount } = mountInBrowser(
|
|
20
|
-
h('span', { id: 'n' }, () => String(count())),
|
|
21
|
-
)
|
|
22
|
-
expect(container.querySelector('#n')?.textContent).toBe('0')
|
|
23
|
-
|
|
24
|
-
count.set(42)
|
|
25
|
-
await flush()
|
|
26
|
-
expect(container.querySelector('#n')?.textContent).toBe('42')
|
|
27
|
-
unmount()
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('keyed <For> reconciler inserts at the right index when a list grows', async () => {
|
|
31
|
-
type Row = { id: number; label: string }
|
|
32
|
-
const rows = signal<Row[]>([
|
|
33
|
-
{ id: 1, label: 'a' },
|
|
34
|
-
{ id: 2, label: 'b' },
|
|
35
|
-
])
|
|
36
|
-
const { container, unmount } = mountInBrowser(
|
|
37
|
-
h(
|
|
38
|
-
'ul',
|
|
39
|
-
{ id: 'list' },
|
|
40
|
-
For({
|
|
41
|
-
each: rows,
|
|
42
|
-
by: (r: Row) => r.id,
|
|
43
|
-
children: (r: Row) => h('li', { 'data-id': String(r.id) }, r.label),
|
|
44
|
-
}),
|
|
45
|
-
),
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
let items = container.querySelectorAll<HTMLLIElement>('#list li')
|
|
49
|
-
expect(items).toHaveLength(2)
|
|
50
|
-
expect(Array.from(items).map((el) => el.textContent)).toEqual(['a', 'b'])
|
|
51
|
-
|
|
52
|
-
rows.set([
|
|
53
|
-
{ id: 1, label: 'a' },
|
|
54
|
-
{ id: 3, label: 'c' },
|
|
55
|
-
{ id: 2, label: 'b' },
|
|
56
|
-
])
|
|
57
|
-
await flush()
|
|
58
|
-
|
|
59
|
-
items = container.querySelectorAll<HTMLLIElement>('#list li')
|
|
60
|
-
expect(items).toHaveLength(3)
|
|
61
|
-
expect(Array.from(items).map((el) => el.dataset.id)).toEqual(['1', '3', '2'])
|
|
62
|
-
expect(Array.from(items).map((el) => el.textContent)).toEqual(['a', 'c', 'b'])
|
|
63
|
-
unmount()
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
it('keyed <For> with _rp-wrapped each (compiled JSX shape) renders + reacts to signal updates', async () => {
|
|
67
|
-
// Regression for the `<For each={signal}>` JSX form. The compiler emits
|
|
68
|
-
// `h(For, { each: _rp(() => rows()), ... })`. `makeReactiveProps` (via
|
|
69
|
-
// mountComponent) converts the `_rp`-branded function to a getter on
|
|
70
|
-
// `props.each`. `For()` then forwards those props onto a `ForSymbol`
|
|
71
|
-
// VNode, which reaches `mountChild`'s ForSymbol branch.
|
|
72
|
-
//
|
|
73
|
-
// Before the fix, that branch destructured `{ each, by, children }`
|
|
74
|
-
// eagerly — firing the getter and binding `each` to the *resolved
|
|
75
|
-
// array*, not the function. `mountFor` then crashed on `source()`
|
|
76
|
-
// (calling an array) and the list never rendered. This test produces
|
|
77
|
-
// the exact same vnode shape the compiler emits, so it catches the
|
|
78
|
-
// regression without needing a JSX transform pipeline in the test.
|
|
79
|
-
type Row = { id: number; label: string }
|
|
80
|
-
const rows = signal<Row[]>([
|
|
81
|
-
{ id: 1, label: 'a' },
|
|
82
|
-
{ id: 2, label: 'b' },
|
|
83
|
-
{ id: 3, label: 'c' },
|
|
84
|
-
])
|
|
85
|
-
|
|
86
|
-
const { container, unmount } = mountInBrowser(
|
|
87
|
-
h(
|
|
88
|
-
'ul',
|
|
89
|
-
{ id: 'rp-list' },
|
|
90
|
-
h(For, {
|
|
91
|
-
each: _rp(() => rows()),
|
|
92
|
-
by: (r: Row) => r.id,
|
|
93
|
-
children: (r: Row) => h('li', { 'data-id': String(r.id) }, r.label),
|
|
94
|
-
}),
|
|
95
|
-
),
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
let items = container.querySelectorAll<HTMLLIElement>('#rp-list li')
|
|
99
|
-
expect(items).toHaveLength(3)
|
|
100
|
-
expect(Array.from(items).map((el) => el.dataset.id)).toEqual(['1', '2', '3'])
|
|
101
|
-
|
|
102
|
-
// Signal-driven update — replace + reorder the list.
|
|
103
|
-
rows.set([
|
|
104
|
-
{ id: 2, label: 'b' },
|
|
105
|
-
{ id: 4, label: 'd' },
|
|
106
|
-
{ id: 1, label: 'a' },
|
|
107
|
-
])
|
|
108
|
-
await flush()
|
|
109
|
-
|
|
110
|
-
items = container.querySelectorAll<HTMLLIElement>('#rp-list li')
|
|
111
|
-
expect(items).toHaveLength(3)
|
|
112
|
-
expect(Array.from(items).map((el) => el.dataset.id)).toEqual(['2', '4', '1'])
|
|
113
|
-
expect(Array.from(items).map((el) => el.textContent)).toEqual(['b', 'd', 'a'])
|
|
114
|
-
|
|
115
|
-
// Append — confirms reactivity persists across multiple updates.
|
|
116
|
-
rows.set([
|
|
117
|
-
{ id: 2, label: 'b' },
|
|
118
|
-
{ id: 4, label: 'd' },
|
|
119
|
-
{ id: 1, label: 'a' },
|
|
120
|
-
{ id: 5, label: 'e' },
|
|
121
|
-
])
|
|
122
|
-
await flush()
|
|
123
|
-
items = container.querySelectorAll<HTMLLIElement>('#rp-list li')
|
|
124
|
-
expect(items).toHaveLength(4)
|
|
125
|
-
unmount()
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
it('creates SVG with the SVG namespace and updates reactive attributes via setAttribute', async () => {
|
|
129
|
-
const x = signal(10)
|
|
130
|
-
const { container, unmount } = mountInBrowser(
|
|
131
|
-
h(
|
|
132
|
-
'svg',
|
|
133
|
-
{ id: 'svg', width: '100', height: '100' },
|
|
134
|
-
h('rect', { id: 'r', x: () => x(), y: '0', width: '20', height: '20' }),
|
|
135
|
-
),
|
|
136
|
-
)
|
|
137
|
-
|
|
138
|
-
const svg = container.querySelector('#svg')
|
|
139
|
-
const rect = container.querySelector('#r')
|
|
140
|
-
expect(svg?.namespaceURI).toBe('http://www.w3.org/2000/svg')
|
|
141
|
-
expect(rect?.namespaceURI).toBe('http://www.w3.org/2000/svg')
|
|
142
|
-
// SVGRectElement.x is a read-only SVGAnimatedLength getter — applying
|
|
143
|
-
// via property would crash. setAttribute is the only safe path.
|
|
144
|
-
expect(rect?.getAttribute('x')).toBe('10')
|
|
145
|
-
|
|
146
|
-
x.set(55)
|
|
147
|
-
await flush()
|
|
148
|
-
expect(rect?.getAttribute('x')).toBe('55')
|
|
149
|
-
unmount()
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
it('delegated event handler sees `currentTarget` as the bound element, not the listener root', async () => {
|
|
153
|
-
// Regression for a real Pyreon framework bug found via PR #329's form
|
|
154
|
-
// section. Pyreon's TargetedEvent<E> type promises `currentTarget` is
|
|
155
|
-
// the per-element type (e.g. HTMLInputElement), but native event
|
|
156
|
-
// delegation leaves `currentTarget` as the container (where the
|
|
157
|
-
// listener is registered). User code that writes
|
|
158
|
-
// `(ev.currentTarget as HTMLInputElement).value` would silently read
|
|
159
|
-
// from a <div> and get undefined.
|
|
160
|
-
//
|
|
161
|
-
// The fix is in delegate.ts: per-handler Object.defineProperty
|
|
162
|
-
// override of currentTarget, matching React/Vue/Solid behavior.
|
|
163
|
-
const { container, unmount } = mountInBrowser(
|
|
164
|
-
h(
|
|
165
|
-
'div',
|
|
166
|
-
{ id: 'wrap' },
|
|
167
|
-
h('input', {
|
|
168
|
-
id: 'inp',
|
|
169
|
-
type: 'text',
|
|
170
|
-
'data-marker': 'real-input',
|
|
171
|
-
onInput: (ev: Event) => {
|
|
172
|
-
const ct = ev.currentTarget as HTMLInputElement | null
|
|
173
|
-
// Capture observable signals so the test can assert on them
|
|
174
|
-
;(globalThis as { __test_ct_tag?: string | undefined }).__test_ct_tag = ct?.tagName
|
|
175
|
-
;(globalThis as { __test_ct_value?: string | undefined }).__test_ct_value =
|
|
176
|
-
ct?.value
|
|
177
|
-
;(globalThis as { __test_ct_marker?: string | null | undefined }).__test_ct_marker =
|
|
178
|
-
ct?.getAttribute('data-marker') ?? null
|
|
179
|
-
},
|
|
180
|
-
}),
|
|
181
|
-
),
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
const inp = container.querySelector<HTMLInputElement>('#inp')!
|
|
185
|
-
inp.value = 'hello'
|
|
186
|
-
inp.dispatchEvent(new Event('input', { bubbles: true }))
|
|
187
|
-
await flush()
|
|
188
|
-
|
|
189
|
-
// Without the fix: tagName would be 'DIV' (or whatever container is),
|
|
190
|
-
// value would be undefined, marker would be null.
|
|
191
|
-
expect((globalThis as { __test_ct_tag?: string | undefined }).__test_ct_tag).toBe('INPUT')
|
|
192
|
-
expect((globalThis as { __test_ct_value?: string | undefined }).__test_ct_value).toBe('hello')
|
|
193
|
-
expect((globalThis as { __test_ct_marker?: string | null | undefined }).__test_ct_marker).toBe(
|
|
194
|
-
'real-input',
|
|
195
|
-
)
|
|
196
|
-
|
|
197
|
-
unmount()
|
|
198
|
-
delete (globalThis as { __test_ct_tag?: string | undefined }).__test_ct_tag
|
|
199
|
-
delete (globalThis as { __test_ct_value?: string | undefined }).__test_ct_value
|
|
200
|
-
delete (globalThis as { __test_ct_marker?: string | null | undefined }).__test_ct_marker
|
|
201
|
-
})
|
|
202
|
-
|
|
203
|
-
it('dispatches a real PointerEvent and fires the onClick handler', async () => {
|
|
204
|
-
const clicks = signal(0)
|
|
205
|
-
const { container, unmount } = mountInBrowser(
|
|
206
|
-
h(
|
|
207
|
-
'button',
|
|
208
|
-
{
|
|
209
|
-
id: 'btn',
|
|
210
|
-
onClick: () => clicks.set(clicks() + 1),
|
|
211
|
-
},
|
|
212
|
-
() => `clicks: ${clicks()}`,
|
|
213
|
-
),
|
|
214
|
-
)
|
|
215
|
-
|
|
216
|
-
const btn = container.querySelector<HTMLButtonElement>('#btn')!
|
|
217
|
-
expect(btn.textContent).toBe('clicks: 0')
|
|
218
|
-
|
|
219
|
-
btn.dispatchEvent(
|
|
220
|
-
new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' }),
|
|
221
|
-
)
|
|
222
|
-
btn.dispatchEvent(
|
|
223
|
-
new PointerEvent('pointerup', { bubbles: true, pointerType: 'mouse' }),
|
|
224
|
-
)
|
|
225
|
-
btn.click()
|
|
226
|
-
await flush()
|
|
227
|
-
expect(btn.textContent).toBe('clicks: 1')
|
|
228
|
-
unmount()
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
it('emits the duplicate-key dev warning under non-production NODE_ENV', async () => {
|
|
232
|
-
// process.env.NODE_ENV !== 'production' in this dev browser run — the
|
|
233
|
-
// bundler-agnostic gate that every modern bundler auto-replaces at
|
|
234
|
-
// consumer build time. The warning must fire here. The companion
|
|
235
|
-
// `runtime-dom.prod-bundle.test.ts` Node test proves the same code path
|
|
236
|
-
// is dead in a prod bundle (NODE_ENV='production').
|
|
237
|
-
expect(process.env.NODE_ENV).not.toBe('production')
|
|
238
|
-
|
|
239
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
240
|
-
const dupes = signal([
|
|
241
|
-
{ id: 1, label: 'a' },
|
|
242
|
-
{ id: 1, label: 'b' },
|
|
243
|
-
])
|
|
244
|
-
const { unmount } = mountInBrowser(
|
|
245
|
-
h(
|
|
246
|
-
'div',
|
|
247
|
-
null,
|
|
248
|
-
For({
|
|
249
|
-
each: dupes,
|
|
250
|
-
by: (r: { id: number }) => r.id,
|
|
251
|
-
children: (r: { id: number; label: string }) => h('span', { class: 'dup' }, r.label),
|
|
252
|
-
}),
|
|
253
|
-
),
|
|
254
|
-
)
|
|
255
|
-
await flush()
|
|
256
|
-
|
|
257
|
-
const calls = warn.mock.calls.flat().join('\n')
|
|
258
|
-
expect(calls).toMatch(/Duplicate key/i)
|
|
259
|
-
unmount()
|
|
260
|
-
})
|
|
261
|
-
|
|
262
|
-
it('hydrateRoot — attaches reactive listeners to existing SSR markup without rerender', async () => {
|
|
263
|
-
// Simulate SSR-rendered HTML in the container.
|
|
264
|
-
const container = document.createElement('div')
|
|
265
|
-
container.innerHTML = '<button id="ssr-btn" type="button">clicks: 0</button>'
|
|
266
|
-
document.body.appendChild(container)
|
|
267
|
-
|
|
268
|
-
const ssrButtonRef = container.querySelector<HTMLButtonElement>('#ssr-btn')!
|
|
269
|
-
const count = signal(0)
|
|
270
|
-
const cleanup = hydrateRoot(
|
|
271
|
-
container,
|
|
272
|
-
h(
|
|
273
|
-
'button',
|
|
274
|
-
{
|
|
275
|
-
id: 'ssr-btn',
|
|
276
|
-
type: 'button',
|
|
277
|
-
onClick: () => count.set(count() + 1),
|
|
278
|
-
},
|
|
279
|
-
() => `clicks: ${count()}`,
|
|
280
|
-
),
|
|
281
|
-
)
|
|
282
|
-
|
|
283
|
-
// Same DOM node — hydrate adopts it, doesn't replace.
|
|
284
|
-
expect(container.querySelector('#ssr-btn')).toBe(ssrButtonRef)
|
|
285
|
-
|
|
286
|
-
// Click triggers the hydrated handler + reactive text update.
|
|
287
|
-
ssrButtonRef.click()
|
|
288
|
-
await flush()
|
|
289
|
-
expect(ssrButtonRef.textContent).toBe('clicks: 1')
|
|
290
|
-
|
|
291
|
-
cleanup()
|
|
292
|
-
container.remove()
|
|
293
|
-
})
|
|
294
|
-
|
|
295
|
-
it('Portal — children render in a different DOM subtree (not the wrapper)', async () => {
|
|
296
|
-
const target = document.createElement('div')
|
|
297
|
-
target.id = 'portal-target'
|
|
298
|
-
document.body.appendChild(target)
|
|
299
|
-
|
|
300
|
-
const { container, unmount } = mountInBrowser(
|
|
301
|
-
h(
|
|
302
|
-
'div',
|
|
303
|
-
{ id: 'src' },
|
|
304
|
-
h(Portal, { target }, h('span', { id: 'teleported' }, 'over there')),
|
|
305
|
-
),
|
|
306
|
-
)
|
|
307
|
-
|
|
308
|
-
// Portal child is in target, NOT in container.
|
|
309
|
-
expect(container.querySelector('#teleported')).toBeNull()
|
|
310
|
-
expect(target.querySelector('#teleported')?.textContent).toBe('over there')
|
|
311
|
-
unmount()
|
|
312
|
-
target.remove()
|
|
313
|
-
})
|
|
314
|
-
|
|
315
|
-
it('Transition — show=false applies leave classes; transitionend removes element', async () => {
|
|
316
|
-
const visible = signal(true)
|
|
317
|
-
const { container, unmount } = mountInBrowser(
|
|
318
|
-
h(
|
|
319
|
-
Transition,
|
|
320
|
-
{ name: 'fade', show: () => visible() },
|
|
321
|
-
// Real CSS transition so transitionend actually fires when the
|
|
322
|
-
// class swap changes opacity (not just instantly).
|
|
323
|
-
h('div', { id: 'fading', style: 'transition: opacity 30ms; opacity: 1' }, 'hello'),
|
|
324
|
-
),
|
|
325
|
-
)
|
|
326
|
-
await flush()
|
|
327
|
-
expect(container.querySelector('#fading')).not.toBeNull()
|
|
328
|
-
|
|
329
|
-
visible.set(false)
|
|
330
|
-
// After two rAFs the leave-active + leave-to classes are applied.
|
|
331
|
-
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
|
332
|
-
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
|
333
|
-
|
|
334
|
-
const stillRendered = container.querySelector('#fading')
|
|
335
|
-
if (stillRendered) {
|
|
336
|
-
// Expect at least one of the fade-leave classes during the
|
|
337
|
-
// active phase.
|
|
338
|
-
expect(stillRendered.className).toMatch(/fade-leave/)
|
|
339
|
-
// Manually fire transitionend to short-circuit the 5s safety
|
|
340
|
-
// timeout (we don't care about real timing here, only that the
|
|
341
|
-
// event-driven cleanup path works).
|
|
342
|
-
stillRendered.dispatchEvent(new Event('transitionend', { bubbles: true }))
|
|
343
|
-
}
|
|
344
|
-
await flush()
|
|
345
|
-
await new Promise<void>((r) => setTimeout(r, 16))
|
|
346
|
-
expect(container.querySelector('#fading')).toBeNull()
|
|
347
|
-
unmount()
|
|
348
|
-
})
|
|
349
|
-
|
|
350
|
-
it('two mount() roots stay isolated — events on one do not affect the other', async () => {
|
|
351
|
-
const c1 = signal(0)
|
|
352
|
-
const c2 = signal(0)
|
|
353
|
-
const root1 = document.createElement('div')
|
|
354
|
-
const root2 = document.createElement('div')
|
|
355
|
-
document.body.append(root1, root2)
|
|
356
|
-
|
|
357
|
-
const u1 = mount(
|
|
358
|
-
h('button', { id: 'b1', onClick: () => c1.set(c1() + 1) }, () => `c1=${c1()}`),
|
|
359
|
-
root1,
|
|
360
|
-
)
|
|
361
|
-
const u2 = mount(
|
|
362
|
-
h('button', { id: 'b2', onClick: () => c2.set(c2() + 1) }, () => `c2=${c2()}`),
|
|
363
|
-
root2,
|
|
364
|
-
)
|
|
365
|
-
|
|
366
|
-
root1.querySelector<HTMLButtonElement>('#b1')!.click()
|
|
367
|
-
root1.querySelector<HTMLButtonElement>('#b1')!.click()
|
|
368
|
-
root2.querySelector<HTMLButtonElement>('#b2')!.click()
|
|
369
|
-
await flush()
|
|
370
|
-
|
|
371
|
-
expect(c1()).toBe(2)
|
|
372
|
-
expect(c2()).toBe(1)
|
|
373
|
-
|
|
374
|
-
u1()
|
|
375
|
-
u2()
|
|
376
|
-
root1.remove()
|
|
377
|
-
root2.remove()
|
|
378
|
-
})
|
|
379
|
-
|
|
380
|
-
it('event delegation — multi-word event names like onPointerDown actually fire', async () => {
|
|
381
|
-
// Regression for the bug fixed alongside this PR:
|
|
382
|
-
// `onPointerDown` was being lowercased to `pointerDown` for the
|
|
383
|
-
// DELEGATED_EVENTS lookup, missing the all-lowercase entry, so the
|
|
384
|
-
// handler was attached via addEventListener('pointerDown', ...) which
|
|
385
|
-
// never fires. Same for mousedown, dblclick, touchstart, etc.
|
|
386
|
-
let pointerDownFired = 0
|
|
387
|
-
let dblClickFired = 0
|
|
388
|
-
const { container, unmount } = mountInBrowser(
|
|
389
|
-
h('div', {
|
|
390
|
-
id: 'evt',
|
|
391
|
-
onPointerDown: () => {
|
|
392
|
-
pointerDownFired++
|
|
393
|
-
},
|
|
394
|
-
onDblClick: () => {
|
|
395
|
-
dblClickFired++
|
|
396
|
-
},
|
|
397
|
-
}),
|
|
398
|
-
)
|
|
399
|
-
const target = container.querySelector('#evt')!
|
|
400
|
-
target.dispatchEvent(
|
|
401
|
-
new PointerEvent('pointerdown', { bubbles: true, pointerId: 1 }),
|
|
402
|
-
)
|
|
403
|
-
target.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
|
|
404
|
-
await flush()
|
|
405
|
-
expect(pointerDownFired).toBe(1)
|
|
406
|
-
expect(dblClickFired).toBe(1)
|
|
407
|
-
unmount()
|
|
408
|
-
})
|
|
409
|
-
})
|
package/src/tests/setup.ts
DELETED
|
@@ -1,270 +0,0 @@
|
|
|
1
|
-
import { createContext, Fragment, h, provide, useContext } from '@pyreon/core'
|
|
2
|
-
import { signal } from '@pyreon/reactivity'
|
|
3
|
-
import { mount } from '@pyreon/runtime-dom'
|
|
4
|
-
import { describe, expect, it } from 'vitest'
|
|
5
|
-
|
|
6
|
-
const TestCtx = createContext('default')
|
|
7
|
-
|
|
8
|
-
describe('context inheritance through reactive boundaries', () => {
|
|
9
|
-
it('child inside reactive accessor inherits parent context', async () => {
|
|
10
|
-
let childValue: string | undefined
|
|
11
|
-
|
|
12
|
-
function Child() {
|
|
13
|
-
childValue = useContext(TestCtx)
|
|
14
|
-
return h('span', null, childValue)
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function Parent() {
|
|
18
|
-
provide(TestCtx, 'from-parent')
|
|
19
|
-
const show = signal(false)
|
|
20
|
-
setTimeout(() => show.set(true), 10)
|
|
21
|
-
return () => (show() ? h(Child, null) : null)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const container = document.createElement('div')
|
|
25
|
-
mount(h(Parent, null), container)
|
|
26
|
-
await new Promise((r) => setTimeout(r, 50))
|
|
27
|
-
expect(childValue).toBe('from-parent')
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('deeply nested context survives through multiple reactive layers', async () => {
|
|
31
|
-
let innerValue: string | undefined
|
|
32
|
-
|
|
33
|
-
function Inner() {
|
|
34
|
-
innerValue = useContext(TestCtx)
|
|
35
|
-
return h('span', null, innerValue)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function Middle() {
|
|
39
|
-
const show = signal(false)
|
|
40
|
-
setTimeout(() => show.set(true), 10)
|
|
41
|
-
return () => (show() ? h(Inner, null) : null)
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function Outer() {
|
|
45
|
-
provide(TestCtx, 'outer-value')
|
|
46
|
-
const show = signal(false)
|
|
47
|
-
setTimeout(() => show.set(true), 5)
|
|
48
|
-
return () => (show() ? h(Middle, null) : null)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const container = document.createElement('div')
|
|
52
|
-
mount(h(Outer, null), container)
|
|
53
|
-
await new Promise((r) => setTimeout(r, 100))
|
|
54
|
-
expect(innerValue).toBe('outer-value')
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
it("sibling providers don't leak context to each other", async () => {
|
|
58
|
-
let childAValue: string | undefined
|
|
59
|
-
let childBValue: string | undefined
|
|
60
|
-
|
|
61
|
-
function ChildA() {
|
|
62
|
-
childAValue = useContext(TestCtx)
|
|
63
|
-
return h('span', null, childAValue)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function ChildB() {
|
|
67
|
-
childBValue = useContext(TestCtx)
|
|
68
|
-
return h('span', null, childBValue)
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function ProviderA() {
|
|
72
|
-
provide(TestCtx, 'A')
|
|
73
|
-
return h(ChildA, null)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function ProviderB() {
|
|
77
|
-
provide(TestCtx, 'B')
|
|
78
|
-
return h(ChildB, null)
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function App() {
|
|
82
|
-
return h(Fragment, null, h(ProviderA, null), h(ProviderB, null))
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const container = document.createElement('div')
|
|
86
|
-
mount(h(App, null), container)
|
|
87
|
-
await new Promise((r) => setTimeout(r, 50))
|
|
88
|
-
|
|
89
|
-
expect(childAValue).toBe('A')
|
|
90
|
-
expect(childBValue).toBe('B')
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
it('Show toggle preserves context across hide/show cycle', async () => {
|
|
94
|
-
let childValue: string | undefined
|
|
95
|
-
let mountCount = 0
|
|
96
|
-
|
|
97
|
-
function Child() {
|
|
98
|
-
mountCount++
|
|
99
|
-
childValue = useContext(TestCtx)
|
|
100
|
-
return h('span', null, childValue)
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function Parent() {
|
|
104
|
-
provide(TestCtx, 'persistent')
|
|
105
|
-
const show = signal(true)
|
|
106
|
-
|
|
107
|
-
// Hide then show again
|
|
108
|
-
setTimeout(() => show.set(false), 10)
|
|
109
|
-
setTimeout(() => show.set(true), 30)
|
|
110
|
-
|
|
111
|
-
return () => (show() ? h(Child, null) : null)
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const container = document.createElement('div')
|
|
115
|
-
mount(h(Parent, null), container)
|
|
116
|
-
await new Promise((r) => setTimeout(r, 100))
|
|
117
|
-
|
|
118
|
-
expect(childValue).toBe('persistent')
|
|
119
|
-
expect(mountCount).toBe(2) // mounted twice (initial + re-show)
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
it('reactive context getter updates JSX without re-running component', async () => {
|
|
123
|
-
const ModeCtx = createContext<() => string>(() => 'light')
|
|
124
|
-
let renderCount = 0
|
|
125
|
-
|
|
126
|
-
function Child() {
|
|
127
|
-
const getMode = useContext(ModeCtx)
|
|
128
|
-
renderCount++
|
|
129
|
-
// Reading the getter inside a reactive accessor — updates when mode changes
|
|
130
|
-
return h('span', null, () => getMode())
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function Parent() {
|
|
134
|
-
const mode = signal<string>('light')
|
|
135
|
-
provide(ModeCtx, () => mode())
|
|
136
|
-
setTimeout(() => mode.set('dark'), 10)
|
|
137
|
-
return h(Child, null)
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const container = document.createElement('div')
|
|
141
|
-
mount(h(Parent, null), container)
|
|
142
|
-
|
|
143
|
-
expect(container.textContent).toBe('light')
|
|
144
|
-
|
|
145
|
-
await new Promise((r) => setTimeout(r, 50))
|
|
146
|
-
expect(container.textContent).toBe('dark')
|
|
147
|
-
// Component setup ran once — JSX expression re-evaluated reactively
|
|
148
|
-
expect(renderCount).toBe(1)
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
it('nested Show inside For with context', async () => {
|
|
152
|
-
const ItemCtx = createContext('none')
|
|
153
|
-
const collected: string[] = []
|
|
154
|
-
|
|
155
|
-
function Item() {
|
|
156
|
-
const val = useContext(ItemCtx)
|
|
157
|
-
collected.push(val)
|
|
158
|
-
return h('li', null, val)
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function Parent() {
|
|
162
|
-
provide(ItemCtx, 'parent-provided')
|
|
163
|
-
const items = signal([1, 2, 3])
|
|
164
|
-
const show = signal(false)
|
|
165
|
-
setTimeout(() => show.set(true), 10)
|
|
166
|
-
|
|
167
|
-
return () => (show() ? h('ul', null, ...items().map((i) => h(Item, { key: i }))) : null)
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const container = document.createElement('div')
|
|
171
|
-
mount(h(Parent, null), container)
|
|
172
|
-
await new Promise((r) => setTimeout(r, 50))
|
|
173
|
-
|
|
174
|
-
expect(collected.length).toBe(3)
|
|
175
|
-
expect(collected.every((v) => v === 'parent-provided')).toBe(true)
|
|
176
|
-
})
|
|
177
|
-
|
|
178
|
-
// ── Lock-in for the context-truncation fix (PR #406) ──────────────────────
|
|
179
|
-
//
|
|
180
|
-
// Pre-fix `mountReactive`'s `restoreContextStack` did
|
|
181
|
-
// `stack.length = savedLength` in its finally block — which destroyed
|
|
182
|
-
// every provider frame that the synchronous mount had pushed via
|
|
183
|
-
// `provide()`. Signal-driven re-runs of `_bind` / `renderEffect` inside
|
|
184
|
-
// the mounted subtree later saw a half-empty stack and `useContext()`
|
|
185
|
-
// silently fell back to the default. The original symptom was
|
|
186
|
-
// `<PyreonUI mode={signal()}>` toggling not propagating to consumers
|
|
187
|
-
// — discovered while writing PR #406's regression e2e and traced back
|
|
188
|
-
// through the binding subscription chain to this stack truncation.
|
|
189
|
-
//
|
|
190
|
-
// The fix has two cooperating layers; each provides defense-in-depth
|
|
191
|
-
// for the other, so this assertion would still pass if you revert
|
|
192
|
-
// EITHER alone — but reverting BOTH layers together fails it. To
|
|
193
|
-
// bisect-verify cleanly, revert both:
|
|
194
|
-
// 1. `packages/core/core/src/context.ts:restoreContextStack` — change
|
|
195
|
-
// the finally block back to `stack.length = savedLength` (truncate
|
|
196
|
-
// everything fn() pushed).
|
|
197
|
-
// 2. `packages/core/reactivity/src/effect.ts:_bind` — remove the
|
|
198
|
-
// `_snapshotCapture` capture/restore wiring so re-runs call fn()
|
|
199
|
-
// against whatever the live stack happens to be at re-run time.
|
|
200
|
-
//
|
|
201
|
-
// With both reverted, this test fails with `seen[1] === 'default'`.
|
|
202
|
-
//
|
|
203
|
-
// What the test exercises: a `_bind` text binding inside a child mounted
|
|
204
|
-
// through a reactive accessor (which goes through `mountReactive`). The
|
|
205
|
-
// binding subscribes to a signal and reads `useContext(Ctx)`. After
|
|
206
|
-
// initial mount, the provider frame is at risk of being truncated by
|
|
207
|
-
// `mountReactive`'s cleanup — toggling the signal forces the binding to
|
|
208
|
-
// re-run, which re-reads context. If either fix is in place, the
|
|
209
|
-
// re-read finds the provider frame and returns the provided value.
|
|
210
|
-
it('binding re-runs preserve context lookup across mountReactive cleanup boundary (PR #406 splice + snapshot capture)', async () => {
|
|
211
|
-
const Ctx = createContext('default')
|
|
212
|
-
const trigger = signal(0)
|
|
213
|
-
let lastSeen: string | undefined
|
|
214
|
-
let runCount = 0
|
|
215
|
-
|
|
216
|
-
function Inner() {
|
|
217
|
-
// JSX text accessor compiles to a `_bind` / renderEffect text binding
|
|
218
|
-
// that subscribes to `trigger` (signal read inside the body) AND
|
|
219
|
-
// captures the external context snapshot at setup time.
|
|
220
|
-
return h('span', null, () => {
|
|
221
|
-
trigger()
|
|
222
|
-
const v = useContext(Ctx)
|
|
223
|
-
lastSeen = v
|
|
224
|
-
runCount++
|
|
225
|
-
return v
|
|
226
|
-
})
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function Provider() {
|
|
230
|
-
// CRITICAL for exercising the bug: `provide()` runs INSIDE the
|
|
231
|
-
// reactive child fn (the accessor `() => h(Provider)` returned by
|
|
232
|
-
// App below). That puts Ctx on the stack DURING `mountReactive`'s
|
|
233
|
-
// restoreContextStack(snapshot, fn) execution — and pre-fix the
|
|
234
|
-
// truncating finally block (`stack.length = savedLength`) destroyed
|
|
235
|
-
// the frame the moment fn returned. If Outer pushed Ctx in its OWN
|
|
236
|
-
// body BEFORE returning the accessor, the frame would already be on
|
|
237
|
-
// the stack at snapshot-capture time and survive truncation
|
|
238
|
-
// unrelated — so the test wouldn't actually exercise the bug.
|
|
239
|
-
provide(Ctx, 'provider-value')
|
|
240
|
-
return h(Inner, null)
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function App() {
|
|
244
|
-
// No provide() here — the provider frame must be pushed strictly
|
|
245
|
-
// inside the reactive accessor body (= inside `mountReactive`'s fn)
|
|
246
|
-
// so the truncation-vs-splice path is reached.
|
|
247
|
-
return () => h(Provider, null)
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const container = document.createElement('div')
|
|
251
|
-
mount(h(App, null), container)
|
|
252
|
-
await new Promise((r) => setTimeout(r, 20))
|
|
253
|
-
expect(lastSeen).toBe('provider-value')
|
|
254
|
-
const initialRunCount = runCount
|
|
255
|
-
|
|
256
|
-
// Force the binding's effect to re-run AFTER the synchronous mount
|
|
257
|
-
// has fully unwound. With the broken pre-fix shape, mountReactive's
|
|
258
|
-
// `stack.length = savedLength` finally block has already destroyed
|
|
259
|
-
// the Ctx frame Provider pushed, so this re-run reads useContext
|
|
260
|
-
// against a stack that no longer contains the provider — and
|
|
261
|
-
// `lastSeen` becomes `'default'`.
|
|
262
|
-
trigger.set(1)
|
|
263
|
-
await new Promise((r) => setTimeout(r, 20))
|
|
264
|
-
expect(lastSeen).toBe('provider-value')
|
|
265
|
-
// Sanity: the re-run actually happened. Otherwise the test could
|
|
266
|
-
// pass for the wrong reason (e.g. if the trigger subscription wasn't
|
|
267
|
-
// established because the accessor didn't read `trigger()` reactively).
|
|
268
|
-
expect(runCount).toBeGreaterThan(initialRunCount)
|
|
269
|
-
})
|
|
270
|
-
})
|