@pyreon/runtime-dom 0.13.1 → 0.15.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/README.md +23 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/analysis/keep-alive-entry.js.html +5406 -0
- package/lib/analysis/transition-entry.js.html +5406 -0
- package/lib/index.js +156 -57
- package/lib/keep-alive-entry.js +1342 -0
- package/lib/transition-entry.js +168 -0
- package/lib/types/index.d.ts +54 -5
- package/lib/types/keep-alive-entry.d.ts +41 -0
- package/lib/types/transition-entry.d.ts +59 -0
- package/package.json +17 -6
- package/src/delegate.ts +16 -0
- package/src/hydrate.ts +23 -14
- package/src/hydration-debug.ts +99 -14
- package/src/index.ts +30 -6
- package/src/keep-alive-entry.ts +3 -0
- package/src/keep-alive.ts +5 -1
- package/src/mount.ts +160 -56
- package/src/nodes.ts +62 -13
- package/src/props.ts +1 -2
- package/src/template.ts +57 -2
- package/src/tests/coverage-gaps.test.ts +709 -0
- package/src/tests/dev-gate-pattern.test.ts +17 -11
- package/src/tests/dev-gate-treeshake.test.ts +20 -26
- package/src/tests/hydration-integration.test.tsx +166 -1
- package/src/tests/lis-prepend.browser.test.ts +99 -0
- package/src/tests/mount.test.ts +91 -0
- package/src/tests/native-markers.test.ts +19 -0
- package/src/tests/runtime-dom.browser.test.ts +121 -7
- package/src/tests/show-context.test.ts +93 -0
- package/src/tests/template.test.ts +135 -1
- package/src/transition-entry.ts +7 -0
- package/src/transition-group.ts +6 -1
- package/src/transition.ts +11 -3
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
|
@@ -8,22 +8,27 @@ const SRC = path.resolve(here, '..')
|
|
|
8
8
|
|
|
9
9
|
// Source-pattern regression test for the dev-mode warning gate. Pairs with
|
|
10
10
|
// the browser test in `runtime-dom.browser.test.ts` (which proves the gate
|
|
11
|
-
// fires in dev) — this asserts the gate is written using the
|
|
12
|
-
//
|
|
13
|
-
//
|
|
11
|
+
// fires in dev) — this asserts the gate is written using the bundler-agnostic
|
|
12
|
+
// pattern (`process.env.NODE_ENV !== 'production'`) that every modern bundler
|
|
13
|
+
// (Vite, Webpack/Next.js, esbuild, Rollup, Parcel, Bun) literal-replaces at
|
|
14
|
+
// consumer build time. The two previously-shipped broken patterns must not
|
|
15
|
+
// appear:
|
|
16
|
+
// 1. `typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'`
|
|
17
|
+
// — dead in Vite browser bundles.
|
|
18
|
+
// 2. `import.meta.env.DEV` — Vite/Rolldown-only; undefined and silent in
|
|
19
|
+
// Webpack/Next.js, esbuild, Rollup, Parcel, Bun.
|
|
14
20
|
//
|
|
15
21
|
// Same shape as `flow/src/tests/integration.test.ts:warnIgnoredOptions`.
|
|
16
22
|
//
|
|
17
|
-
// The lint rule `pyreon/no-process-dev-gate`
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// configuration drifts.
|
|
23
|
+
// The lint rule `pyreon/no-process-dev-gate` is the CI-wide enforcement for
|
|
24
|
+
// this. This test is the narrow, package-local safety net so a regression in
|
|
25
|
+
// runtime-dom is caught even if the lint configuration drifts.
|
|
21
26
|
|
|
22
27
|
const FILES_WITH_DEV_GATE = ['nodes.ts', 'hydration-debug.ts']
|
|
23
28
|
|
|
24
29
|
describe('runtime-dom dev-warning gate (source pattern)', () => {
|
|
25
30
|
for (const file of FILES_WITH_DEV_GATE) {
|
|
26
|
-
it(`${file} uses
|
|
31
|
+
it(`${file} uses bundler-agnostic process.env.NODE_ENV`, async () => {
|
|
27
32
|
const source = await readFile(path.join(SRC, file), 'utf8')
|
|
28
33
|
// Strip line + block comments so referencing the broken pattern in
|
|
29
34
|
// documentation doesn't false-positive.
|
|
@@ -31,10 +36,11 @@ describe('runtime-dom dev-warning gate (source pattern)', () => {
|
|
|
31
36
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
32
37
|
.replace(/(^|[^:])\/\/.*$/gm, '$1')
|
|
33
38
|
|
|
34
|
-
// The gate
|
|
35
|
-
expect(code).toMatch(/
|
|
36
|
-
//
|
|
39
|
+
// The bundler-agnostic gate must appear (bare `process.env.NODE_ENV`).
|
|
40
|
+
expect(code).toMatch(/process\.env\.NODE_ENV/)
|
|
41
|
+
// Neither broken pattern may appear in executable code.
|
|
37
42
|
expect(code).not.toMatch(/typeof\s+process\s*!==?\s*['"]undefined['"]/)
|
|
43
|
+
expect(code).not.toMatch(/import\.meta\.env\??\.DEV/)
|
|
38
44
|
})
|
|
39
45
|
}
|
|
40
46
|
})
|
|
@@ -8,28 +8,24 @@ import { build } from 'vite'
|
|
|
8
8
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
9
9
|
const SRC = path.resolve(here, '..')
|
|
10
10
|
|
|
11
|
-
// Bundle-level regression test for the
|
|
11
|
+
// Bundle-level regression test for the dev-warning gate.
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
13
|
+
// runtime-dom uses bundler-agnostic `process.env.NODE_ENV !== 'production'`
|
|
14
|
+
// for dev gates — the cross-bundler library convention used by React, Vue,
|
|
15
|
+
// Preact, Solid, MobX, Redux. Every modern bundler (Vite, Webpack/Next.js,
|
|
16
|
+
// esbuild, Rollup, Parcel, Bun) auto-replaces `process.env.NODE_ENV` at
|
|
17
|
+
// consumer build time. This test bundles each representative runtime-dom
|
|
18
|
+
// file through Vite's production build and asserts dev-warning strings
|
|
19
|
+
// are GONE from the output — proving literal-replacement + dead-code
|
|
20
|
+
// elimination work end-to-end.
|
|
18
21
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// correctly — the raw esbuild baseline was misleading. Raw Rolldown
|
|
24
|
-
// alone also doesn't replicate Vite's behavior because Rolldown's
|
|
25
|
-
// `define` doesn't rewrite optional-chain access paths.
|
|
22
|
+
// The test uses Vite because that's Pyreon's reference consumer pipeline
|
|
23
|
+
// today; the same files under Webpack / esbuild / Rollup etc. tree-shake
|
|
24
|
+
// equivalently because they all replace `process.env.NODE_ENV`. Vite is
|
|
25
|
+
// just the most-tested path.
|
|
26
26
|
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
// ever regresses, this catches it.
|
|
30
|
-
//
|
|
31
|
-
// Scope note: the existing `dev-gate-pattern.test.ts` is the cheap
|
|
32
|
-
// source-level guard (grep for `typeof process`, require `import.meta.env.DEV`).
|
|
27
|
+
// Scope note: `dev-gate-pattern.test.ts` is the cheap source-level guard
|
|
28
|
+
// (grep for the broken patterns, require bare `process.env.NODE_ENV`).
|
|
33
29
|
// This test is the expensive end-to-end guard for the bundle path.
|
|
34
30
|
|
|
35
31
|
interface FileContract {
|
|
@@ -84,19 +80,17 @@ const FILES_UNDER_TEST: FileContract[] = [
|
|
|
84
80
|
async function bundleWithVite(entry: string, dev: boolean): Promise<string> {
|
|
85
81
|
const outDir = mkdtempSync(path.join(tmpdir(), 'pyreon-vite-treeshake-'))
|
|
86
82
|
try {
|
|
87
|
-
// Vite library-mode build with explicit minify.
|
|
88
|
-
// `
|
|
89
|
-
//
|
|
83
|
+
// Vite library-mode build with explicit minify. The bundler-agnostic
|
|
84
|
+
// gate uses `process.env.NODE_ENV` — Vite's library mode doesn't apply
|
|
85
|
+
// the default replacement automatically, so we set it ourselves to
|
|
86
|
+
// match what every modern bundler does at consumer build time.
|
|
90
87
|
await build({
|
|
91
88
|
mode: dev ? 'development' : 'production',
|
|
92
89
|
logLevel: 'error',
|
|
93
90
|
configFile: false,
|
|
94
91
|
resolve: { conditions: ['bun'] },
|
|
95
|
-
// Explicit define — Vite in lib mode doesn't always apply the
|
|
96
|
-
// default production env replacement, so we set it ourselves.
|
|
97
92
|
define: {
|
|
98
|
-
'
|
|
99
|
-
'import.meta.env': JSON.stringify({ DEV: dev, PROD: !dev, MODE: dev ? 'development' : 'production' }),
|
|
93
|
+
'process.env.NODE_ENV': JSON.stringify(dev ? 'development' : 'production'),
|
|
100
94
|
},
|
|
101
95
|
build: {
|
|
102
96
|
// PINNED minifier: 'esbuild' is what Pyreon's reference consumers
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { For, h } from '@pyreon/core'
|
|
2
|
+
import { signal } from '@pyreon/reactivity'
|
|
3
|
+
import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
// Real-Chromium coverage for the `computeForLis` known-slot fast path.
|
|
7
|
+
// happy-dom is fine for op-counting the LIS probes but doesn't prove the
|
|
8
|
+
// reconciler actually lays out the DOM in the right order after a prepend.
|
|
9
|
+
// A bug in the fast path would silently corrupt the DOM under real CSS
|
|
10
|
+
// layout — caught here, not in the vitest happy-dom suite.
|
|
11
|
+
|
|
12
|
+
function buildRows(count: number, offset = 0): Array<{ id: number; label: string }> {
|
|
13
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
14
|
+
id: i + offset,
|
|
15
|
+
label: `row-${i + offset}`,
|
|
16
|
+
}))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('runtime-dom LIS prepend fast path', () => {
|
|
20
|
+
it('prepends 100 rows to a 100-row list, DOM matches the signal order', async () => {
|
|
21
|
+
type Row = { id: number; label: string }
|
|
22
|
+
const rows = signal<Row[]>(buildRows(100, 0))
|
|
23
|
+
const { container, unmount } = mountInBrowser(
|
|
24
|
+
h(
|
|
25
|
+
'ul',
|
|
26
|
+
{ id: 'list' },
|
|
27
|
+
For({
|
|
28
|
+
each: rows,
|
|
29
|
+
by: (r: Row) => r.id,
|
|
30
|
+
children: (r: Row) => h('li', { 'data-id': String(r.id) }, r.label),
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
let items = container.querySelectorAll<HTMLLIElement>('#list li')
|
|
36
|
+
expect(items).toHaveLength(100)
|
|
37
|
+
expect(items[0]?.dataset.id).toBe('0')
|
|
38
|
+
expect(items[99]?.dataset.id).toBe('99')
|
|
39
|
+
|
|
40
|
+
// Prepend 100 new rows with ids 100..199. Final list: [100..199, 0..99].
|
|
41
|
+
const prepended = buildRows(100, 100)
|
|
42
|
+
rows.set([...prepended, ...rows()])
|
|
43
|
+
await flush()
|
|
44
|
+
|
|
45
|
+
items = container.querySelectorAll<HTMLLIElement>('#list li')
|
|
46
|
+
expect(items).toHaveLength(200)
|
|
47
|
+
// First 100 items must be the prepended rows in order.
|
|
48
|
+
expect(items[0]?.dataset.id).toBe('100')
|
|
49
|
+
expect(items[99]?.dataset.id).toBe('199')
|
|
50
|
+
// Next 100 must be the original rows in original order.
|
|
51
|
+
expect(items[100]?.dataset.id).toBe('0')
|
|
52
|
+
expect(items[199]?.dataset.id).toBe('99')
|
|
53
|
+
unmount()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('measured prepend wall-clock stays in the expected range', async () => {
|
|
57
|
+
// HONEST framing: the LIS fast path saves ~50-100 µs on a 1k prepend.
|
|
58
|
+
// The full prepend cost is dominated by DOM work (~5-20 ms in real
|
|
59
|
+
// Chromium for 1k <li> nodes). This test measures the full wall-clock
|
|
60
|
+
// to give a realistic upper bound — the LIS save is a single-digit
|
|
61
|
+
// percent improvement, not a headline win.
|
|
62
|
+
//
|
|
63
|
+
// Assertion bound is intentionally loose (< 500 ms). The purpose is
|
|
64
|
+
// to anchor a "is this catastrophically slow" ceiling, not to prove
|
|
65
|
+
// the LIS fix is responsible for any particular chunk of time.
|
|
66
|
+
type Row = { id: number; label: string }
|
|
67
|
+
const rows = signal<Row[]>(buildRows(1000, 0))
|
|
68
|
+
const { container, unmount } = mountInBrowser(
|
|
69
|
+
h(
|
|
70
|
+
'ul',
|
|
71
|
+
{ id: 'perf-list' },
|
|
72
|
+
For({
|
|
73
|
+
each: rows,
|
|
74
|
+
by: (r: Row) => r.id,
|
|
75
|
+
children: (r: Row) => h('li', { 'data-id': String(r.id) }, r.label),
|
|
76
|
+
}),
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
// Warm up — first mount allocates backing arrays.
|
|
81
|
+
await flush()
|
|
82
|
+
expect(container.querySelectorAll('#perf-list li')).toHaveLength(1000)
|
|
83
|
+
|
|
84
|
+
const prepended = buildRows(1000, 1000)
|
|
85
|
+
const t0 = performance.now()
|
|
86
|
+
rows.set([...prepended, ...rows()])
|
|
87
|
+
await flush()
|
|
88
|
+
const elapsed = performance.now() - t0
|
|
89
|
+
|
|
90
|
+
// oxlint-disable-next-line no-console
|
|
91
|
+
console.log(`[lis-prepend] 1k→2k prepend wall-clock: ${elapsed.toFixed(2)}ms`)
|
|
92
|
+
|
|
93
|
+
expect(container.querySelectorAll('#perf-list li')).toHaveLength(2000)
|
|
94
|
+
// Loose ceiling. Real Chromium typically lands at 10-50ms. We're not
|
|
95
|
+
// asserting the LIS win — we're asserting the whole path didn't break.
|
|
96
|
+
expect(elapsed).toBeLessThan(500)
|
|
97
|
+
unmount()
|
|
98
|
+
})
|
|
99
|
+
})
|
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 ─────────────────────────────────────────────────────
|
|
@@ -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
|
+
})
|