@pyreon/core 0.23.0 → 0.24.1
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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +82 -3
- package/lib/types/index.d.ts +64 -2
- package/package.json +2 -2
- package/src/context.ts +97 -4
- package/src/index.ts +1 -0
- package/src/tests/context.test.ts +368 -0
|
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
|
|
|
5386
5386
|
</script>
|
|
5387
5387
|
<script>
|
|
5388
5388
|
/*<!--*/
|
|
5389
|
-
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"
|
|
5389
|
+
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"48c493c7-1","name":"lifecycle.ts"},{"uid":"48c493c7-3","name":"component.ts"},{"uid":"48c493c7-5","name":"compat-marker.ts"},{"uid":"48c493c7-7","name":"compat-shared.ts"},{"uid":"48c493c7-9","name":"context.ts"},{"uid":"48c493c7-11","name":"dynamic.ts"},{"uid":"48c493c7-13","name":"telemetry.ts"},{"uid":"48c493c7-15","name":"error-boundary.ts"},{"uid":"48c493c7-17","name":"for.ts"},{"uid":"48c493c7-19","name":"ref.ts"},{"uid":"48c493c7-21","name":"defer.ts"},{"uid":"48c493c7-23","name":"lazy.ts"},{"uid":"48c493c7-25","name":"map-array.ts"},{"uid":"48c493c7-27","name":"portal.ts"},{"uid":"48c493c7-29","name":"props.ts"},{"uid":"48c493c7-31","name":"show.ts"},{"uid":"48c493c7-33","name":"style.ts"},{"uid":"48c493c7-35","name":"suspense.ts"},{"uid":"48c493c7-37","name":"index.ts"}]}]},{"name":"jsx-dev-runtime.js","children":[{"name":"src/jsx-dev-runtime.ts","uid":"48c493c7-39"}]},{"name":"jsx-runtime.js","children":[{"name":"src/jsx-runtime.ts","uid":"48c493c7-41"}]},{"name":"_chunks/h-CYSD6aBx.js","children":[{"name":"src/h.ts","uid":"48c493c7-43"}]}],"isRoot":true},"nodeParts":{"48c493c7-1":{"renderedLength":3090,"gzipLength":1316,"brotliLength":0,"metaUid":"48c493c7-0"},"48c493c7-3":{"renderedLength":1908,"gzipLength":881,"brotliLength":0,"metaUid":"48c493c7-2"},"48c493c7-5":{"renderedLength":3173,"gzipLength":1409,"brotliLength":0,"metaUid":"48c493c7-4"},"48c493c7-7":{"renderedLength":2346,"gzipLength":1033,"brotliLength":0,"metaUid":"48c493c7-6"},"48c493c7-9":{"renderedLength":9041,"gzipLength":3596,"brotliLength":0,"metaUid":"48c493c7-8"},"48c493c7-11":{"renderedLength":490,"gzipLength":292,"brotliLength":0,"metaUid":"48c493c7-10"},"48c493c7-13":{"renderedLength":1990,"gzipLength":950,"brotliLength":0,"metaUid":"48c493c7-12"},"48c493c7-15":{"renderedLength":1666,"gzipLength":843,"brotliLength":0,"metaUid":"48c493c7-14"},"48c493c7-17":{"renderedLength":700,"gzipLength":478,"brotliLength":0,"metaUid":"48c493c7-16"},"48c493c7-19":{"renderedLength":86,"gzipLength":98,"brotliLength":0,"metaUid":"48c493c7-18"},"48c493c7-21":{"renderedLength":4387,"gzipLength":1891,"brotliLength":0,"metaUid":"48c493c7-20"},"48c493c7-23":{"renderedLength":461,"gzipLength":273,"brotliLength":0,"metaUid":"48c493c7-22"},"48c493c7-25":{"renderedLength":1018,"gzipLength":571,"brotliLength":0,"metaUid":"48c493c7-24"},"48c493c7-27":{"renderedLength":818,"gzipLength":491,"brotliLength":0,"metaUid":"48c493c7-26"},"48c493c7-29":{"renderedLength":6310,"gzipLength":2344,"brotliLength":0,"metaUid":"48c493c7-28"},"48c493c7-31":{"renderedLength":2022,"gzipLength":854,"brotliLength":0,"metaUid":"48c493c7-30"},"48c493c7-33":{"renderedLength":1858,"gzipLength":825,"brotliLength":0,"metaUid":"48c493c7-32"},"48c493c7-35":{"renderedLength":1104,"gzipLength":614,"brotliLength":0,"metaUid":"48c493c7-34"},"48c493c7-37":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"48c493c7-36"},"48c493c7-39":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"48c493c7-38"},"48c493c7-41":{"renderedLength":1789,"gzipLength":834,"brotliLength":0,"metaUid":"48c493c7-40"},"48c493c7-43":{"renderedLength":1813,"gzipLength":957,"brotliLength":0,"metaUid":"48c493c7-42"}},"nodeMetas":{"48c493c7-0":{"id":"/src/lifecycle.ts","moduleParts":{"index.js":"48c493c7-1"},"imported":[],"importedBy":[{"uid":"48c493c7-36"},{"uid":"48c493c7-2"},{"uid":"48c493c7-8"},{"uid":"48c493c7-14"},{"uid":"48c493c7-20"}]},"48c493c7-2":{"id":"/src/component.ts","moduleParts":{"index.js":"48c493c7-3"},"imported":[{"uid":"48c493c7-0"}],"importedBy":[{"uid":"48c493c7-36"},{"uid":"48c493c7-14"}]},"48c493c7-4":{"id":"/src/compat-marker.ts","moduleParts":{"index.js":"48c493c7-5"},"imported":[],"importedBy":[{"uid":"48c493c7-36"},{"uid":"48c493c7-14"}]},"48c493c7-6":{"id":"/src/compat-shared.ts","moduleParts":{"index.js":"48c493c7-7"},"imported":[],"importedBy":[{"uid":"48c493c7-36"}]},"48c493c7-8":{"id":"/src/context.ts","moduleParts":{"index.js":"48c493c7-9"},"imported":[{"uid":"48c493c7-44"},{"uid":"48c493c7-0"}],"importedBy":[{"uid":"48c493c7-36"}]},"48c493c7-10":{"id":"/src/dynamic.ts","moduleParts":{"index.js":"48c493c7-11"},"imported":[{"uid":"48c493c7-42"}],"importedBy":[{"uid":"48c493c7-36"}]},"48c493c7-12":{"id":"/src/telemetry.ts","moduleParts":{"index.js":"48c493c7-13"},"imported":[{"uid":"48c493c7-44"}],"importedBy":[{"uid":"48c493c7-36"},{"uid":"48c493c7-14"}]},"48c493c7-14":{"id":"/src/error-boundary.ts","moduleParts":{"index.js":"48c493c7-15"},"imported":[{"uid":"48c493c7-44"},{"uid":"48c493c7-4"},{"uid":"48c493c7-2"},{"uid":"48c493c7-0"},{"uid":"48c493c7-12"}],"importedBy":[{"uid":"48c493c7-36"}]},"48c493c7-16":{"id":"/src/for.ts","moduleParts":{"index.js":"48c493c7-17"},"imported":[],"importedBy":[{"uid":"48c493c7-36"}]},"48c493c7-18":{"id":"/src/ref.ts","moduleParts":{"index.js":"48c493c7-19"},"imported":[],"importedBy":[{"uid":"48c493c7-36"},{"uid":"48c493c7-20"}]},"48c493c7-20":{"id":"/src/defer.ts","moduleParts":{"index.js":"48c493c7-21"},"imported":[{"uid":"48c493c7-44"},{"uid":"48c493c7-42"},{"uid":"48c493c7-0"},{"uid":"48c493c7-18"}],"importedBy":[{"uid":"48c493c7-36"}]},"48c493c7-22":{"id":"/src/lazy.ts","moduleParts":{"index.js":"48c493c7-23"},"imported":[{"uid":"48c493c7-44"},{"uid":"48c493c7-42"}],"importedBy":[{"uid":"48c493c7-36"}]},"48c493c7-24":{"id":"/src/map-array.ts","moduleParts":{"index.js":"48c493c7-25"},"imported":[],"importedBy":[{"uid":"48c493c7-36"}]},"48c493c7-26":{"id":"/src/portal.ts","moduleParts":{"index.js":"48c493c7-27"},"imported":[],"importedBy":[{"uid":"48c493c7-36"}]},"48c493c7-28":{"id":"/src/props.ts","moduleParts":{"index.js":"48c493c7-29"},"imported":[],"importedBy":[{"uid":"48c493c7-36"}]},"48c493c7-30":{"id":"/src/show.ts","moduleParts":{"index.js":"48c493c7-31"},"imported":[],"importedBy":[{"uid":"48c493c7-36"}]},"48c493c7-32":{"id":"/src/style.ts","moduleParts":{"index.js":"48c493c7-33"},"imported":[],"importedBy":[{"uid":"48c493c7-36"}]},"48c493c7-34":{"id":"/src/suspense.ts","moduleParts":{"index.js":"48c493c7-35"},"imported":[{"uid":"48c493c7-42"}],"importedBy":[{"uid":"48c493c7-36"}]},"48c493c7-36":{"id":"/src/index.ts","moduleParts":{"index.js":"48c493c7-37"},"imported":[{"uid":"48c493c7-2"},{"uid":"48c493c7-4"},{"uid":"48c493c7-6"},{"uid":"48c493c7-8"},{"uid":"48c493c7-10"},{"uid":"48c493c7-14"},{"uid":"48c493c7-16"},{"uid":"48c493c7-42"},{"uid":"48c493c7-20"},{"uid":"48c493c7-22"},{"uid":"48c493c7-0"},{"uid":"48c493c7-24"},{"uid":"48c493c7-26"},{"uid":"48c493c7-28"},{"uid":"48c493c7-18"},{"uid":"48c493c7-30"},{"uid":"48c493c7-32"},{"uid":"48c493c7-34"},{"uid":"48c493c7-12"}],"importedBy":[],"isEntry":true},"48c493c7-38":{"id":"/src/jsx-dev-runtime.ts","moduleParts":{"jsx-dev-runtime.js":"48c493c7-39"},"imported":[{"uid":"48c493c7-40"}],"importedBy":[],"isEntry":true},"48c493c7-40":{"id":"/src/jsx-runtime.ts","moduleParts":{"jsx-runtime.js":"48c493c7-41"},"imported":[{"uid":"48c493c7-42"}],"importedBy":[{"uid":"48c493c7-38"}],"isEntry":true},"48c493c7-42":{"id":"/src/h.ts","moduleParts":{"_chunks/h-CYSD6aBx.js":"48c493c7-43"},"imported":[],"importedBy":[{"uid":"48c493c7-36"},{"uid":"48c493c7-10"},{"uid":"48c493c7-20"},{"uid":"48c493c7-22"},{"uid":"48c493c7-34"},{"uid":"48c493c7-40"}]},"48c493c7-44":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"48c493c7-8"},{"uid":"48c493c7-14"},{"uid":"48c493c7-20"},{"uid":"48c493c7-22"},{"uid":"48c493c7-12"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
|
|
5390
5390
|
|
|
5391
5391
|
const run = () => {
|
|
5392
5392
|
const width = window.innerWidth;
|
package/lib/index.js
CHANGED
|
@@ -362,6 +362,23 @@ function popContext() {
|
|
|
362
362
|
stack.pop();
|
|
363
363
|
}
|
|
364
364
|
/**
|
|
365
|
+
* Read the current live stack length WITHOUT allocating a snapshot.
|
|
366
|
+
*
|
|
367
|
+
* SSR cleanup uses this as a position marker: capture the live length
|
|
368
|
+
* before a component renders, pop the live stack back to that length
|
|
369
|
+
* after. Previously these sites called `captureContextStack().length`,
|
|
370
|
+
* which allocated a full snapshot array (potentially 40k+ entries
|
|
371
|
+
* under deeply-nested reactive boundaries — the same allocation the
|
|
372
|
+
* `captureContextStack` dedup work is designed to shrink) just to
|
|
373
|
+
* read its length. This helper avoids the allocation entirely AND
|
|
374
|
+
* decouples SSR cleanup from `captureContextStack`'s snapshot shape,
|
|
375
|
+
* so dedup at capture time can never silently break SSR length
|
|
376
|
+
* bookkeeping.
|
|
377
|
+
*/
|
|
378
|
+
function getContextStackLength() {
|
|
379
|
+
return getStack().length;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
365
382
|
* Remove a SPECIFIC frame from the context stack by reference identity.
|
|
366
383
|
*
|
|
367
384
|
* Internal — used by `provide()` and `withContext()` to safely clean up
|
|
@@ -420,15 +437,77 @@ function withContext(context, value, fn) {
|
|
|
420
437
|
}
|
|
421
438
|
}
|
|
422
439
|
/**
|
|
423
|
-
* Capture a snapshot of the current context stack
|
|
440
|
+
* Capture a snapshot of the current context stack, **deduplicated** so
|
|
441
|
+
* only the topmost frame for each context-id is retained.
|
|
424
442
|
*
|
|
425
443
|
* Used by `mountReactive` to preserve the context that was active when a
|
|
426
444
|
* reactive boundary (e.g. `<Show>`, `<For>`) was set up. When the boundary
|
|
427
445
|
* later mounts new children inside an effect, the snapshot is restored so
|
|
428
446
|
* those children can see ancestor providers via `useContext()`.
|
|
447
|
+
*
|
|
448
|
+
* **Why dedup is semantically equivalent to a full snapshot:**
|
|
449
|
+
* `useContext()` walks the stack in reverse and returns the first frame
|
|
450
|
+
* matching the requested context-id (`for (let i = stack.length - 1; i >= 0; i--)`
|
|
451
|
+
* — see implementation below in this file). Any frame deeper in the
|
|
452
|
+
* stack that ALSO provides the same id is unreachable by definition —
|
|
453
|
+
* the reverse walk stops at the first match. Those shadowed frames are
|
|
454
|
+
* dead weight in the snapshot: they carry no observable value, they
|
|
455
|
+
* cost memory, and they can NEVER affect program behavior.
|
|
456
|
+
*
|
|
457
|
+
* The dedup walks frames from top to bottom keeping a `seen` set of
|
|
458
|
+
* already-resolved context ids. A frame is kept iff at least one of
|
|
459
|
+
* its keys is NOT in `seen` (i.e. it's the topmost provider for at
|
|
460
|
+
* least one id). All of a frame's keys are added to `seen` regardless
|
|
461
|
+
* of whether the frame is kept — `seen` represents "ids that are
|
|
462
|
+
* already provided by a more-recent frame".
|
|
463
|
+
*
|
|
464
|
+
* **Why this is safe for `restoreContextStack`:**
|
|
465
|
+
* `restoreContextStack` pushes the snapshot's frames onto the live
|
|
466
|
+
* stack, runs `fn()`, then removes those frames by **reference
|
|
467
|
+
* identity** (`stack.lastIndexOf(frame)`) — NOT by position or count
|
|
468
|
+
* of the snapshot. A deduped snapshot pushes fewer frames; the same
|
|
469
|
+
* reference-identity cleanup removes exactly those frames. No
|
|
470
|
+
* bookkeeping invariant breaks.
|
|
471
|
+
*
|
|
472
|
+
* **Why this is safe for the live stack length invariant:**
|
|
473
|
+
* SSR cleanup uses `getContextStackLength()` (a sibling helper) for
|
|
474
|
+
* position-marker bookkeeping. That helper reads the LIVE stack
|
|
475
|
+
* length, NOT the snapshot length, so dedup at capture time has zero
|
|
476
|
+
* effect on SSR cleanup behavior.
|
|
477
|
+
*
|
|
478
|
+
* **Why this is needed:**
|
|
479
|
+
* Under deeply-nested reactive boundaries (a `<Show>` inside a `<For>`
|
|
480
|
+
* inside a `<Suspense>`, each effect capturing its own snapshot at
|
|
481
|
+
* setup time), the live stack temporarily holds the same context-id
|
|
482
|
+
* pushed multiple times during nested `restoreContextStack` windows.
|
|
483
|
+
* The pre-dedup `[...getStack()]` snapshot baked those duplicates in
|
|
484
|
+
* permanently — each effect's closure retained an O(stack-depth)
|
|
485
|
+
* array for its lifetime. Reported heap snapshots from 0.21.x showed
|
|
486
|
+
* 1.22 MB / 321k-entry arrays from this pattern. The 0.23.0
|
|
487
|
+
* restoreContextStack reference-identity fix cleaned the LIVE stack
|
|
488
|
+
* but left the residual snapshot-amplification — observable as 20
|
|
489
|
+
* arrays at 157 KB each (40k entries) retained by effect closures.
|
|
490
|
+
* This dedup collapses each captured snapshot to ~N entries, where
|
|
491
|
+
* N is the number of DISTINCT context ids in scope (typically 2-10
|
|
492
|
+
* in real apps).
|
|
429
493
|
*/
|
|
430
494
|
function captureContextStack() {
|
|
431
|
-
|
|
495
|
+
const stack = getStack();
|
|
496
|
+
if (stack.length <= 1) return stack.slice();
|
|
497
|
+
const seen = /* @__PURE__ */ new Set();
|
|
498
|
+
const reversed = [];
|
|
499
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
500
|
+
const frame = stack[i];
|
|
501
|
+
if (!frame) continue;
|
|
502
|
+
let unique = false;
|
|
503
|
+
for (const id of frame.keys()) if (!seen.has(id)) {
|
|
504
|
+
seen.add(id);
|
|
505
|
+
unique = true;
|
|
506
|
+
}
|
|
507
|
+
if (unique) reversed.push(frame);
|
|
508
|
+
}
|
|
509
|
+
reversed.reverse();
|
|
510
|
+
return reversed;
|
|
432
511
|
}
|
|
433
512
|
/**
|
|
434
513
|
* Execute `fn()` with a previously captured context stack active.
|
|
@@ -1201,5 +1280,5 @@ function Suspense(props) {
|
|
|
1201
1280
|
}
|
|
1202
1281
|
|
|
1203
1282
|
//#endregion
|
|
1204
|
-
export { CSS_UNITLESS, Defer, Dynamic, EMPTY_PROPS, ErrorBoundary, For, ForSymbol, Fragment, Match, MatchSymbol, NATIVE_COMPAT_MARKER, Portal, PortalSymbol, REACTIVE_PROP, Show, Suspense, Switch, _rp, _wrapSpread, captureContextStack, createContext, createReactiveContext, createRef, createUniqueId, cx, defineComponent, dispatchToErrorBoundary, h, isNativeCompat, lazy, makeReactiveProps, mapArray, mapCompatDomProps, mergeProps, nativeCompat, normalizeStyleValue, onErrorCaptured, onMount, onUnmount, onUpdate, popContext, propagateError, provide, pushContext, registerErrorHandler, removeContextFrame, reportError, restoreContextStack, runWithHooks, setContextStackProvider, shallowEqualProps, splitProps, toKebabCase, useContext, withContext };
|
|
1283
|
+
export { CSS_UNITLESS, Defer, Dynamic, EMPTY_PROPS, ErrorBoundary, For, ForSymbol, Fragment, Match, MatchSymbol, NATIVE_COMPAT_MARKER, Portal, PortalSymbol, REACTIVE_PROP, Show, Suspense, Switch, _rp, _wrapSpread, captureContextStack, createContext, createReactiveContext, createRef, createUniqueId, cx, defineComponent, dispatchToErrorBoundary, getContextStackLength, h, isNativeCompat, lazy, makeReactiveProps, mapArray, mapCompatDomProps, mergeProps, nativeCompat, normalizeStyleValue, onErrorCaptured, onMount, onUnmount, onUpdate, popContext, propagateError, provide, pushContext, registerErrorHandler, removeContextFrame, reportError, restoreContextStack, runWithHooks, setContextStackProvider, shallowEqualProps, splitProps, toKebabCase, useContext, withContext };
|
|
1205
1284
|
//# sourceMappingURL=index.js.map
|
package/lib/types/index.d.ts
CHANGED
|
@@ -258,6 +258,21 @@ declare function pushContext(values: Map<symbol, unknown>): void;
|
|
|
258
258
|
* unmount, making the top-of-stack unsafe to assume.
|
|
259
259
|
*/
|
|
260
260
|
declare function popContext(): void;
|
|
261
|
+
/**
|
|
262
|
+
* Read the current live stack length WITHOUT allocating a snapshot.
|
|
263
|
+
*
|
|
264
|
+
* SSR cleanup uses this as a position marker: capture the live length
|
|
265
|
+
* before a component renders, pop the live stack back to that length
|
|
266
|
+
* after. Previously these sites called `captureContextStack().length`,
|
|
267
|
+
* which allocated a full snapshot array (potentially 40k+ entries
|
|
268
|
+
* under deeply-nested reactive boundaries — the same allocation the
|
|
269
|
+
* `captureContextStack` dedup work is designed to shrink) just to
|
|
270
|
+
* read its length. This helper avoids the allocation entirely AND
|
|
271
|
+
* decouples SSR cleanup from `captureContextStack`'s snapshot shape,
|
|
272
|
+
* so dedup at capture time can never silently break SSR length
|
|
273
|
+
* bookkeeping.
|
|
274
|
+
*/
|
|
275
|
+
declare function getContextStackLength(): number;
|
|
261
276
|
/**
|
|
262
277
|
* Remove a SPECIFIC frame from the context stack by reference identity.
|
|
263
278
|
*
|
|
@@ -303,12 +318,59 @@ declare function provide<T>(context: Context<T>, value: T): void;
|
|
|
303
318
|
declare function withContext<T>(context: Context<T>, value: T, fn: () => void): void;
|
|
304
319
|
type ContextSnapshot = Map<symbol, unknown>[];
|
|
305
320
|
/**
|
|
306
|
-
* Capture a snapshot of the current context stack
|
|
321
|
+
* Capture a snapshot of the current context stack, **deduplicated** so
|
|
322
|
+
* only the topmost frame for each context-id is retained.
|
|
307
323
|
*
|
|
308
324
|
* Used by `mountReactive` to preserve the context that was active when a
|
|
309
325
|
* reactive boundary (e.g. `<Show>`, `<For>`) was set up. When the boundary
|
|
310
326
|
* later mounts new children inside an effect, the snapshot is restored so
|
|
311
327
|
* those children can see ancestor providers via `useContext()`.
|
|
328
|
+
*
|
|
329
|
+
* **Why dedup is semantically equivalent to a full snapshot:**
|
|
330
|
+
* `useContext()` walks the stack in reverse and returns the first frame
|
|
331
|
+
* matching the requested context-id (`for (let i = stack.length - 1; i >= 0; i--)`
|
|
332
|
+
* — see implementation below in this file). Any frame deeper in the
|
|
333
|
+
* stack that ALSO provides the same id is unreachable by definition —
|
|
334
|
+
* the reverse walk stops at the first match. Those shadowed frames are
|
|
335
|
+
* dead weight in the snapshot: they carry no observable value, they
|
|
336
|
+
* cost memory, and they can NEVER affect program behavior.
|
|
337
|
+
*
|
|
338
|
+
* The dedup walks frames from top to bottom keeping a `seen` set of
|
|
339
|
+
* already-resolved context ids. A frame is kept iff at least one of
|
|
340
|
+
* its keys is NOT in `seen` (i.e. it's the topmost provider for at
|
|
341
|
+
* least one id). All of a frame's keys are added to `seen` regardless
|
|
342
|
+
* of whether the frame is kept — `seen` represents "ids that are
|
|
343
|
+
* already provided by a more-recent frame".
|
|
344
|
+
*
|
|
345
|
+
* **Why this is safe for `restoreContextStack`:**
|
|
346
|
+
* `restoreContextStack` pushes the snapshot's frames onto the live
|
|
347
|
+
* stack, runs `fn()`, then removes those frames by **reference
|
|
348
|
+
* identity** (`stack.lastIndexOf(frame)`) — NOT by position or count
|
|
349
|
+
* of the snapshot. A deduped snapshot pushes fewer frames; the same
|
|
350
|
+
* reference-identity cleanup removes exactly those frames. No
|
|
351
|
+
* bookkeeping invariant breaks.
|
|
352
|
+
*
|
|
353
|
+
* **Why this is safe for the live stack length invariant:**
|
|
354
|
+
* SSR cleanup uses `getContextStackLength()` (a sibling helper) for
|
|
355
|
+
* position-marker bookkeeping. That helper reads the LIVE stack
|
|
356
|
+
* length, NOT the snapshot length, so dedup at capture time has zero
|
|
357
|
+
* effect on SSR cleanup behavior.
|
|
358
|
+
*
|
|
359
|
+
* **Why this is needed:**
|
|
360
|
+
* Under deeply-nested reactive boundaries (a `<Show>` inside a `<For>`
|
|
361
|
+
* inside a `<Suspense>`, each effect capturing its own snapshot at
|
|
362
|
+
* setup time), the live stack temporarily holds the same context-id
|
|
363
|
+
* pushed multiple times during nested `restoreContextStack` windows.
|
|
364
|
+
* The pre-dedup `[...getStack()]` snapshot baked those duplicates in
|
|
365
|
+
* permanently — each effect's closure retained an O(stack-depth)
|
|
366
|
+
* array for its lifetime. Reported heap snapshots from 0.21.x showed
|
|
367
|
+
* 1.22 MB / 321k-entry arrays from this pattern. The 0.23.0
|
|
368
|
+
* restoreContextStack reference-identity fix cleaned the LIVE stack
|
|
369
|
+
* but left the residual snapshot-amplification — observable as 20
|
|
370
|
+
* arrays at 157 KB each (40k entries) retained by effect closures.
|
|
371
|
+
* This dedup collapses each captured snapshot to ~N entries, where
|
|
372
|
+
* N is the number of DISTINCT context ids in scope (typically 2-10
|
|
373
|
+
* in real apps).
|
|
312
374
|
*/
|
|
313
375
|
declare function captureContextStack(): ContextSnapshot;
|
|
314
376
|
/**
|
|
@@ -1443,5 +1505,5 @@ declare function registerErrorHandler(handler: ErrorHandler): () => void;
|
|
|
1443
1505
|
*/
|
|
1444
1506
|
declare function reportError(ctx: ErrorContext): void;
|
|
1445
1507
|
//#endregion
|
|
1446
|
-
export { type AnchorAttributes, type ButtonAttributes, type CSSProperties, CSS_UNITLESS, type ClassValue, type CleanupFn, type ComponentFn, type ComponentInstance, type Context, type ContextSnapshot, Defer, type DeferProps, Dynamic, type DynamicProps, EMPTY_PROPS, ErrorBoundary, type ErrorContext, type ErrorHandler, type ExtractProps, For, type ForProps, ForSymbol, type FormAttributes, Fragment, type HigherOrderComponent, type ImgAttributes, type InputAttributes, type LazyComponent, type LifecycleHooks, Match, type MatchProps, MatchSymbol, NATIVE_COMPAT_MARKER, type NativeItem, Portal, type PortalProps, PortalSymbol, type Props, type PyreonHTMLAttributes, REACTIVE_PROP, type ReactiveContext, type ReactiveTraceEntry, type Ref, type RefCallback, type RefProp, type SelectAttributes, Show, type ShowProps, type StyleValue, Suspense, type SvgAttributes, Switch, type SwitchProps, type TargetedEvent, type TextareaAttributes, type VNode, type VNodeChild, type VNodeChildAccessor, type VNodeChildAtom, _rp, _wrapSpread, captureContextStack, createContext, createReactiveContext, createRef, createUniqueId, cx, defineComponent, dispatchToErrorBoundary, h, isNativeCompat, lazy, makeReactiveProps, mapArray, mapCompatDomProps, mergeProps, nativeCompat, normalizeStyleValue, onErrorCaptured, onMount, onUnmount, onUpdate, popContext, propagateError, provide, pushContext, registerErrorHandler, removeContextFrame, reportError, restoreContextStack, runWithHooks, setContextStackProvider, shallowEqualProps, splitProps, toKebabCase, useContext, withContext };
|
|
1508
|
+
export { type AnchorAttributes, type ButtonAttributes, type CSSProperties, CSS_UNITLESS, type ClassValue, type CleanupFn, type ComponentFn, type ComponentInstance, type Context, type ContextSnapshot, Defer, type DeferProps, Dynamic, type DynamicProps, EMPTY_PROPS, ErrorBoundary, type ErrorContext, type ErrorHandler, type ExtractProps, For, type ForProps, ForSymbol, type FormAttributes, Fragment, type HigherOrderComponent, type ImgAttributes, type InputAttributes, type LazyComponent, type LifecycleHooks, Match, type MatchProps, MatchSymbol, NATIVE_COMPAT_MARKER, type NativeItem, Portal, type PortalProps, PortalSymbol, type Props, type PyreonHTMLAttributes, REACTIVE_PROP, type ReactiveContext, type ReactiveTraceEntry, type Ref, type RefCallback, type RefProp, type SelectAttributes, Show, type ShowProps, type StyleValue, Suspense, type SvgAttributes, Switch, type SwitchProps, type TargetedEvent, type TextareaAttributes, type VNode, type VNodeChild, type VNodeChildAccessor, type VNodeChildAtom, _rp, _wrapSpread, captureContextStack, createContext, createReactiveContext, createRef, createUniqueId, cx, defineComponent, dispatchToErrorBoundary, getContextStackLength, h, isNativeCompat, lazy, makeReactiveProps, mapArray, mapCompatDomProps, mergeProps, nativeCompat, normalizeStyleValue, onErrorCaptured, onMount, onUnmount, onUpdate, popContext, propagateError, provide, pushContext, registerErrorHandler, removeContextFrame, reportError, restoreContextStack, runWithHooks, setContextStackProvider, shallowEqualProps, splitProps, toKebabCase, useContext, withContext };
|
|
1447
1509
|
//# sourceMappingURL=index2.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.1",
|
|
4
4
|
"description": "Core component model and lifecycle for Pyreon",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/core#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"prepublishOnly": "bun run build"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@pyreon/reactivity": "^0.
|
|
56
|
+
"@pyreon/reactivity": "^0.24.1"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@pyreon/manifest": "0.13.1"
|
package/src/context.ts
CHANGED
|
@@ -89,6 +89,24 @@ export function popContext() {
|
|
|
89
89
|
stack.pop()
|
|
90
90
|
}
|
|
91
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
|
+
|
|
92
110
|
/**
|
|
93
111
|
* Remove a SPECIFIC frame from the context stack by reference identity.
|
|
94
112
|
*
|
|
@@ -179,17 +197,92 @@ export function withContext<T>(context: Context<T>, value: T, fn: () => void) {
|
|
|
179
197
|
export type ContextSnapshot = Map<symbol, unknown>[]
|
|
180
198
|
|
|
181
199
|
/**
|
|
182
|
-
* 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.
|
|
183
202
|
*
|
|
184
203
|
* Used by `mountReactive` to preserve the context that was active when a
|
|
185
204
|
* reactive boundary (e.g. `<Show>`, `<For>`) was set up. When the boundary
|
|
186
205
|
* later mounts new children inside an effect, the snapshot is restored so
|
|
187
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).
|
|
188
253
|
*/
|
|
189
254
|
export function captureContextStack(): ContextSnapshot {
|
|
190
|
-
|
|
191
|
-
//
|
|
192
|
-
|
|
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
|
|
193
286
|
}
|
|
194
287
|
|
|
195
288
|
/**
|
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
|
+
})
|