@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.
Files changed (53) hide show
  1. package/package.json +5 -9
  2. package/src/delegate.ts +0 -98
  3. package/src/devtools.ts +0 -339
  4. package/src/env.d.ts +0 -6
  5. package/src/hydrate.ts +0 -450
  6. package/src/hydration-debug.ts +0 -129
  7. package/src/index.ts +0 -83
  8. package/src/keep-alive-entry.ts +0 -3
  9. package/src/keep-alive.ts +0 -83
  10. package/src/manifest.ts +0 -236
  11. package/src/mount.ts +0 -597
  12. package/src/nodes.ts +0 -896
  13. package/src/props.ts +0 -474
  14. package/src/template.ts +0 -523
  15. package/src/tests/callback-ref-unmount.browser.test.ts +0 -62
  16. package/src/tests/callback-ref-unmount.test.ts +0 -52
  17. package/src/tests/compiler-integration.test.tsx +0 -508
  18. package/src/tests/coverage-gaps.test.ts +0 -3183
  19. package/src/tests/coverage.test.ts +0 -1140
  20. package/src/tests/ctx-stack-growth-repro.test.tsx +0 -158
  21. package/src/tests/dev-gate-pattern.test.ts +0 -46
  22. package/src/tests/dev-gate-treeshake.test.ts +0 -256
  23. package/src/tests/error-boundary-stack-leak-repro.test.tsx +0 -133
  24. package/src/tests/fanout-repro.test.tsx +0 -219
  25. package/src/tests/hydration-integration.test.tsx +0 -540
  26. package/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts +0 -140
  27. package/src/tests/lifecycle-integration.test.tsx +0 -342
  28. package/src/tests/lis-prepend.browser.test.ts +0 -99
  29. package/src/tests/manifest-snapshot.test.ts +0 -85
  30. package/src/tests/mount.test.ts +0 -3529
  31. package/src/tests/native-markers.test.ts +0 -19
  32. package/src/tests/props.test.ts +0 -581
  33. package/src/tests/reactive-props.test.ts +0 -270
  34. package/src/tests/real-world-integration.test.tsx +0 -714
  35. package/src/tests/rs-collapse-dyn-h.browser.test.ts +0 -303
  36. package/src/tests/rs-collapse-dyn.browser.test.ts +0 -316
  37. package/src/tests/rs-collapse-h.browser.test.ts +0 -152
  38. package/src/tests/rs-collapse-h.test.ts +0 -237
  39. package/src/tests/rs-collapse.browser.test.ts +0 -128
  40. package/src/tests/runtime-dom.browser.test.ts +0 -409
  41. package/src/tests/setup.ts +0 -3
  42. package/src/tests/show-context.test.ts +0 -270
  43. package/src/tests/show-of-for-batched-toggle.browser.test.ts +0 -122
  44. package/src/tests/ssr-xss-round-trip.browser.test.ts +0 -93
  45. package/src/tests/style-key-removal.browser.test.ts +0 -54
  46. package/src/tests/style-key-removal.test.ts +0 -88
  47. package/src/tests/template.test.ts +0 -383
  48. package/src/tests/transition-timeout-leak.test.ts +0 -126
  49. package/src/tests/transition.test.ts +0 -568
  50. package/src/tests/verified-correct-probes.test.ts +0 -56
  51. package/src/transition-entry.ts +0 -7
  52. package/src/transition-group.ts +0 -350
  53. package/src/transition.ts +0 -245
