@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/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
- pushContext(new Map<symbol, unknown>([[context.id, value]]))
115
- onUnmount(() => popContext())
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
- popContext()
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
- // Shallow copy — each frame (Map) is shared by reference, which is
146
- // correct because providers don't mutate frames after creation.
147
- return [...getStack()]
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
- // Splice out exactly the snapshot frames we pushed (they sit at
176
- // [insertIndex, insertIndex + snapshot.length)). Any frames `fn()`
177
- // pushed AFTER our snapshot (provider frames) get shifted down by
178
- // `snapshot.length` positions but remain on the stack. Their owning
179
- // components' `onUnmount(popContext)` handlers will pop them in
180
- // LIFO order on subtree teardown splice preserves that ordering
181
- // because it doesn't touch frames at indices >= insertIndex +
182
- // snapshot.length until the splice operation itself.
183
- stack.splice(insertIndex, snapshot.length)
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
 
@@ -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
- onUnmount(() => popErrorBoundary())
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
@@ -8,9 +8,11 @@ export {
8
8
  captureContextStack,
9
9
  createContext,
10
10
  createReactiveContext,
11
+ getContextStackLength,
11
12
  popContext,
12
13
  provide,
13
14
  pushContext,
15
+ removeContextFrame,
14
16
  restoreContextStack,
15
17
  setContextStackProvider,
16
18
  useContext,
@@ -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
+ })