@pyreon/runtime-dom 0.14.0 → 0.16.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/analysis/index.js.html +1 -1
- package/lib/analysis/keep-alive-entry.js.html +1 -1
- package/lib/analysis/transition-entry.js.html +1 -1
- package/lib/index.js +150 -62
- package/lib/keep-alive-entry.js +81 -44
- package/lib/transition-entry.js +3 -2
- package/lib/types/index.d.ts +54 -5
- package/package.json +7 -6
- package/src/delegate.ts +16 -0
- package/src/hydrate.ts +9 -2
- package/src/hydration-debug.ts +99 -14
- package/src/index.ts +11 -3
- package/src/keep-alive.ts +15 -4
- package/src/mount.ts +1 -2
- package/src/nodes.ts +87 -41
- package/src/props.ts +11 -2
- package/src/template.ts +48 -2
- package/src/tests/dev-gate-pattern.test.ts +17 -11
- package/src/tests/dev-gate-treeshake.test.ts +20 -26
- package/src/tests/fanout-repro.test.tsx +219 -0
- package/src/tests/hydration-integration.test.tsx +166 -1
- package/src/tests/mount.test.ts +92 -1
- package/src/tests/native-markers.test.ts +19 -0
- package/src/tests/runtime-dom.browser.test.ts +58 -6
- package/src/tests/show-context.test.ts +93 -0
- package/src/tests/template.test.ts +71 -1
- package/src/tests/transition.test.ts +5 -1
- package/src/transition-group.ts +22 -7
- package/src/transition.ts +11 -3
- package/lib/index.js.map +0 -1
- package/lib/keep-alive-entry.js.map +0 -1
- package/lib/transition-entry.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
- package/lib/types/keep-alive-entry.d.ts.map +0 -1
- package/lib/types/transition-entry.d.ts.map +0 -1
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/** @jsxImportSource @pyreon/core */
|
|
2
|
+
/**
|
|
3
|
+
* Reproduction of the deferred bug from PR #490 (queryReactiveKey-1000 journey).
|
|
4
|
+
*
|
|
5
|
+
* The pure-reactivity unit test (packages/core/reactivity/src/tests/fanout-repro.test.ts)
|
|
6
|
+
* passes — many effects subscribing to one signal all fire on every external
|
|
7
|
+
* .set. So the bug must involve actual Pyreon mount frames (provide/onMount/etc),
|
|
8
|
+
* not just the reactivity primitives.
|
|
9
|
+
*
|
|
10
|
+
* This test mounts a real Pyreon component with N effects subscribing to a
|
|
11
|
+
* shared signal, then writes to that signal in a tight external loop —
|
|
12
|
+
* mirroring the real shape from the queryReactiveKey-1000 journey.
|
|
13
|
+
*/
|
|
14
|
+
import { For, h } from '@pyreon/core'
|
|
15
|
+
import { effect, signal } from '@pyreon/reactivity'
|
|
16
|
+
import { describe, expect, it } from 'vitest'
|
|
17
|
+
import { mount } from '../index'
|
|
18
|
+
|
|
19
|
+
describe('signal fan-out under tight external write loop — INSIDE mount frame', () => {
|
|
20
|
+
it('100 effects in a Pyreon component — each fires on every external .set', () => {
|
|
21
|
+
const sig = signal(0)
|
|
22
|
+
const counts = new Array(100).fill(0)
|
|
23
|
+
|
|
24
|
+
const root = document.createElement('div')
|
|
25
|
+
document.body.appendChild(root)
|
|
26
|
+
|
|
27
|
+
const Component = () => {
|
|
28
|
+
for (let i = 0; i < 100; i++) {
|
|
29
|
+
const idx = i
|
|
30
|
+
effect(() => {
|
|
31
|
+
sig()
|
|
32
|
+
counts[idx]++
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
return h('div', null, 'mounted')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const dispose = mount(h(Component, null), root)
|
|
39
|
+
|
|
40
|
+
// All 100 effects ran their initial setup.
|
|
41
|
+
for (const c of counts) expect(c).toBe(1)
|
|
42
|
+
|
|
43
|
+
// 10 external writes outside any batch.
|
|
44
|
+
for (let i = 1; i <= 10; i++) sig.set(i)
|
|
45
|
+
|
|
46
|
+
// Each effect should have re-fired 10 more times → total = 11.
|
|
47
|
+
let failed = 0
|
|
48
|
+
for (let i = 0; i < counts.length; i++) {
|
|
49
|
+
if (counts[i] !== 11) failed++
|
|
50
|
+
}
|
|
51
|
+
expect(failed, `effects with wrong count (out of 100)`).toBe(0)
|
|
52
|
+
|
|
53
|
+
dispose()
|
|
54
|
+
root.remove()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('100 effects + an extra effect AFTER the loop — all fire on each .set', () => {
|
|
58
|
+
// Real-bug shape: a diagnostic effect placed AFTER the useQuery loop
|
|
59
|
+
// saw 0 re-runs across 10 flips, while a BEFORE-loop one saw 1 of 10.
|
|
60
|
+
const sig = signal(0)
|
|
61
|
+
const counts = new Array(100).fill(0)
|
|
62
|
+
let beforeRuns = 0
|
|
63
|
+
let afterRuns = 0
|
|
64
|
+
|
|
65
|
+
const root = document.createElement('div')
|
|
66
|
+
document.body.appendChild(root)
|
|
67
|
+
|
|
68
|
+
const Component = () => {
|
|
69
|
+
effect(() => {
|
|
70
|
+
sig()
|
|
71
|
+
beforeRuns++
|
|
72
|
+
})
|
|
73
|
+
for (let i = 0; i < 100; i++) {
|
|
74
|
+
const idx = i
|
|
75
|
+
effect(() => {
|
|
76
|
+
sig()
|
|
77
|
+
counts[idx]++
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
effect(() => {
|
|
81
|
+
sig()
|
|
82
|
+
afterRuns++
|
|
83
|
+
})
|
|
84
|
+
return h('div', null, 'mounted')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const dispose = mount(h(Component, null), root)
|
|
88
|
+
|
|
89
|
+
expect(beforeRuns).toBe(1)
|
|
90
|
+
expect(afterRuns).toBe(1)
|
|
91
|
+
for (const c of counts) expect(c).toBe(1)
|
|
92
|
+
|
|
93
|
+
for (let i = 1; i <= 10; i++) sig.set(i)
|
|
94
|
+
|
|
95
|
+
expect(beforeRuns, 'before-loop effect runs').toBe(11)
|
|
96
|
+
expect(afterRuns, 'after-loop effect runs').toBe(11)
|
|
97
|
+
let failed = 0
|
|
98
|
+
for (let i = 0; i < counts.length; i++) {
|
|
99
|
+
if (counts[i] !== 11) failed++
|
|
100
|
+
}
|
|
101
|
+
expect(failed, `effects with wrong count`).toBe(0)
|
|
102
|
+
|
|
103
|
+
dispose()
|
|
104
|
+
root.remove()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('the body of each effect ALSO writes to a per-effect local signal (mimics useQuery slot writes)', () => {
|
|
108
|
+
// useQuery's effect body calls observer.setOptions which triggers the
|
|
109
|
+
// observer's subscribe callback which does batch(() => 9 signal.sets).
|
|
110
|
+
// Approximate that with: each outer effect's body creates its own local
|
|
111
|
+
// signal and writes to it in a batch.
|
|
112
|
+
const sig = signal(0)
|
|
113
|
+
const counts = new Array(100).fill(0)
|
|
114
|
+
|
|
115
|
+
const root = document.createElement('div')
|
|
116
|
+
document.body.appendChild(root)
|
|
117
|
+
|
|
118
|
+
const Component = () => {
|
|
119
|
+
for (let i = 0; i < 100; i++) {
|
|
120
|
+
const idx = i
|
|
121
|
+
const slot = signal('')
|
|
122
|
+
effect(() => {
|
|
123
|
+
sig() // subscribe
|
|
124
|
+
// Mimic batched writes inside the effect body
|
|
125
|
+
slot.set(`run-${counts[idx]}`)
|
|
126
|
+
counts[idx]++
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
return h('div', null, 'mounted')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const dispose = mount(h(Component, null), root)
|
|
133
|
+
|
|
134
|
+
for (const c of counts) expect(c).toBe(1)
|
|
135
|
+
|
|
136
|
+
for (let i = 1; i <= 10; i++) sig.set(i)
|
|
137
|
+
|
|
138
|
+
let failed = 0
|
|
139
|
+
for (let i = 0; i < counts.length; i++) {
|
|
140
|
+
if (counts[i] !== 11) failed++
|
|
141
|
+
}
|
|
142
|
+
expect(failed, `effects with wrong count`).toBe(0)
|
|
143
|
+
|
|
144
|
+
dispose()
|
|
145
|
+
root.remove()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// ─── The real bug shape: <For> wrapping the queries ───────────────────────
|
|
149
|
+
//
|
|
150
|
+
// mountFor (`packages/core/runtime-dom/src/nodes.ts`) wraps its body in
|
|
151
|
+
// effect() but does NOT untrack the child mountChild calls (mountReactive
|
|
152
|
+
// does — line 92 of nodes.ts). As a result, any signal read during a
|
|
153
|
+
// child component's setup tracks against the For effect's run. When the
|
|
154
|
+
// tracked signal flips, For's effect re-runs → runCleanup() disposes ALL
|
|
155
|
+
// inner effects (the per-item setOptions effects) → handleIncrementalUpdate
|
|
156
|
+
// sees keys unchanged → does not re-mount → setOptions effects gone +
|
|
157
|
+
// never recreated. signalWrite fires N times but effectRun stays at the
|
|
158
|
+
// initial-mount count — the exact PR #490 observation.
|
|
159
|
+
|
|
160
|
+
it('REGRESSION: 100 effects mounted under <For> re-fire when shared signal flips', () => {
|
|
161
|
+
// Mirrors the queryReactiveKey-1000 shape: a mode/count tuple keys the
|
|
162
|
+
// For so its body mounts QueryAtScale-equivalent ONCE. Inside, N effects
|
|
163
|
+
// subscribe to a separate `reactKey` signal. External flips of reactKey
|
|
164
|
+
// must propagate to all N inner effects.
|
|
165
|
+
const reactKey = signal(0)
|
|
166
|
+
const counts = new Array(100).fill(0)
|
|
167
|
+
const root = document.createElement('div')
|
|
168
|
+
document.body.appendChild(root)
|
|
169
|
+
|
|
170
|
+
// Stable single-item array — For mounts the inner component exactly once.
|
|
171
|
+
const items = [{ id: 1 }] as const
|
|
172
|
+
type Item = (typeof items)[number]
|
|
173
|
+
|
|
174
|
+
const Inner = (props: { item: Item }) => {
|
|
175
|
+
// Read props.item to keep the component honest — same shape as
|
|
176
|
+
// QueryAtScale (which reads props.mode + props.count). That read
|
|
177
|
+
// tracks against the OUTER For effect via makeReactiveProps' getter.
|
|
178
|
+
void props.item.id
|
|
179
|
+
|
|
180
|
+
// Mimic useQuery's "read signal at construction time, OUTSIDE the
|
|
181
|
+
// inner effect" pattern. This is what `new QueryObserver(client,
|
|
182
|
+
// options())` does — options() reads reactKey while activeEffect is
|
|
183
|
+
// the outer effect (For's run), leaking the subscription up.
|
|
184
|
+
const _seed = reactKey() // ← this is the leak
|
|
185
|
+
|
|
186
|
+
// Plus: 100 effects each subscribing to reactKey via their own bodies.
|
|
187
|
+
for (let i = 0; i < 100; i++) {
|
|
188
|
+
const idx = i
|
|
189
|
+
effect(() => {
|
|
190
|
+
reactKey()
|
|
191
|
+
counts[idx]++
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
return h('div', { 'data-testid': 'inner' }, `mounted ${_seed}`)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const dispose = mount(
|
|
198
|
+
h(For, {
|
|
199
|
+
each: items,
|
|
200
|
+
by: (it: Item) => it.id,
|
|
201
|
+
children: (it: Item) => h(Inner, { item: it }),
|
|
202
|
+
}),
|
|
203
|
+
root,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
for (const c of counts) expect(c).toBe(1)
|
|
207
|
+
|
|
208
|
+
for (let i = 1; i <= 10; i++) reactKey.set(i)
|
|
209
|
+
|
|
210
|
+
let failed = 0
|
|
211
|
+
for (let i = 0; i < counts.length; i++) {
|
|
212
|
+
if (counts[i] !== 11) failed++
|
|
213
|
+
}
|
|
214
|
+
expect(failed, `effects with wrong count after 10 flips`).toBe(0)
|
|
215
|
+
|
|
216
|
+
dispose()
|
|
217
|
+
root.remove()
|
|
218
|
+
})
|
|
219
|
+
})
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* hydrate on client, verify signals work and DOM is reused.
|
|
6
6
|
*/
|
|
7
7
|
import type { VNodeChild } from '@pyreon/core'
|
|
8
|
-
import { For, Fragment, h, Show } from '@pyreon/core'
|
|
8
|
+
import { _rp, For, Fragment, h, Show } from '@pyreon/core'
|
|
9
9
|
import { signal } from '@pyreon/reactivity'
|
|
10
10
|
import { renderToString } from '@pyreon/runtime-server'
|
|
11
11
|
import { disableHydrationWarnings, enableHydrationWarnings, hydrateRoot } from '../index'
|
|
@@ -373,3 +373,168 @@ describe('hydration integration — mismatch recovery', () => {
|
|
|
373
373
|
cleanup()
|
|
374
374
|
})
|
|
375
375
|
})
|
|
376
|
+
|
|
377
|
+
// ─── onHydrationMismatch telemetry hook ────────────────────────────────────
|
|
378
|
+
//
|
|
379
|
+
// Pre-fix: runtime-dom emitted hydration mismatches via console.warn ONLY,
|
|
380
|
+
// gated on __DEV__. Production deployments (Sentry, Datadog) had no
|
|
381
|
+
// integration point — mismatches surfaced as silent recovery (text
|
|
382
|
+
// rewritten or DOM remounted) with no telemetry signal. The asymmetry
|
|
383
|
+
// with `@pyreon/core`'s `registerErrorHandler` (which captures component
|
|
384
|
+
// + reactivity errors via the `__pyreon_report_error__` bridge) was the
|
|
385
|
+
// gap.
|
|
386
|
+
//
|
|
387
|
+
// Post-fix: `onHydrationMismatch(handler)` registers a callback fired on
|
|
388
|
+
// EVERY mismatch in dev AND prod, independent of the warn toggle.
|
|
389
|
+
// Mirrors core's `registerErrorHandler` shape.
|
|
390
|
+
describe('hydration integration — onHydrationMismatch telemetry hook', () => {
|
|
391
|
+
test('handler fires with full mismatch context on tag mismatch', async () => {
|
|
392
|
+
const { onHydrationMismatch } = await import('../hydration-debug')
|
|
393
|
+
const captured: Array<{ type: string; expected: unknown; actual: unknown; path: string; timestamp: number }> = []
|
|
394
|
+
const unsub = onHydrationMismatch((ctx) => {
|
|
395
|
+
captured.push({
|
|
396
|
+
type: ctx.type,
|
|
397
|
+
expected: ctx.expected,
|
|
398
|
+
actual: ctx.actual,
|
|
399
|
+
path: ctx.path,
|
|
400
|
+
timestamp: ctx.timestamp,
|
|
401
|
+
})
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
const el = container()
|
|
405
|
+
el.innerHTML = '<div>server content</div>'
|
|
406
|
+
|
|
407
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
408
|
+
const cleanup = hydrateRoot(el, h('span', null, 'client content'))
|
|
409
|
+
|
|
410
|
+
expect(captured.length).toBeGreaterThan(0)
|
|
411
|
+
const tagMismatch = captured.find((c) => c.type === 'tag')
|
|
412
|
+
expect(tagMismatch).toBeDefined()
|
|
413
|
+
expect(tagMismatch?.expected).toBe('span')
|
|
414
|
+
expect(typeof tagMismatch?.path).toBe('string')
|
|
415
|
+
expect(typeof tagMismatch?.timestamp).toBe('number')
|
|
416
|
+
|
|
417
|
+
cleanup()
|
|
418
|
+
unsub()
|
|
419
|
+
warnSpy.mockRestore()
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
test('handler fires for tag mismatch in production-style silence (warn disabled)', () => {
|
|
423
|
+
const el = container()
|
|
424
|
+
el.innerHTML = '<div>server content</div>'
|
|
425
|
+
|
|
426
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
427
|
+
disableHydrationWarnings() // simulate production: warns silenced
|
|
428
|
+
|
|
429
|
+
return import('../hydration-debug').then(({ onHydrationMismatch }) => {
|
|
430
|
+
const captured: Array<{ type: string }> = []
|
|
431
|
+
const unsub = onHydrationMismatch((ctx) => {
|
|
432
|
+
captured.push({ type: ctx.type })
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
const cleanup = hydrateRoot(el, h('span', null, 'client content'))
|
|
436
|
+
|
|
437
|
+
// Telemetry hook fired even with warn disabled — independent.
|
|
438
|
+
expect(captured.length).toBeGreaterThan(0)
|
|
439
|
+
expect(captured.some((c) => c.type === 'tag')).toBe(true)
|
|
440
|
+
// console.warn was NOT called (production-style silence).
|
|
441
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
442
|
+
|
|
443
|
+
cleanup()
|
|
444
|
+
unsub()
|
|
445
|
+
warnSpy.mockRestore()
|
|
446
|
+
enableHydrationWarnings()
|
|
447
|
+
})
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
test('multiple handlers all receive forwarded mismatches; unsub stops one cleanly', async () => {
|
|
451
|
+
const { onHydrationMismatch } = await import('../hydration-debug')
|
|
452
|
+
let count1 = 0
|
|
453
|
+
let count2 = 0
|
|
454
|
+
const unsub1 = onHydrationMismatch(() => count1++)
|
|
455
|
+
const unsub2 = onHydrationMismatch(() => count2++)
|
|
456
|
+
|
|
457
|
+
const el = container()
|
|
458
|
+
el.innerHTML = '<div>server</div>'
|
|
459
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
460
|
+
|
|
461
|
+
const cleanup = hydrateRoot(el, h('span', null, 'client'))
|
|
462
|
+
|
|
463
|
+
expect(count1).toBeGreaterThan(0)
|
|
464
|
+
expect(count1).toBe(count2)
|
|
465
|
+
|
|
466
|
+
// Unsubscribe one — only the other fires next time.
|
|
467
|
+
unsub1()
|
|
468
|
+
const before2 = count2
|
|
469
|
+
const el2 = container()
|
|
470
|
+
el2.innerHTML = '<p>foo</p>'
|
|
471
|
+
const cleanup2 = hydrateRoot(el2, h('article', null, 'bar'))
|
|
472
|
+
|
|
473
|
+
expect(count2).toBeGreaterThan(before2)
|
|
474
|
+
|
|
475
|
+
cleanup()
|
|
476
|
+
cleanup2()
|
|
477
|
+
unsub2()
|
|
478
|
+
warnSpy.mockRestore()
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
test('handler errors do not propagate into hydration', async () => {
|
|
482
|
+
const { onHydrationMismatch } = await import('../hydration-debug')
|
|
483
|
+
let goodHandlerFired = false
|
|
484
|
+
const unsubBad = onHydrationMismatch(() => {
|
|
485
|
+
throw new Error('telemetry SDK exploded')
|
|
486
|
+
})
|
|
487
|
+
const unsubGood = onHydrationMismatch(() => {
|
|
488
|
+
goodHandlerFired = true
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
const el = container()
|
|
492
|
+
el.innerHTML = '<div>server</div>'
|
|
493
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
494
|
+
disableHydrationWarnings()
|
|
495
|
+
|
|
496
|
+
// Hydration must complete without throwing despite bad handler.
|
|
497
|
+
const cleanup = hydrateRoot(el, h('span', null, 'client'))
|
|
498
|
+
expect(goodHandlerFired).toBe(true)
|
|
499
|
+
// Client content still rendered — recovery worked.
|
|
500
|
+
expect(el.textContent).toContain('client')
|
|
501
|
+
|
|
502
|
+
cleanup()
|
|
503
|
+
unsubBad()
|
|
504
|
+
unsubGood()
|
|
505
|
+
warnSpy.mockRestore()
|
|
506
|
+
enableHydrationWarnings()
|
|
507
|
+
})
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
// ─── _rp prop forwarding through SSR -> hydrate ─────────────────────────────
|
|
511
|
+
|
|
512
|
+
describe('hydration integration — `_rp`-wrapped component props (regression)', () => {
|
|
513
|
+
// Pre-fix, hydrate.ts skipped `makeReactiveProps` on the way into a
|
|
514
|
+
// component, so `props.x` returned the raw `_rp` function instead of the
|
|
515
|
+
// resolved value. mount.ts already did the right thing, so the failure mode
|
|
516
|
+
// surfaced only on cold-start SSR/hydrate (the fundamentals NavItem layout
|
|
517
|
+
// shape — see e2e/fundamentals/playground.spec.ts). Lock in BOTH the SSR
|
|
518
|
+
// emit and the post-hydration value.
|
|
519
|
+
test('SSR emits resolved string from `_rp` prop, hydration preserves it', async () => {
|
|
520
|
+
const Link = (props: { to: string }) =>
|
|
521
|
+
h('a', { href: `#${props.to}`, id: 'lnk' }, () => props.to)
|
|
522
|
+
|
|
523
|
+
const html = await renderToString(
|
|
524
|
+
h(Link, { to: _rp(() => '/about') as unknown as string }),
|
|
525
|
+
)
|
|
526
|
+
expect(html).toBe('<a href="#/about" id="lnk">/about</a>')
|
|
527
|
+
expect(html).not.toContain('=>')
|
|
528
|
+
|
|
529
|
+
const el = container()
|
|
530
|
+
el.innerHTML = html
|
|
531
|
+
const cleanup = hydrateRoot(
|
|
532
|
+
el,
|
|
533
|
+
h(Link, { to: _rp(() => '/about') as unknown as string }),
|
|
534
|
+
)
|
|
535
|
+
const link = el.querySelector<HTMLAnchorElement>('#lnk')!
|
|
536
|
+
expect(link.getAttribute('href')).toBe('#/about')
|
|
537
|
+
expect(link.textContent).toBe('/about')
|
|
538
|
+
cleanup()
|
|
539
|
+
})
|
|
540
|
+
})
|
package/src/tests/mount.test.ts
CHANGED
|
@@ -7,12 +7,14 @@ import {
|
|
|
7
7
|
For,
|
|
8
8
|
Fragment,
|
|
9
9
|
h,
|
|
10
|
+
lazy,
|
|
10
11
|
Match,
|
|
11
12
|
onMount,
|
|
12
13
|
onUnmount,
|
|
13
14
|
onUpdate,
|
|
14
15
|
Portal,
|
|
15
16
|
Show,
|
|
17
|
+
Suspense as _Suspense,
|
|
16
18
|
Switch,
|
|
17
19
|
} from '@pyreon/core'
|
|
18
20
|
import { cell, signal } from '@pyreon/reactivity'
|
|
@@ -36,6 +38,7 @@ const Transition = _Transition as unknown as ComponentFn<Record<string, unknown>
|
|
|
36
38
|
const TransitionGroup = _TransitionGroup as unknown as ComponentFn<Record<string, unknown>>
|
|
37
39
|
const ErrorBoundary = _ErrorBoundary as unknown as ComponentFn<Record<string, unknown>>
|
|
38
40
|
const KeepAlive = _KeepAlive as unknown as ComponentFn<Record<string, unknown>>
|
|
41
|
+
const Suspense = _Suspense as unknown as ComponentFn<Record<string, unknown>>
|
|
39
42
|
|
|
40
43
|
function container(): HTMLElement {
|
|
41
44
|
const el = document.createElement('div')
|
|
@@ -773,6 +776,94 @@ describe('ErrorBoundary', () => {
|
|
|
773
776
|
;(el.querySelector('#fix') as HTMLButtonElement).click()
|
|
774
777
|
expect(el.querySelector('#signal-ok')?.textContent).toBe('fixed')
|
|
775
778
|
})
|
|
779
|
+
|
|
780
|
+
// ── lazy() + Suspense + ErrorBoundary integration ──
|
|
781
|
+
//
|
|
782
|
+
// The `lazy(loader)` wrapper throws synchronously when its loader's
|
|
783
|
+
// promise rejects (`error()` returns truthy → `throw err`).
|
|
784
|
+
//
|
|
785
|
+
// Pyreon components run ONCE — reactivity comes from reading signals
|
|
786
|
+
// inside reactive scopes. `lazy()`'s wrapper reads its `error` /
|
|
787
|
+
// `loaded` signals inline, so the surrounding context must be a
|
|
788
|
+
// reactive scope for signal changes to trigger re-render.
|
|
789
|
+
//
|
|
790
|
+
// `Suspense` wraps its children in `h(Fragment, null, () => ...)` —
|
|
791
|
+
// an explicit reactive accessor that calls `__loading()`. THAT
|
|
792
|
+
// accessor's reactive scope is what tracks lazy's signals: when the
|
|
793
|
+
// loader rejects, the accessor re-runs, the lazy child re-mounts,
|
|
794
|
+
// the wrapper throws, mountComponent catches, dispatches to the
|
|
795
|
+
// nearest `<ErrorBoundary>` on the boundary stack.
|
|
796
|
+
//
|
|
797
|
+
// Without Suspense, lazy()'s post-mount errors don't surface (no
|
|
798
|
+
// reactive scope to drive re-render). This is consistent with the
|
|
799
|
+
// framework's component-runs-once contract — but worth pinning
|
|
800
|
+
// down with explicit tests.
|
|
801
|
+
|
|
802
|
+
test('lazy() loader rejection surfaces to ErrorBoundary via Suspense', async () => {
|
|
803
|
+
const el = container()
|
|
804
|
+
const Comp = lazy<Record<string, never>>(() =>
|
|
805
|
+
Promise.reject(new Error('module load failed')),
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
mount(
|
|
809
|
+
h(ErrorBoundary, {
|
|
810
|
+
fallback: (err: unknown) =>
|
|
811
|
+
h('p', { id: 'lazy-fb' }, `Caught: ${(err as Error).message}`),
|
|
812
|
+
children: h(
|
|
813
|
+
Suspense,
|
|
814
|
+
{ fallback: h('p', { id: 'spinner' }, 'loading...') },
|
|
815
|
+
h(Comp, {}),
|
|
816
|
+
),
|
|
817
|
+
}),
|
|
818
|
+
el,
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
// Initial render: lazy is still loading → Suspense shows spinner,
|
|
822
|
+
// boundary fallback NOT triggered yet.
|
|
823
|
+
expect(el.querySelector('#spinner')).not.toBeNull()
|
|
824
|
+
expect(el.querySelector('#lazy-fb')).toBeNull()
|
|
825
|
+
|
|
826
|
+
// Wait for promise rejection to flush.
|
|
827
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
828
|
+
// Reactive flush.
|
|
829
|
+
await Promise.resolve()
|
|
830
|
+
|
|
831
|
+
// After load fails: Suspense's reactive accessor re-runs → child
|
|
832
|
+
// wrapper throws → caught by mountComponent → dispatched to
|
|
833
|
+
// ErrorBoundary → fallback rendered.
|
|
834
|
+
expect(el.querySelector('#lazy-fb')?.textContent).toContain('module load failed')
|
|
835
|
+
expect(el.querySelector('#spinner')).toBeNull()
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
test('lazy() resolves successfully renders content without firing fallback', async () => {
|
|
839
|
+
const el = container()
|
|
840
|
+
const Inner: ComponentFn<Record<string, never>> = () =>
|
|
841
|
+
h('p', { id: 'loaded' }, 'content')
|
|
842
|
+
const Comp = lazy<Record<string, never>>(() => Promise.resolve({ default: Inner }))
|
|
843
|
+
|
|
844
|
+
let fallbackInvocations = 0
|
|
845
|
+
mount(
|
|
846
|
+
h(ErrorBoundary, {
|
|
847
|
+
fallback: () => {
|
|
848
|
+
fallbackInvocations++
|
|
849
|
+
return h('p', { id: 'should-not-appear' }, 'error')
|
|
850
|
+
},
|
|
851
|
+
children: h(
|
|
852
|
+
Suspense,
|
|
853
|
+
{ fallback: h('p', { id: 'spinner' }, 'loading...') },
|
|
854
|
+
h(Comp, {}),
|
|
855
|
+
),
|
|
856
|
+
}),
|
|
857
|
+
el,
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
861
|
+
await Promise.resolve()
|
|
862
|
+
|
|
863
|
+
expect(el.querySelector('#loaded')?.textContent).toBe('content')
|
|
864
|
+
expect(el.querySelector('#should-not-appear')).toBeNull()
|
|
865
|
+
expect(fallbackInvocations).toBe(0)
|
|
866
|
+
})
|
|
776
867
|
})
|
|
777
868
|
|
|
778
869
|
// ─── Transition component ─────────────────────────────────────────────────────
|
|
@@ -1520,7 +1611,7 @@ describe('mount — edge cases', () => {
|
|
|
1520
1611
|
|
|
1521
1612
|
test('mounting array of children', () => {
|
|
1522
1613
|
const el = container()
|
|
1523
|
-
mount(h('div', null,
|
|
1614
|
+
mount(h('div', null, h('span', null, 'a'), h('span', null, 'b'), h('span', null, 'c')), el)
|
|
1524
1615
|
expect(el.querySelectorAll('span').length).toBe(3)
|
|
1525
1616
|
})
|
|
1526
1617
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { isNativeCompat } from '@pyreon/core'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { KeepAlive } from '../keep-alive'
|
|
4
|
+
import { Transition } from '../transition'
|
|
5
|
+
import { TransitionGroup } from '../transition-group'
|
|
6
|
+
|
|
7
|
+
// Marker-presence assertion (PR 3 lock-in). Bisect-verified: removing
|
|
8
|
+
// `nativeCompat(...)` from any of these files fails the corresponding test.
|
|
9
|
+
describe('native-compat markers — @pyreon/runtime-dom', () => {
|
|
10
|
+
it('Transition is marked native', () => {
|
|
11
|
+
expect(isNativeCompat(Transition)).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
it('TransitionGroup is marked native', () => {
|
|
14
|
+
expect(isNativeCompat(TransitionGroup)).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
it('KeepAlive is marked native', () => {
|
|
17
|
+
expect(isNativeCompat(KeepAlive)).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
})
|
|
@@ -149,6 +149,57 @@ describe('runtime-dom in real browser', () => {
|
|
|
149
149
|
unmount()
|
|
150
150
|
})
|
|
151
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
|
+
|
|
152
203
|
it('dispatches a real PointerEvent and fires the onClick handler', async () => {
|
|
153
204
|
const clicks = signal(0)
|
|
154
205
|
const { container, unmount } = mountInBrowser(
|
|
@@ -177,12 +228,13 @@ describe('runtime-dom in real browser', () => {
|
|
|
177
228
|
unmount()
|
|
178
229
|
})
|
|
179
230
|
|
|
180
|
-
it('emits the duplicate-key
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
// proves the same code path
|
|
185
|
-
|
|
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')
|
|
186
238
|
|
|
187
239
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
188
240
|
const dupes = signal([
|