@pyreon/runtime-dom 0.21.0 → 0.23.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 +115 -91
- package/lib/_chunks/keep-alive-BM7bn3W9.js +1657 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +8 -1821
- package/lib/keep-alive-entry.js +2 -1380
- package/lib/transition-entry.js +1 -1
- package/package.json +6 -6
- package/src/tests/ctx-stack-growth-repro.test.tsx +158 -0
- package/src/tests/error-boundary-stack-leak-repro.test.tsx +133 -0
- package/lib/analysis/keep-alive-entry.js.html +0 -5406
- package/lib/analysis/transition-entry.js.html +0 -5406
package/lib/transition-entry.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Fragment, createRef, h, nativeCompat, onUnmount } from "@pyreon/core";
|
|
2
1
|
import { effect, runUntracked, signal } from "@pyreon/reactivity";
|
|
2
|
+
import { Fragment, createRef, h, nativeCompat, onUnmount } from "@pyreon/core";
|
|
3
3
|
|
|
4
4
|
//#region src/transition.ts
|
|
5
5
|
const __DEV__ = process.env.NODE_ENV !== "production";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/runtime-dom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "DOM renderer for Pyreon",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/runtime-dom#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -54,15 +54,15 @@
|
|
|
54
54
|
"prepublishOnly": "bun run build"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@pyreon/core": "^0.
|
|
58
|
-
"@pyreon/reactivity": "^0.
|
|
57
|
+
"@pyreon/core": "^0.23.0",
|
|
58
|
+
"@pyreon/reactivity": "^0.23.0"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@happy-dom/global-registrator": "^20.8.9",
|
|
62
|
-
"@pyreon/compiler": "^0.
|
|
62
|
+
"@pyreon/compiler": "^0.23.0",
|
|
63
63
|
"@pyreon/manifest": "0.13.1",
|
|
64
|
-
"@pyreon/runtime-server": "^0.
|
|
65
|
-
"@pyreon/test-utils": "^0.13.
|
|
64
|
+
"@pyreon/runtime-server": "^0.23.0",
|
|
65
|
+
"@pyreon/test-utils": "^0.13.10",
|
|
66
66
|
"@vitest/browser-playwright": "^4.1.4",
|
|
67
67
|
"esbuild": "^0.28.0",
|
|
68
68
|
"happy-dom": "^20.8.3",
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
})
|