@pyreon/runtime-dom 0.24.5 → 0.24.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -9
- package/src/delegate.ts +0 -98
- package/src/devtools.ts +0 -339
- package/src/env.d.ts +0 -6
- package/src/hydrate.ts +0 -450
- package/src/hydration-debug.ts +0 -129
- package/src/index.ts +0 -83
- package/src/keep-alive-entry.ts +0 -3
- package/src/keep-alive.ts +0 -83
- package/src/manifest.ts +0 -236
- package/src/mount.ts +0 -597
- package/src/nodes.ts +0 -896
- package/src/props.ts +0 -474
- package/src/template.ts +0 -523
- package/src/tests/callback-ref-unmount.browser.test.ts +0 -62
- package/src/tests/callback-ref-unmount.test.ts +0 -52
- package/src/tests/compiler-integration.test.tsx +0 -508
- package/src/tests/coverage-gaps.test.ts +0 -3183
- package/src/tests/coverage.test.ts +0 -1140
- package/src/tests/ctx-stack-growth-repro.test.tsx +0 -158
- package/src/tests/dev-gate-pattern.test.ts +0 -46
- package/src/tests/dev-gate-treeshake.test.ts +0 -256
- package/src/tests/error-boundary-stack-leak-repro.test.tsx +0 -133
- package/src/tests/fanout-repro.test.tsx +0 -219
- package/src/tests/hydration-integration.test.tsx +0 -540
- package/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts +0 -140
- package/src/tests/lifecycle-integration.test.tsx +0 -342
- package/src/tests/lis-prepend.browser.test.ts +0 -99
- package/src/tests/manifest-snapshot.test.ts +0 -85
- package/src/tests/mount.test.ts +0 -3529
- package/src/tests/native-markers.test.ts +0 -19
- package/src/tests/props.test.ts +0 -581
- package/src/tests/reactive-props.test.ts +0 -270
- package/src/tests/real-world-integration.test.tsx +0 -714
- package/src/tests/rs-collapse-dyn-h.browser.test.ts +0 -303
- package/src/tests/rs-collapse-dyn.browser.test.ts +0 -316
- package/src/tests/rs-collapse-h.browser.test.ts +0 -152
- package/src/tests/rs-collapse-h.test.ts +0 -237
- package/src/tests/rs-collapse.browser.test.ts +0 -128
- package/src/tests/runtime-dom.browser.test.ts +0 -409
- package/src/tests/setup.ts +0 -3
- package/src/tests/show-context.test.ts +0 -270
- package/src/tests/show-of-for-batched-toggle.browser.test.ts +0 -122
- package/src/tests/ssr-xss-round-trip.browser.test.ts +0 -93
- package/src/tests/style-key-removal.browser.test.ts +0 -54
- package/src/tests/style-key-removal.test.ts +0 -88
- package/src/tests/template.test.ts +0 -383
- package/src/tests/transition-timeout-leak.test.ts +0 -126
- package/src/tests/transition.test.ts +0 -568
- package/src/tests/verified-correct-probes.test.ts +0 -56
- package/src/transition-entry.ts +0 -7
- package/src/transition-group.ts +0 -350
- package/src/transition.ts +0 -245
|
@@ -1,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
|
-
})
|