@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.
@@ -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.21.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.21.0",
58
- "@pyreon/reactivity": "^0.21.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.21.0",
62
+ "@pyreon/compiler": "^0.23.0",
63
63
  "@pyreon/manifest": "0.13.1",
64
- "@pyreon/runtime-server": "^0.21.0",
65
- "@pyreon/test-utils": "^0.13.8",
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
+ })