@pyreon/core 0.22.0 → 0.24.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 +172 -54
- package/lib/_chunks/h-CYSD6aBx.js +48 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +140 -58
- package/lib/jsx-dev-runtime.js +3 -97
- package/lib/jsx-runtime.js +1 -45
- package/lib/types/index.d.ts +91 -2
- package/package.json +2 -2
- package/src/component.ts +33 -2
- package/src/context.ts +166 -17
- package/src/error-boundary.ts +7 -1
- package/src/index.ts +2 -0
- package/src/tests/context.test.ts +368 -0
- package/lib/analysis/jsx-dev-runtime.js.html +0 -5406
- package/lib/analysis/jsx-runtime.js.html +0 -5406
package/src/context.ts
CHANGED
|
@@ -73,12 +73,62 @@ export function pushContext(values: Map<symbol, unknown>) {
|
|
|
73
73
|
getStack().push(values)
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Pop the LAST frame from the context stack.
|
|
78
|
+
*
|
|
79
|
+
* NOTE: position-based pop. Safe ONLY when the caller can guarantee that the
|
|
80
|
+
* top of the stack is the frame they want to remove (the strict LIFO contract).
|
|
81
|
+
* The `provide()` helper does NOT use this — it uses identity-based removal
|
|
82
|
+
* via `removeContextFrame` because reactive boundaries can push snapshot
|
|
83
|
+
* frames between a component's `provide(ctx, value)` and its eventual
|
|
84
|
+
* unmount, making the top-of-stack unsafe to assume.
|
|
85
|
+
*/
|
|
76
86
|
export function popContext() {
|
|
77
87
|
const stack = getStack()
|
|
78
88
|
if (stack.length === 0) return
|
|
79
89
|
stack.pop()
|
|
80
90
|
}
|
|
81
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Read the current live stack length WITHOUT allocating a snapshot.
|
|
94
|
+
*
|
|
95
|
+
* SSR cleanup uses this as a position marker: capture the live length
|
|
96
|
+
* before a component renders, pop the live stack back to that length
|
|
97
|
+
* after. Previously these sites called `captureContextStack().length`,
|
|
98
|
+
* which allocated a full snapshot array (potentially 40k+ entries
|
|
99
|
+
* under deeply-nested reactive boundaries — the same allocation the
|
|
100
|
+
* `captureContextStack` dedup work is designed to shrink) just to
|
|
101
|
+
* read its length. This helper avoids the allocation entirely AND
|
|
102
|
+
* decouples SSR cleanup from `captureContextStack`'s snapshot shape,
|
|
103
|
+
* so dedup at capture time can never silently break SSR length
|
|
104
|
+
* bookkeeping.
|
|
105
|
+
*/
|
|
106
|
+
export function getContextStackLength(): number {
|
|
107
|
+
return getStack().length
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Remove a SPECIFIC frame from the context stack by reference identity.
|
|
112
|
+
*
|
|
113
|
+
* Internal — used by `provide()` and `withContext()` to safely clean up
|
|
114
|
+
* their pushed frame on unmount even when other frames have been pushed
|
|
115
|
+
* between push and pop (e.g. a reactive boundary's `restoreContextStack`
|
|
116
|
+
* pushing snapshot frames during the descendant's lifecycle). The
|
|
117
|
+
* symmetric position-based `popContext()` would pop the wrong frame in
|
|
118
|
+
* that case and orphan the descendant's provider frame on the live stack
|
|
119
|
+
* — the root cause of the 321k-entry context-stack leak under repeated
|
|
120
|
+
* reactive remounts.
|
|
121
|
+
*
|
|
122
|
+
* Uses `lastIndexOf` (LIFO match) — picks the most-recently-pushed frame
|
|
123
|
+
* with that exact reference, so `provide(ctx, a); provide(ctx, b)` followed
|
|
124
|
+
* by two unmounts removes them in reverse order.
|
|
125
|
+
*/
|
|
126
|
+
export function removeContextFrame(frame: Map<symbol, unknown>): void {
|
|
127
|
+
const stack = getStack()
|
|
128
|
+
const idx = stack.lastIndexOf(frame)
|
|
129
|
+
if (idx !== -1) stack.splice(idx, 1)
|
|
130
|
+
}
|
|
131
|
+
|
|
82
132
|
/**
|
|
83
133
|
* Read the nearest provided value for a context.
|
|
84
134
|
* Falls back to `context.defaultValue` if none found.
|
|
@@ -111,8 +161,17 @@ export function useContext<T>(context: Context<T>): T {
|
|
|
111
161
|
* }
|
|
112
162
|
*/
|
|
113
163
|
export function provide<T>(context: Context<T>, value: T): void {
|
|
114
|
-
|
|
115
|
-
|
|
164
|
+
const frame = new Map<symbol, unknown>([[context.id, value]])
|
|
165
|
+
pushContext(frame)
|
|
166
|
+
// Identity-based removal — the top of the stack is NOT guaranteed to be
|
|
167
|
+
// this frame at unmount time. Reactive boundaries (`mountReactive`'s
|
|
168
|
+
// effect snapshot-restore + the inner `restoreContextStack` call) push
|
|
169
|
+
// additional snapshot frames during a descendant's lifecycle. A
|
|
170
|
+
// position-based `popContext()` would pop the snapshot frame instead
|
|
171
|
+
// of this provider's frame and orphan the provider on the live stack.
|
|
172
|
+
// See `.claude/rules/anti-patterns.md` "Context-stack frame identity"
|
|
173
|
+
// for the full bug class.
|
|
174
|
+
onUnmount(() => removeContextFrame(frame))
|
|
116
175
|
}
|
|
117
176
|
|
|
118
177
|
/**
|
|
@@ -125,7 +184,11 @@ export function withContext<T>(context: Context<T>, value: T, fn: () => void) {
|
|
|
125
184
|
try {
|
|
126
185
|
fn()
|
|
127
186
|
} finally {
|
|
128
|
-
|
|
187
|
+
// Same identity-based-removal rationale as `provide()` — `fn()` may
|
|
188
|
+
// synchronously trigger a `mountReactive` re-run whose snapshot-restore
|
|
189
|
+
// window leaves the top-of-stack pointing at a snapshot push, not our
|
|
190
|
+
// frame.
|
|
191
|
+
removeContextFrame(frame)
|
|
129
192
|
}
|
|
130
193
|
}
|
|
131
194
|
|
|
@@ -134,17 +197,92 @@ export function withContext<T>(context: Context<T>, value: T, fn: () => void) {
|
|
|
134
197
|
export type ContextSnapshot = Map<symbol, unknown>[]
|
|
135
198
|
|
|
136
199
|
/**
|
|
137
|
-
* Capture a snapshot of the current context stack
|
|
200
|
+
* Capture a snapshot of the current context stack, **deduplicated** so
|
|
201
|
+
* only the topmost frame for each context-id is retained.
|
|
138
202
|
*
|
|
139
203
|
* Used by `mountReactive` to preserve the context that was active when a
|
|
140
204
|
* reactive boundary (e.g. `<Show>`, `<For>`) was set up. When the boundary
|
|
141
205
|
* later mounts new children inside an effect, the snapshot is restored so
|
|
142
206
|
* those children can see ancestor providers via `useContext()`.
|
|
207
|
+
*
|
|
208
|
+
* **Why dedup is semantically equivalent to a full snapshot:**
|
|
209
|
+
* `useContext()` walks the stack in reverse and returns the first frame
|
|
210
|
+
* matching the requested context-id (`for (let i = stack.length - 1; i >= 0; i--)`
|
|
211
|
+
* — see implementation below in this file). Any frame deeper in the
|
|
212
|
+
* stack that ALSO provides the same id is unreachable by definition —
|
|
213
|
+
* the reverse walk stops at the first match. Those shadowed frames are
|
|
214
|
+
* dead weight in the snapshot: they carry no observable value, they
|
|
215
|
+
* cost memory, and they can NEVER affect program behavior.
|
|
216
|
+
*
|
|
217
|
+
* The dedup walks frames from top to bottom keeping a `seen` set of
|
|
218
|
+
* already-resolved context ids. A frame is kept iff at least one of
|
|
219
|
+
* its keys is NOT in `seen` (i.e. it's the topmost provider for at
|
|
220
|
+
* least one id). All of a frame's keys are added to `seen` regardless
|
|
221
|
+
* of whether the frame is kept — `seen` represents "ids that are
|
|
222
|
+
* already provided by a more-recent frame".
|
|
223
|
+
*
|
|
224
|
+
* **Why this is safe for `restoreContextStack`:**
|
|
225
|
+
* `restoreContextStack` pushes the snapshot's frames onto the live
|
|
226
|
+
* stack, runs `fn()`, then removes those frames by **reference
|
|
227
|
+
* identity** (`stack.lastIndexOf(frame)`) — NOT by position or count
|
|
228
|
+
* of the snapshot. A deduped snapshot pushes fewer frames; the same
|
|
229
|
+
* reference-identity cleanup removes exactly those frames. No
|
|
230
|
+
* bookkeeping invariant breaks.
|
|
231
|
+
*
|
|
232
|
+
* **Why this is safe for the live stack length invariant:**
|
|
233
|
+
* SSR cleanup uses `getContextStackLength()` (a sibling helper) for
|
|
234
|
+
* position-marker bookkeeping. That helper reads the LIVE stack
|
|
235
|
+
* length, NOT the snapshot length, so dedup at capture time has zero
|
|
236
|
+
* effect on SSR cleanup behavior.
|
|
237
|
+
*
|
|
238
|
+
* **Why this is needed:**
|
|
239
|
+
* Under deeply-nested reactive boundaries (a `<Show>` inside a `<For>`
|
|
240
|
+
* inside a `<Suspense>`, each effect capturing its own snapshot at
|
|
241
|
+
* setup time), the live stack temporarily holds the same context-id
|
|
242
|
+
* pushed multiple times during nested `restoreContextStack` windows.
|
|
243
|
+
* The pre-dedup `[...getStack()]` snapshot baked those duplicates in
|
|
244
|
+
* permanently — each effect's closure retained an O(stack-depth)
|
|
245
|
+
* array for its lifetime. Reported heap snapshots from 0.21.x showed
|
|
246
|
+
* 1.22 MB / 321k-entry arrays from this pattern. The 0.23.0
|
|
247
|
+
* restoreContextStack reference-identity fix cleaned the LIVE stack
|
|
248
|
+
* but left the residual snapshot-amplification — observable as 20
|
|
249
|
+
* arrays at 157 KB each (40k entries) retained by effect closures.
|
|
250
|
+
* This dedup collapses each captured snapshot to ~N entries, where
|
|
251
|
+
* N is the number of DISTINCT context ids in scope (typically 2-10
|
|
252
|
+
* in real apps).
|
|
143
253
|
*/
|
|
144
254
|
export function captureContextStack(): ContextSnapshot {
|
|
145
|
-
|
|
146
|
-
//
|
|
147
|
-
|
|
255
|
+
const stack = getStack()
|
|
256
|
+
// Fast path: empty stack or single frame is the common case for
|
|
257
|
+
// top-level mounts and zero-context apps. Skip the dedup machinery.
|
|
258
|
+
if (stack.length <= 1) return stack.slice()
|
|
259
|
+
|
|
260
|
+
// Walk top-to-bottom, keeping the topmost frame for each context-id.
|
|
261
|
+
// Each frame is a Map<symbol, unknown>; `seen` tracks ids already
|
|
262
|
+
// provided by a more-recent frame.
|
|
263
|
+
const seen = new Set<symbol>()
|
|
264
|
+
const reversed: Map<symbol, unknown>[] = []
|
|
265
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
266
|
+
const frame = stack[i]
|
|
267
|
+
if (!frame) continue
|
|
268
|
+
// A frame is unique if it provides at least one not-yet-seen id.
|
|
269
|
+
// Iterate ALL keys to accumulate them into `seen` (so deeper
|
|
270
|
+
// frames sharing any one of them are correctly shadowed even if
|
|
271
|
+
// they also have other unique keys).
|
|
272
|
+
let unique = false
|
|
273
|
+
for (const id of frame.keys()) {
|
|
274
|
+
if (!seen.has(id)) {
|
|
275
|
+
seen.add(id)
|
|
276
|
+
unique = true
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (unique) reversed.push(frame)
|
|
280
|
+
}
|
|
281
|
+
// We walked top-to-bottom; the result is in reverse stack order.
|
|
282
|
+
// Reverse back so the snapshot is in bottom-to-top order, matching
|
|
283
|
+
// the order `restoreContextStack` pushes them.
|
|
284
|
+
reversed.reverse()
|
|
285
|
+
return reversed
|
|
148
286
|
}
|
|
149
287
|
|
|
150
288
|
/**
|
|
@@ -162,7 +300,6 @@ export function captureContextStack(): ContextSnapshot {
|
|
|
162
300
|
*/
|
|
163
301
|
export function restoreContextStack<T>(snapshot: ContextSnapshot, fn: () => T): T {
|
|
164
302
|
const stack = getStack()
|
|
165
|
-
const insertIndex = stack.length
|
|
166
303
|
|
|
167
304
|
// Push captured snapshot frames at the END of the current stack.
|
|
168
305
|
for (const frame of snapshot) {
|
|
@@ -172,15 +309,27 @@ export function restoreContextStack<T>(snapshot: ContextSnapshot, fn: () => T):
|
|
|
172
309
|
try {
|
|
173
310
|
return fn()
|
|
174
311
|
} finally {
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
// `
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
// snapshot
|
|
183
|
-
|
|
312
|
+
// Remove our pushed snapshot frames by REFERENCE IDENTITY (not by
|
|
313
|
+
// position). `fn()` may legitimately remove frames at indices BEFORE
|
|
314
|
+
// our push window — most commonly via `provide()` registering
|
|
315
|
+
// `onUnmount(removeContextFrame(frame))` and a descendant unmount
|
|
316
|
+
// firing inside this restore window. A position-based `splice` would
|
|
317
|
+
// either pull the wrong frames or no-op when the live stack has
|
|
318
|
+
// shrunk below the original `insertIndex + snapshot.length` —
|
|
319
|
+
// orphaning the snapshot pushes on the live stack and producing the
|
|
320
|
+
// 321k-frame leak reported under repeated reactive remounts.
|
|
321
|
+
//
|
|
322
|
+
// Iterate in reverse so multi-occurrence frames (the same Map ref
|
|
323
|
+
// pushed by multiple nested restores) are removed in LIFO push order.
|
|
324
|
+
// `lastIndexOf` is O(N); N is small in practice (single-digit nesting),
|
|
325
|
+
// and the alternative `findLastIndex(f => f === frame)` is the same
|
|
326
|
+
// cost.
|
|
327
|
+
for (let i = snapshot.length - 1; i >= 0; i--) {
|
|
328
|
+
const frame = snapshot[i]
|
|
329
|
+
if (!frame) continue
|
|
330
|
+
const idx = stack.lastIndexOf(frame)
|
|
331
|
+
if (idx !== -1) stack.splice(idx, 1)
|
|
332
|
+
}
|
|
184
333
|
}
|
|
185
334
|
}
|
|
186
335
|
|
package/src/error-boundary.ts
CHANGED
|
@@ -68,7 +68,13 @@ export function ErrorBoundary(props: {
|
|
|
68
68
|
|
|
69
69
|
// Push synchronously — before children are mounted — so child errors see this boundary
|
|
70
70
|
pushErrorBoundary(handler)
|
|
71
|
-
|
|
71
|
+
// Identity-based pop: pass our own handler reference. Sibling boundaries
|
|
72
|
+
// can unmount in any order driven by the renderer (keyed `<For>` removal
|
|
73
|
+
// of a non-last item, `<Show>` flipping on the FIRST of N siblings, route
|
|
74
|
+
// nav, etc.) — without passing the handler reference, the position-based
|
|
75
|
+
// `pop()` would remove the WRONG boundary's handler. Same bug class as
|
|
76
|
+
// #725 (`popContext()` orphaning provider frames under reactive remount).
|
|
77
|
+
onUnmount(() => popErrorBoundary(handler))
|
|
72
78
|
|
|
73
79
|
return (): VNodeChildAtom => {
|
|
74
80
|
const err = error()
|
package/src/index.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { runWithHooks } from '../component'
|
|
2
2
|
import {
|
|
3
|
+
captureContextStack,
|
|
3
4
|
createContext,
|
|
5
|
+
getContextStackLength,
|
|
4
6
|
popContext,
|
|
5
7
|
provide,
|
|
6
8
|
pushContext,
|
|
9
|
+
restoreContextStack,
|
|
7
10
|
setContextStackProvider,
|
|
8
11
|
useContext,
|
|
9
12
|
withContext,
|
|
10
13
|
} from '../context'
|
|
14
|
+
import type { ContextSnapshot } from '../context'
|
|
11
15
|
import type { ComponentFn, Props } from '../types'
|
|
12
16
|
|
|
13
17
|
describe('createContext', () => {
|
|
@@ -259,3 +263,367 @@ describe('setContextStackProvider', () => {
|
|
|
259
263
|
setContextStackProvider(() => freshStack)
|
|
260
264
|
})
|
|
261
265
|
})
|
|
266
|
+
|
|
267
|
+
// ─── captureContextStack — dedup semantics ───────────────────────────────────
|
|
268
|
+
//
|
|
269
|
+
// The capture step deduplicates: only the topmost frame per context-id is
|
|
270
|
+
// retained in the snapshot. This is a HEAP-LEAK fix: under deeply-nested
|
|
271
|
+
// reactive boundaries, each effect's setup-time snapshot used to grow with
|
|
272
|
+
// the live stack's transient duplicates (40k+ entries reported in 0.21.x;
|
|
273
|
+
// see context.ts JSDoc for the full story). Dedup collapses the captured
|
|
274
|
+
// size to ~N entries where N is the number of distinct context ids in
|
|
275
|
+
// scope (typically 2-10 in real apps).
|
|
276
|
+
//
|
|
277
|
+
// Safety property: `useContext` walks the stack in reverse and stops at
|
|
278
|
+
// the first matching frame; any shadowed frame is unreachable. The dedup
|
|
279
|
+
// preserves the topmost frame per id, so `useContext` returns the same
|
|
280
|
+
// value before and after.
|
|
281
|
+
|
|
282
|
+
describe('captureContextStack — dedup', () => {
|
|
283
|
+
const restoreStack: Map<symbol, unknown>[][] = []
|
|
284
|
+
let testStack: Map<symbol, unknown>[]
|
|
285
|
+
|
|
286
|
+
beforeEach(() => {
|
|
287
|
+
testStack = []
|
|
288
|
+
setContextStackProvider(() => testStack)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
afterEach(() => {
|
|
292
|
+
while (restoreStack.length > 0) restoreStack.pop()
|
|
293
|
+
const freshStack: Map<symbol, unknown>[] = []
|
|
294
|
+
setContextStackProvider(() => freshStack)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test('empty stack snapshot is empty', () => {
|
|
298
|
+
expect(captureContextStack()).toEqual([])
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
test('single frame snapshot is identical', () => {
|
|
302
|
+
const ctx = createContext('default')
|
|
303
|
+
pushContext(new Map([[ctx.id, 'A']]))
|
|
304
|
+
const snap = captureContextStack()
|
|
305
|
+
expect(snap).toHaveLength(1)
|
|
306
|
+
expect(snap[0]).toBe(testStack[0]) // same reference
|
|
307
|
+
popContext()
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
test('stack with no duplicate ids snapshots verbatim', () => {
|
|
311
|
+
const a = createContext('a-default')
|
|
312
|
+
const b = createContext('b-default')
|
|
313
|
+
const c = createContext('c-default')
|
|
314
|
+
pushContext(new Map([[a.id, 'A']]))
|
|
315
|
+
pushContext(new Map([[b.id, 'B']]))
|
|
316
|
+
pushContext(new Map([[c.id, 'C']]))
|
|
317
|
+
const snap = captureContextStack()
|
|
318
|
+
expect(snap).toHaveLength(3)
|
|
319
|
+
expect(snap.map((f) => Array.from(f.values()))).toEqual([['A'], ['B'], ['C']])
|
|
320
|
+
popContext()
|
|
321
|
+
popContext()
|
|
322
|
+
popContext()
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
test('duplicate ids collapse to topmost', () => {
|
|
326
|
+
// Same context-id pushed 3 times — typical of nested restoreContextStack
|
|
327
|
+
// windows. Only the topmost should appear in the snapshot.
|
|
328
|
+
const ctx = createContext('default')
|
|
329
|
+
pushContext(new Map([[ctx.id, 'A']]))
|
|
330
|
+
pushContext(new Map([[ctx.id, 'B']]))
|
|
331
|
+
pushContext(new Map([[ctx.id, 'C']]))
|
|
332
|
+
const snap = captureContextStack()
|
|
333
|
+
expect(snap).toHaveLength(1)
|
|
334
|
+
expect(snap[0]!.get(ctx.id)).toBe('C') // topmost wins
|
|
335
|
+
popContext()
|
|
336
|
+
popContext()
|
|
337
|
+
popContext()
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
test('mixed: deep stack with mostly duplicates collapses', () => {
|
|
341
|
+
// Simulates the bug shape: same context pushed 40 times via nested
|
|
342
|
+
// restore windows + one unique frame at the top.
|
|
343
|
+
const repeated = createContext('default')
|
|
344
|
+
const unique = createContext('default')
|
|
345
|
+
for (let i = 0; i < 40; i++) {
|
|
346
|
+
pushContext(new Map([[repeated.id, `dup-${i}`]]))
|
|
347
|
+
}
|
|
348
|
+
pushContext(new Map([[unique.id, 'unique']]))
|
|
349
|
+
expect(testStack).toHaveLength(41)
|
|
350
|
+
|
|
351
|
+
const snap = captureContextStack()
|
|
352
|
+
// Result: topmost `repeated` frame + the `unique` frame = 2 entries.
|
|
353
|
+
// Pre-fix this snapshot would have all 41 frames — the leak.
|
|
354
|
+
expect(snap).toHaveLength(2)
|
|
355
|
+
// Ordering must match push order (bottom-to-top in the array).
|
|
356
|
+
expect(snap[0]!.get(repeated.id)).toBe('dup-39')
|
|
357
|
+
expect(snap[1]!.get(unique.id)).toBe('unique')
|
|
358
|
+
|
|
359
|
+
for (let i = 0; i < 41; i++) popContext()
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
test('multi-key frame: kept if it provides ANY un-shadowed id', () => {
|
|
363
|
+
// Frame with two contexts; only one is shadowed by a deeper push.
|
|
364
|
+
const a = createContext('a')
|
|
365
|
+
const b = createContext('b')
|
|
366
|
+
pushContext(new Map<symbol, unknown>([[a.id, 'a1'], [b.id, 'b1']]))
|
|
367
|
+
pushContext(new Map([[a.id, 'a2']])) // shadows `a`, NOT `b`
|
|
368
|
+
|
|
369
|
+
const snap = captureContextStack()
|
|
370
|
+
// Both frames should remain: the upper provides `a`, the lower
|
|
371
|
+
// still provides un-shadowed `b`.
|
|
372
|
+
expect(snap).toHaveLength(2)
|
|
373
|
+
|
|
374
|
+
// Verify useContext semantics survive: a→a2, b→b1
|
|
375
|
+
setContextStackProvider(() => snap)
|
|
376
|
+
expect(useContext(a)).toBe('a2')
|
|
377
|
+
expect(useContext(b)).toBe('b1')
|
|
378
|
+
setContextStackProvider(() => testStack)
|
|
379
|
+
|
|
380
|
+
popContext()
|
|
381
|
+
popContext()
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
test('multi-key frame: dropped if ALL its ids are shadowed', () => {
|
|
385
|
+
const a = createContext('a')
|
|
386
|
+
const b = createContext('b')
|
|
387
|
+
pushContext(new Map<symbol, unknown>([[a.id, 'a1'], [b.id, 'b1']]))
|
|
388
|
+
pushContext(new Map<symbol, unknown>([[a.id, 'a2'], [b.id, 'b2']]))
|
|
389
|
+
|
|
390
|
+
const snap = captureContextStack()
|
|
391
|
+
expect(snap).toHaveLength(1)
|
|
392
|
+
expect(snap[0]!.get(a.id)).toBe('a2')
|
|
393
|
+
expect(snap[0]!.get(b.id)).toBe('b2')
|
|
394
|
+
|
|
395
|
+
popContext()
|
|
396
|
+
popContext()
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
test('useContext returns same value pre/post dedup for arbitrary read patterns', () => {
|
|
400
|
+
// Cross-check: build a complex stack, capture, then verify useContext
|
|
401
|
+
// returns the same value when reading from the original stack vs the
|
|
402
|
+
// deduped snapshot. This is the load-bearing semantic-equivalence
|
|
403
|
+
// assertion for the safety argument.
|
|
404
|
+
const a = createContext('a-default')
|
|
405
|
+
const b = createContext('b-default')
|
|
406
|
+
const c = createContext('c-default')
|
|
407
|
+
pushContext(new Map([[a.id, 'a1']]))
|
|
408
|
+
pushContext(new Map<symbol, unknown>([[a.id, 'a2'], [b.id, 'b1']]))
|
|
409
|
+
pushContext(new Map([[c.id, 'c1']]))
|
|
410
|
+
pushContext(new Map([[a.id, 'a3']]))
|
|
411
|
+
pushContext(new Map([[b.id, 'b2']]))
|
|
412
|
+
|
|
413
|
+
// Read against original stack
|
|
414
|
+
const beforeA = useContext(a)
|
|
415
|
+
const beforeB = useContext(b)
|
|
416
|
+
const beforeC = useContext(c)
|
|
417
|
+
|
|
418
|
+
// Capture (dedup happens) and read against the snapshot
|
|
419
|
+
const snap = captureContextStack()
|
|
420
|
+
setContextStackProvider(() => snap)
|
|
421
|
+
const afterA = useContext(a)
|
|
422
|
+
const afterB = useContext(b)
|
|
423
|
+
const afterC = useContext(c)
|
|
424
|
+
|
|
425
|
+
expect(afterA).toBe(beforeA) // 'a3' from the topmost frame
|
|
426
|
+
expect(afterB).toBe(beforeB) // 'b2' from the topmost frame
|
|
427
|
+
expect(afterC).toBe(beforeC) // 'c1' (still the only c-provider)
|
|
428
|
+
|
|
429
|
+
// Clean up
|
|
430
|
+
setContextStackProvider(() => testStack)
|
|
431
|
+
for (let i = 0; i < 5; i++) popContext()
|
|
432
|
+
})
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
// ─── restoreContextStack — works against deduped snapshots ───────────────────
|
|
436
|
+
|
|
437
|
+
describe('restoreContextStack — with deduped snapshots', () => {
|
|
438
|
+
let testStack: Map<symbol, unknown>[]
|
|
439
|
+
|
|
440
|
+
beforeEach(() => {
|
|
441
|
+
testStack = []
|
|
442
|
+
setContextStackProvider(() => testStack)
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
afterEach(() => {
|
|
446
|
+
const freshStack: Map<symbol, unknown>[] = []
|
|
447
|
+
setContextStackProvider(() => freshStack)
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
test('restores deduped snapshot — fn() sees correct context, stack cleans up', () => {
|
|
451
|
+
const ctx = createContext('default')
|
|
452
|
+
pushContext(new Map([[ctx.id, 'A']]))
|
|
453
|
+
pushContext(new Map([[ctx.id, 'B']]))
|
|
454
|
+
pushContext(new Map([[ctx.id, 'C']]))
|
|
455
|
+
|
|
456
|
+
const snap = captureContextStack()
|
|
457
|
+
expect(snap).toHaveLength(1) // dedup collapsed to topmost
|
|
458
|
+
|
|
459
|
+
// Now empty the stack to simulate post-mount state
|
|
460
|
+
popContext()
|
|
461
|
+
popContext()
|
|
462
|
+
popContext()
|
|
463
|
+
expect(testStack).toHaveLength(0)
|
|
464
|
+
|
|
465
|
+
// Restore the deduped snapshot
|
|
466
|
+
const observed = restoreContextStack(snap, () => {
|
|
467
|
+
// Inside fn(): stack has the deduped frame
|
|
468
|
+
expect(testStack).toHaveLength(1)
|
|
469
|
+
return useContext(ctx)
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
// fn() saw the topmost-frame value, NOT 'default' — semantic equivalence
|
|
473
|
+
expect(observed).toBe('C')
|
|
474
|
+
// After restore, the snapshot's frames are removed by reference identity
|
|
475
|
+
expect(testStack).toHaveLength(0)
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
test('restoring 40-duplicate stack only pushes/pops 1 frame post-dedup', () => {
|
|
479
|
+
// This is the bug-shape regression test. Pre-dedup, this snapshot was
|
|
480
|
+
// 40 entries; restore pushed 40 then removed 40. Post-dedup, both
|
|
481
|
+
// operations move 1 frame.
|
|
482
|
+
const ctx = createContext('default')
|
|
483
|
+
for (let i = 0; i < 40; i++) {
|
|
484
|
+
pushContext(new Map([[ctx.id, `dup-${i}`]]))
|
|
485
|
+
}
|
|
486
|
+
const snap = captureContextStack()
|
|
487
|
+
expect(snap).toHaveLength(1)
|
|
488
|
+
|
|
489
|
+
// Empty the live stack so the restore is observable in isolation.
|
|
490
|
+
while (testStack.length > 0) popContext()
|
|
491
|
+
|
|
492
|
+
let observedLenInside = -1
|
|
493
|
+
restoreContextStack(snap, () => {
|
|
494
|
+
observedLenInside = testStack.length
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
// 1 push during fn, 1 splice after = stack stays balanced.
|
|
498
|
+
expect(observedLenInside).toBe(1)
|
|
499
|
+
expect(testStack).toHaveLength(0)
|
|
500
|
+
})
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
// ─── Leak audit: snapshot allocations stay bounded under deep stacks ─────────
|
|
504
|
+
//
|
|
505
|
+
// This is the regression lock for the heap-snapshot finding that motivated
|
|
506
|
+
// the dedup. Reported in 0.21.x: 1.22 MB / 321k-entry arrays retained by
|
|
507
|
+
// effect closures under deeply-nested reactive boundaries. The 0.23.0
|
|
508
|
+
// restoreContextStack fix cleaned the live stack but residual snapshot
|
|
509
|
+
// amplification persisted (~3 MB / 20×40k-entry arrays). This dedup
|
|
510
|
+
// closes that. The test below makes the bug-shape impossible to
|
|
511
|
+
// re-introduce silently: it builds the deep-stack scenario, captures
|
|
512
|
+
// N snapshots that previously would each have held the stack-depth, and
|
|
513
|
+
// asserts the TOTAL frame count across all snapshots scales with the
|
|
514
|
+
// number of DISTINCT context ids in scope, NOT with the stack depth.
|
|
515
|
+
|
|
516
|
+
describe('captureContextStack — leak audit (regression lock)', () => {
|
|
517
|
+
let testStack: Map<symbol, unknown>[]
|
|
518
|
+
|
|
519
|
+
beforeEach(() => {
|
|
520
|
+
testStack = []
|
|
521
|
+
setContextStackProvider(() => testStack)
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
afterEach(() => {
|
|
525
|
+
const freshStack: Map<symbol, unknown>[] = []
|
|
526
|
+
setContextStackProvider(() => freshStack)
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
test('1000 snapshots of a deep duplicate-heavy stack retain bounded total frames', () => {
|
|
530
|
+
// Build a stack of 100 frames, all pushing the same context (simulates
|
|
531
|
+
// nested restoreContextStack windows). Then capture 1000 snapshots —
|
|
532
|
+
// one per effect setup, as happens in a large component tree.
|
|
533
|
+
const ctx = createContext('default')
|
|
534
|
+
for (let i = 0; i < 100; i++) {
|
|
535
|
+
pushContext(new Map([[ctx.id, `dup-${i}`]]))
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const snapshots: ContextSnapshot[] = []
|
|
539
|
+
for (let i = 0; i < 1000; i++) {
|
|
540
|
+
snapshots.push(captureContextStack())
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Pre-dedup: 1000 snapshots × 100 frames = 100,000 frame references.
|
|
544
|
+
// Post-dedup: 1000 snapshots × 1 frame (topmost) = 1,000 frame references.
|
|
545
|
+
// The assertion bounds total retention at the dedup-correct ceiling.
|
|
546
|
+
const totalFrames = snapshots.reduce((sum, s) => sum + s.length, 0)
|
|
547
|
+
expect(totalFrames).toBe(1000) // 1000 snapshots × 1 unique id
|
|
548
|
+
|
|
549
|
+
// Clean up
|
|
550
|
+
for (let i = 0; i < 100; i++) popContext()
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
test('mixed deep stack: total frames bounded by distinct id count, not depth', () => {
|
|
554
|
+
// 50 unique contexts pushed into a stack of 500 frames (10 duplicates
|
|
555
|
+
// per context). Capture 100 snapshots.
|
|
556
|
+
const ctxs = Array.from({ length: 50 }, () => createContext('default'))
|
|
557
|
+
for (let depth = 0; depth < 10; depth++) {
|
|
558
|
+
for (const ctx of ctxs) {
|
|
559
|
+
pushContext(new Map([[ctx.id, `d${depth}`]]))
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
expect(testStack).toHaveLength(500)
|
|
563
|
+
|
|
564
|
+
const snapshots: ContextSnapshot[] = []
|
|
565
|
+
for (let i = 0; i < 100; i++) {
|
|
566
|
+
snapshots.push(captureContextStack())
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Pre-dedup: 100 × 500 = 50,000 frame references.
|
|
570
|
+
// Post-dedup: 100 × 50 (topmost per distinct id) = 5,000 frame
|
|
571
|
+
// references. 10× reduction matches the empirical bug-shape.
|
|
572
|
+
const totalFrames = snapshots.reduce((sum, s) => sum + s.length, 0)
|
|
573
|
+
expect(totalFrames).toBe(100 * 50)
|
|
574
|
+
|
|
575
|
+
// Clean up
|
|
576
|
+
for (let i = 0; i < 500; i++) popContext()
|
|
577
|
+
})
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
// ─── getContextStackLength ──────────────────────────────────────────────────
|
|
581
|
+
|
|
582
|
+
describe('getContextStackLength', () => {
|
|
583
|
+
let testStack: Map<symbol, unknown>[]
|
|
584
|
+
|
|
585
|
+
beforeEach(() => {
|
|
586
|
+
testStack = []
|
|
587
|
+
setContextStackProvider(() => testStack)
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
afterEach(() => {
|
|
591
|
+
const freshStack: Map<symbol, unknown>[] = []
|
|
592
|
+
setContextStackProvider(() => freshStack)
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
test('returns the LIVE stack length, not the deduped snapshot length', () => {
|
|
596
|
+
// This is the load-bearing distinction: SSR cleanup uses
|
|
597
|
+
// `getContextStackLength()` as a position marker, and it must reflect
|
|
598
|
+
// the live (un-deduped) stack length so subsequent `popContext` calls
|
|
599
|
+
// pop the right number of frames.
|
|
600
|
+
const ctx = createContext('default')
|
|
601
|
+
pushContext(new Map([[ctx.id, 'A']]))
|
|
602
|
+
pushContext(new Map([[ctx.id, 'B']]))
|
|
603
|
+
pushContext(new Map([[ctx.id, 'C']]))
|
|
604
|
+
|
|
605
|
+
expect(getContextStackLength()).toBe(3) // live length
|
|
606
|
+
expect(captureContextStack()).toHaveLength(1) // deduped snapshot length
|
|
607
|
+
|
|
608
|
+
popContext()
|
|
609
|
+
popContext()
|
|
610
|
+
popContext()
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
test('zero on empty stack', () => {
|
|
614
|
+
expect(getContextStackLength()).toBe(0)
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
test('matches stack array length after push/pop cycles', () => {
|
|
618
|
+
const ctx = createContext('default')
|
|
619
|
+
expect(getContextStackLength()).toBe(0)
|
|
620
|
+
pushContext(new Map([[ctx.id, 'A']]))
|
|
621
|
+
expect(getContextStackLength()).toBe(1)
|
|
622
|
+
pushContext(new Map([[ctx.id, 'B']]))
|
|
623
|
+
expect(getContextStackLength()).toBe(2)
|
|
624
|
+
popContext()
|
|
625
|
+
expect(getContextStackLength()).toBe(1)
|
|
626
|
+
popContext()
|
|
627
|
+
expect(getContextStackLength()).toBe(0)
|
|
628
|
+
})
|
|
629
|
+
})
|