@@ -1,158 +0,0 @@
1
- /**
2
- * REGRESSION: context stack does not grow unboundedly under repeated reactive
3
- * remounts.
4
- *
5
- * User-reported symptom (`@pyreon/core@<=0.22.0`):
6
- * 1 GB heap; 33 effect snapshots × ~10,000 frames each; live context stack
7
- * contained 321,024 entries but only 47 distinct provider Map instances.
8
- * The same handful of providers were re-referenced thousands of times each.
9
- *
10
- * Root cause:
11
- * `mountReactive`'s effect re-fire flow runs the previous-mount subtree
12
- * cleanup INSIDE the effect's snapshot-restore window. The descendant's
13
- * `onUnmount` calls `popContext()` (position-based, `stack.pop()`) — but
14
- * the top of the stack at that moment is the snapshot-pushed frame, NOT
15
- * the descendant's own provider frame. `popContext()` pops the snapshot
16
- * frame; the descendant's frame is orphaned on the live stack. Geometric
17
- * amplification across nested reactive boundaries × repeated toggles
18
- * produces the 321k-frame state.
19
- *
20
- * Fix: `provide()` registers `onUnmount(removeContextFrame(frame))` — an
21
- * identity-based splice that finds the specific frame regardless of its
22
- * position on the stack.
23
- */
24
- import { captureContextStack, createContext, h, provide, useContext } from '@pyreon/core'
25
- import { signal } from '@pyreon/reactivity'
26
- import { describe, expect, it } from 'vitest'
27
- import { mount } from '..'
28
-
29
- describe('Context stack — growth under repeated remounts', () => {
30
- it('single reactive boundary cycling a Provider — stack stays bounded', () => {
31
- const Ctx = createContext<string>('root')
32
- const container = document.createElement('div')
33
-
34
- const baseLen = captureContextStack().length
35
- const cond = signal(true)
36
-
37
- function InnerProvider() {
38
- provide(Ctx, 'inner')
39
- return h('span', null, useContext(Ctx))
40
- }
41
-
42
- const App = () =>
43
- h('div', null, () => (cond() ? h(InnerProvider, null) : null))
44
-
45
- const unmount = mount(h(App, null), container)
46
-
47
- for (let i = 0; i < 1000; i++) {
48
- cond.set(false)
49
- cond.set(true)
50
- }
51
-
52
- const finalLen = captureContextStack().length
53
- expect(finalLen - baseLen).toBeLessThan(10)
54
-
55
- unmount()
56
- })
57
-
58
- it('REGRESSION: nested reactive boundaries with providers — no orphan frames', () => {
59
- // The exact shape that produced the 321k-entry live stack in 0.22.0:
60
- // two NESTED reactive boundaries, each containing a provider. The
61
- // outer's cleanup chain unmounts the inner; the inner's provider's
62
- // onUnmount popContext used to pop the wrong (snapshot) frame, orphaning
63
- // the provider's frame on the live stack.
64
- const A = createContext<string>('A_default')
65
- const B = createContext<string>('B_default')
66
- const container = document.createElement('div')
67
- const baseLen = captureContextStack().length
68
-
69
- const toggleA = signal(true)
70
- const toggleB = signal(true)
71
-
72
- function PA() {
73
- provide(A, 'A_value')
74
- return h('div', null, () => (toggleB() ? h(PB, null) : null))
75
- }
76
- function PB() {
77
- provide(B, 'B_value')
78
- return h('span', null, `${useContext(A)}/${useContext(B)}`)
79
- }
80
-
81
- const App = () =>
82
- h('div', null, () => (toggleA() ? h(PA, null) : null))
83
-
84
- const unmount = mount(h(App, null), container)
85
-
86
- // 500 full cycles. Without the fix, the stack grows ~1 frame per cycle
87
- // (502 after 500 iterations of toggleB/toggleA off/on).
88
- for (let i = 0; i < 500; i++) {
89
- toggleB.set(false)
90
- toggleB.set(true)
91
- toggleA.set(false)
92
- toggleA.set(true)
93
- }
94
-
95
- const finalLen = captureContextStack().length
96
- expect(finalLen - baseLen).toBeLessThan(10)
97
-
98
- unmount()
99
- })
100
-
101
- it('signal-driven re-mount of a provider — stack stays bounded across many updates', () => {
102
- const Ctx = createContext<string>('root')
103
- const container = document.createElement('div')
104
- const baseLen = captureContextStack().length
105
- const inner = signal('a')
106
-
107
- function InnerProvider() {
108
- provide(Ctx, inner())
109
- return h('span', null, useContext(Ctx))
110
- }
111
-
112
- const App = () => h('div', null, () => h(InnerProvider, null))
113
- const unmount = mount(h(App, null), container)
114
-
115
- for (let i = 0; i < 2000; i++) inner.set(`v${i}`)
116
-
117
- const finalLen = captureContextStack().length
118
- expect(finalLen - baseLen).toBeLessThan(10)
119
-
120
- unmount()
121
- })
122
-
123
- it('contextSnapshot used in restoreContextStack still finds inherited providers post-remount', () => {
124
- // Read-side correctness: the snapshot mechanism's whole point is that
125
- // useContext from a descendant inside a reactive boundary still finds
126
- // the ancestor provider. The fix must not break this.
127
- const Ctx = createContext<string>('root')
128
- const container = document.createElement('div')
129
- const cond = signal(true)
130
- const seen: string[] = []
131
-
132
- function Reader() {
133
- seen.push(useContext(Ctx))
134
- return h('span', null, useContext(Ctx))
135
- }
136
-
137
- function Provider() {
138
- provide(Ctx, 'inherited')
139
- return h('div', null, () => (cond() ? h(Reader, null) : null))
140
- }
141
-
142
- const unmount = mount(h(Provider, null), container)
143
-
144
- // Initial render must see 'inherited'
145
- expect(seen[seen.length - 1]).toBe('inherited')
146
-
147
- // Toggle a few times — every re-mount of Reader must see the inherited
148
- // value, NOT the default 'root'.
149
- for (let i = 0; i < 10; i++) {
150
- cond.set(false)
151
- cond.set(true)
152
- }
153
- // The most recent mount also saw inherited
154
- expect(seen[seen.length - 1]).toBe('inherited')
155
-
156
- unmount()
157
- })
158
- })
@@ -1,46 +0,0 @@
1
- import { readFile } from 'node:fs/promises'
2
- import path from 'node:path'
3
- import { fileURLToPath } from 'node:url'
4
- import { describe, expect, it } from 'vitest'
5
-
6
- const here = path.dirname(fileURLToPath(import.meta.url))
7
- const SRC = path.resolve(here, '..')
8
-
9
- // Source-pattern regression test for the dev-mode warning gate. Pairs with
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 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.
20
- //
21
- // Same shape as `flow/src/tests/integration.test.ts:warnIgnoredOptions`.
22
- //
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.
26
-
27
- const FILES_WITH_DEV_GATE = ['nodes.ts', 'hydration-debug.ts']
28
-
29
- describe('runtime-dom dev-warning gate (source pattern)', () => {
30
- for (const file of FILES_WITH_DEV_GATE) {
31
- it(`${file} uses bundler-agnostic process.env.NODE_ENV`, async () => {
32
- const source = await readFile(path.join(SRC, file), 'utf8')
33
- // Strip line + block comments so referencing the broken pattern in
34
- // documentation doesn't false-positive.
35
- const code = source
36
- .replace(/\/\*[\s\S]*?\*\//g, '')
37
- .replace(/(^|[^:])\/\/.*$/gm, '$1')
38
-
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.
42
- expect(code).not.toMatch(/typeof\s+process\s*!==?\s*['"]undefined['"]/)
43
- expect(code).not.toMatch(/import\.meta\.env\??\.DEV/)
44
- })
45
- }
46
- })
@@ -1,256 +0,0 @@
1
- import { mkdtempSync, rmSync } from 'node:fs'
2
- import path from 'node:path'
3
- import { tmpdir } from 'node:os'
4
- import { fileURLToPath } from 'node:url'
5
- import { describe, expect, it } from 'vitest'
6
- import { build } from 'vite'
7
-
8
- const here = path.dirname(fileURLToPath(import.meta.url))
9
- const SRC = path.resolve(here, '..')
10
-
11
- // Bundle-level regression test for the dev-warning gate.
12
- //
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.
21
- //
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
- //
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`).
29
- // This test is the expensive end-to-end guard for the bundle path.
30
-
31
- interface FileContract {
32
- file: string
33
- /** Dev-warning strings that MUST be eliminated from the prod bundle. */
34
- devWarningStrings: string[]
35
- }
36
-
37
- // Coverage strategy: pick representative files across the runtime-dom
38
- // dev-gate landscape so a regression in any of the typical patterns is
39
- // caught. `nodes.ts` covers the chained `&&` form (the original
40
- // problem). `mount.ts` covers the simple `if (__DEV__)` form across
41
- // multiple Portal/VNode call sites. `props.ts` covers attribute-validation
42
- // warnings inside small inline `if (__DEV__) { ... }` blocks.
43
- // `transition.ts` covers a single `if (__DEV__) { console.warn() }`.
44
- //
45
- // These four files exercise every shape of dev gate currently used in
46
- // runtime-dom; if the contract holds for all of them, it holds for the
47
- // rest of the file set.
48
- const FILES_UNDER_TEST: FileContract[] = [
49
- {
50
- file: 'nodes.ts',
51
- devWarningStrings: [
52
- '[Pyreon] <For> `by` function returned null/undefined',
53
- '[Pyreon] Duplicate key',
54
- ],
55
- },
56
- {
57
- file: 'mount.ts',
58
- devWarningStrings: [
59
- '[Pyreon] <Portal> received a falsy `target`',
60
- '[Pyreon] <Portal> target must be a DOM node',
61
- '[Pyreon] Invalid VNode type',
62
- 'is a void element and cannot have children',
63
- ],
64
- },
65
- {
66
- file: 'props.ts',
67
- devWarningStrings: [
68
- '[Pyreon] Event handler',
69
- '[Pyreon] Blocked unsafe URL',
70
- ],
71
- },
72
- {
73
- file: 'transition.ts',
74
- devWarningStrings: [
75
- '[Pyreon] Transition child is a component',
76
- ],
77
- },
78
- ]
79
-
80
- async function bundleWithVite(entry: string, dev: boolean): Promise<string> {
81
- const outDir = mkdtempSync(path.join(tmpdir(), 'pyreon-vite-treeshake-'))
82
- try {
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.
87
- await build({
88
- mode: dev ? 'development' : 'production',
89
- logLevel: 'error',
90
- configFile: false,
91
- resolve: { conditions: ['bun'] },
92
- define: {
93
- 'process.env.NODE_ENV': JSON.stringify(dev ? 'development' : 'production'),
94
- },
95
- build: {
96
- // PINNED minifier: 'esbuild' is what Pyreon's reference consumers
97
- // (Zero, the example apps) effectively use. If a future Vite
98
- // version flips the default to oxc-minify or terser, behavior
99
- // could differ silently — pinning keeps this test honest.
100
- minify: dev ? false : 'esbuild',
101
- target: 'esnext',
102
- write: true,
103
- outDir,
104
- emptyOutDir: true,
105
- lib: {
106
- entry,
107
- formats: ['es'],
108
- fileName: 'out',
109
- },
110
- // Bundle everything — we want the tested file's strings visible
111
- // in the output, not aliased to an external import.
112
- rollupOptions: {
113
- external: ['@pyreon/core', '@pyreon/reactivity', '@pyreon/runtime-server'],
114
- },
115
- },
116
- })
117
- const outPath = path.join(outDir, 'out.js')
118
- const fs = await import('node:fs')
119
- return fs.readFileSync(outPath, 'utf8')
120
- } finally {
121
- rmSync(outDir, { recursive: true, force: true })
122
- }
123
- }
124
-
125
- describe('runtime-dom dev-warning gate (Vite production bundle)', () => {
126
- for (const { file, devWarningStrings } of FILES_UNDER_TEST) {
127
- it(`${file} → dev warnings eliminated in Vite production bundle`, async () => {
128
- const code = await bundleWithVite(path.join(SRC, file), false)
129
-
130
- for (const warn of devWarningStrings) {
131
- expect(code, `"${warn}" survived prod tree-shake`).not.toContain(warn)
132
- }
133
- expect(code.length).toBeGreaterThan(0)
134
- }, 5000)
135
-
136
- it(`${file} → dev warnings PRESERVED in Vite dev bundle (sanity)`, async () => {
137
- // Gate for the eliminated-when-prod test: if the strings were
138
- // deleted from source entirely, the previous test would pass
139
- // trivially. Bundling in dev mode should keep them.
140
- if (devWarningStrings.length === 0) return
141
-
142
- const code = await bundleWithVite(path.join(SRC, file), true)
143
-
144
- for (const warn of devWarningStrings) {
145
- expect(code, `"${warn}" missing from dev bundle (did source change?)`).toContain(warn)
146
- }
147
- }, 5000)
148
- }
149
- })
150
-
151
- // ─── Non-Vite consumer runtime correctness ─────────────────────────────────
152
- //
153
- // What the CLAUDE.md doc claims for non-Vite consumers (webpack,
154
- // bunchee, raw esbuild bundles): the dev-warning STRINGS may stay in
155
- // the bundle as data, but the warnings themselves don't fire because
156
- // the `import.meta.env?.DEV === true` gate evaluates to `false` when
157
- // `import.meta.env.DEV` is undefined at runtime.
158
- //
159
- // This block bundles `nodes.ts` with raw esbuild (no `define` for
160
- // import.meta.env, simulating a less-aware bundler), then asserts:
161
- //
162
- // 1. The dev-warning strings DO survive (proving we picked a real
163
- // bundle to test, not Vite-equivalent behavior).
164
- // 2. The strings are still gated — they appear next to a check
165
- // involving `import.meta.env` rather than being unconditional.
166
- //
167
- // (2) is what makes the runtime claim true: at runtime `import.meta.env`
168
- // is `undefined` in non-Vite-aware environments, so `?.DEV` returns
169
- // `undefined`, `=== true` returns `false`, and the warn never fires.
170
- // If a future refactor unconditionally calls console.warn (no gate),
171
- // this assertion catches that the runtime contract regressed.
172
-
173
- describe('non-Vite consumer runtime correctness', () => {
174
- it('raw esbuild bundle: warning strings remain in bundle (proves we test the non-Vite path)', async () => {
175
- const { build } = await import('esbuild')
176
- const result = await build({
177
- entryPoints: [path.join(SRC, 'nodes.ts')],
178
- bundle: true,
179
- write: false,
180
- minify: true,
181
- format: 'esm',
182
- platform: 'browser',
183
- external: ['@pyreon/core', '@pyreon/reactivity', '@pyreon/runtime-server'],
184
- // Intentionally no `define` — simulates a non-Vite-aware bundler.
185
- })
186
- const code = result.outputFiles[0]?.text ?? ''
187
- expect(code).toContain('Duplicate key')
188
- }, 5000)
189
-
190
- it('raw esbuild bundle: dev gate evaluates to false at runtime when import.meta.env is undefined', async () => {
191
- // The real claim is RUNTIME — even when warning strings are in the
192
- // bundle, the gate stops `console.warn` from firing. This test
193
- // EXECUTES the bundled module with `import.meta.env` undefined
194
- // (the non-Vite case) and verifies `console.warn` is never called.
195
- //
196
- // Bundle a synthetic harness that exposes the gated callsite as a
197
- // standalone exported function, replacing the cross-package
198
- // imports so we don't need a full Pyreon runtime to execute. The
199
- // harness mirrors the EXACT gate pattern used in nodes.ts.
200
- const { build } = await import('esbuild')
201
- const harness = `
202
- // Same module-scope const pattern used in real Pyreon source.
203
- // @ts-ignore — \`import.meta.env\` is provided by Vite at build time
204
- const __DEV__ = import.meta.env?.DEV === true
205
- export function maybeWarn(seen: Set<string>, key: string): void {
206
- // Mirrors nodes.ts: a chained \`__DEV__ && cond && warn\` form
207
- // (Pattern B from the C-2 probe).
208
- if (seen.has(key)) {
209
- if (__DEV__) {
210
- console.warn(\`[Pyreon] Duplicate key "\${String(key)}" in <For> list.\`)
211
- }
212
- }
213
- seen.add(key)
214
- }
215
- `
216
- const result = await build({
217
- stdin: { contents: harness, loader: 'ts', resolveDir: SRC },
218
- bundle: true,
219
- write: false,
220
- minify: true,
221
- format: 'esm',
222
- platform: 'browser',
223
- // No `define` — same as a non-Vite consumer.
224
- })
225
- const code = result.outputFiles[0]?.text ?? ''
226
-
227
- // The string MUST be in the bundle (proves this is the non-Vite path).
228
- expect(code).toContain('Duplicate key')
229
-
230
- // Now actually execute the bundled module with `import.meta.env`
231
- // resembling the non-Vite environment (undefined). Use a data:
232
- // import to load the bundled ESM module. Bun supports this.
233
- const dataUrl = `data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
234
- const mod = (await import(/* @vite-ignore */ dataUrl)) as {
235
- maybeWarn: (s: Set<string>, k: string) => void
236
- }
237
-
238
- // Spy on console.warn — the real runtime check.
239
- const calls: unknown[][] = []
240
- const original = console.warn
241
- console.warn = (...args: unknown[]) => {
242
- calls.push(args)
243
- }
244
- try {
245
- const seen = new Set<string>()
246
- mod.maybeWarn(seen, 'foo')
247
- mod.maybeWarn(seen, 'foo') // second call → seen.has('foo') is true → would warn if gate broken
248
- } finally {
249
- console.warn = original
250
- }
251
-
252
- // The runtime contract: warning string is in the bundle (data),
253
- // but the gate stops it from firing.
254
- expect(calls).toEqual([])
255
- }, 5000)
256
- })
@@ -1,133 +0,0 @@
1
- /**
2
- * REPRODUCTION + REGRESSION: the `_errorBoundaryStack.pop()` cleanup is
3
- * position-based — same bug class as the `popContext()` bug fixed in #725.
4
- *
5
- * Scenario: two or more sibling `<ErrorBoundary>` boundaries. When a NON-LAST
6
- * boundary unmounts (keyed `<For>` removing the first item, `<Show>` flipping
7
- * the first of several siblings, route nav unmounting the outer of nested
8
- * routes, etc.), its `onUnmount` calls `popErrorBoundary()` → `stack.pop()`
9
- * → pops the LAST (innermost) boundary's handler — the wrong one.
10
- *
11
- * Outcome:
12
- * - Subsequent errors in the SURVIVING boundary's children route to whatever
13
- * handler is now at `stack[length-1]`, which is the stale handler of an
14
- * ALREADY-UNMOUNTED boundary. Calling `error.set(err)` on that handler's
15
- * captured signal is a no-op → the error is silently swallowed AND the
16
- * surviving boundary's fallback never renders.
17
- *
18
- * Fix (#725-class): `popErrorBoundary(handler)` uses `lastIndexOf + splice`
19
- * to remove by IDENTITY. Each ErrorBoundary's `onUnmount` passes its own
20
- * handler reference, so unmount in any order correctly removes the right
21
- * handler.
22
- */
23
- import type { VNodeChild } from '@pyreon/core'
24
- import { ErrorBoundary, h, Show } from '@pyreon/core'
25
- import { signal } from '@pyreon/reactivity'
26
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
27
- import { mount } from '..'
28
-
29
- describe('ErrorBoundary — module-level stack cleanup is identity-safe (#725 class)', () => {
30
- let container: HTMLElement
31
- let errorSpy: ReturnType<typeof vi.spyOn>
32
-
33
- beforeEach(() => {
34
- container = document.createElement('div')
35
- document.body.appendChild(container)
36
- errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
37
- })
38
- afterEach(() => {
39
- container.remove()
40
- errorSpy.mockRestore()
41
- })
42
-
43
- // Tiny `h`-builder helpers so the tests stay readable.
44
- const eb = (testId: string, ...children: VNodeChild[]) =>
45
- h(
46
- ErrorBoundary,
47
- {
48
- fallback: (err: unknown) =>
49
- h('div', { 'data-testid': `fb-${testId}` }, `caught(${testId}): ${String(err)}`),
50
- children: children.length === 1 ? children[0] : children,
51
- },
52
- )
53
-
54
- const showWhen = (when: () => boolean, child: () => VNodeChild) =>
55
- h(Show, { when, children: child })
56
-
57
- it('REGRESSION: surviving sibling boundary still catches errors after a sibling unmounts (FIRST unmounted)', () => {
58
- const showA = signal(false)
59
- const showB = signal(false)
60
- const aliveA = signal(true)
61
-
62
- function Bomb({ name }: { name: string }): never {
63
- throw new Error(`boom-${name}`)
64
- }
65
-
66
- const App = () =>
67
- h(
68
- 'div',
69
- null,
70
- // Boundary A — wrapped in Show so we can UNMOUNT it without
71
- // touching boundary B.
72
- showWhen(
73
- () => aliveA(),
74
- () => eb('A', showWhen(() => showA(), () => h(Bomb, { name: 'A' }))),
75
- ),
76
- // Boundary B — always mounted.
77
- eb('B', showWhen(() => showB(), () => h(Bomb, { name: 'B' }))),
78
- )
79
-
80
- const unmount = mount(h(App, null), container)
81
-
82
- expect(container.querySelector('[data-testid="fb-A"]')).toBeNull()
83
- expect(container.querySelector('[data-testid="fb-B"]')).toBeNull()
84
-
85
- // UNMOUNT boundary A (FIRST sibling). Pre-fix: popErrorBoundary() pops
86
- // the LAST frame — B's handler — instead of A's.
87
- aliveA.set(false)
88
-
89
- // Now trigger a throw inside B's children. With B's handler correctly
90
- // still on the stack (post-fix), B's fallback should render.
91
- showB.set(true)
92
-
93
- const fbB = container.querySelector('[data-testid="fb-B"]')
94
- expect(fbB).toBeTruthy()
95
- expect(fbB?.textContent).toContain('caught(B): Error: boom-B')
96
-
97
- // And the throw must NOT have been routed to A's (stale) fallback.
98
- expect(container.querySelector('[data-testid="fb-A"]')).toBeNull()
99
-
100
- unmount()
101
- })
102
-
103
- it('LIFO case: surviving FIRST boundary catches errors after a LATER sibling unmounts', () => {
104
- // Pre-fix this case worked (LIFO held for last-unmount). Included
105
- // as a guard against the fix regressing the LIFO case.
106
- const showA = signal(false)
107
- const aliveB = signal(true)
108
-
109
- function Bomb({ name }: { name: string }): never {
110
- throw new Error(`boom-${name}`)
111
- }
112
-
113
- const App = () =>
114
- h(
115
- 'div',
116
- null,
117
- eb('A', showWhen(() => showA(), () => h(Bomb, { name: 'A' }))),
118
- showWhen(() => aliveB(), () => eb('B', null)),
119
- )
120
-
121
- const unmount = mount(h(App, null), container)
122
-
123
- // Unmount B — LIFO case (last sibling). Both pre- and post-fix correct.
124
- aliveB.set(false)
125
-
126
- showA.set(true)
127
- const fbA = container.querySelector('[data-testid="fb-A"]')
128
- expect(fbA).toBeTruthy()
129
- expect(fbA?.textContent).toContain('caught(A): Error: boom-A')
130
-
131
- unmount()
132
- })
133
- })