@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.
@@ -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 pattern that
12
- // Vite/Rolldown can literal-replace at build time, NOT the broken
13
- // `typeof process` pattern that PR #200 cleaned up.
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` (introduced in PR #220) is the
18
- // CI-wide enforcement for this. This test is the narrow, package-local
19
- // safety net so a regression in runtime-dom is caught even if the lint
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 import.meta.env.DEV, not typeof process`, async () => {
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 constant must exist, defined via Vite's literal-replaced env.
35
- expect(code).toMatch(/const\s+__DEV__\s*=\s*import\.meta\.env\??\.DEV/)
36
- // The broken pattern must not appear anywhere in executable code.
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 T1.1 C-2 finding.
11
+ // Bundle-level regression test for the dev-warning gate.
12
12
  //
13
- // Background the shape of the problem from PR #227 bring-up:
14
- // Raw `esbuild --minify` preserves chained `__DEV__ && cond &&
15
- // console.warn(...)` patterns even when `import.meta.env.DEV` is
16
- // defined to `false`. That tempted a pattern-rewrite across all
17
- // Pyreon sources.
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
- // What the C-2 investigation actually found:
20
- // Pyreon's real consumer path is Vite (which uses Rolldown under the
21
- // hood plus its own import.meta.env replacement + tree-shake passes).
22
- // Vite's production build DOES eliminate the chained patterns
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
- // This test bundles a runtime-dom entry through Vite's production
28
- // build and asserts dev-warning strings are GONE. If Vite's handling
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. `define` on
88
- // `import.meta.env` isn't usually needed (Vite sets it automatically
89
- // based on mode), but `mode: 'production'` flips DEV to false.
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
- 'import.meta.env.DEV': JSON.stringify(dev),
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
+ })
@@ -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
+ })