@pyreon/core 0.23.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.
@@ -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":"1b1bcb41-1","name":"lifecycle.ts"},{"uid":"1b1bcb41-3","name":"component.ts"},{"uid":"1b1bcb41-5","name":"compat-marker.ts"},{"uid":"1b1bcb41-7","name":"compat-shared.ts"},{"uid":"1b1bcb41-9","name":"context.ts"},{"uid":"1b1bcb41-11","name":"dynamic.ts"},{"uid":"1b1bcb41-13","name":"telemetry.ts"},{"uid":"1b1bcb41-15","name":"error-boundary.ts"},{"uid":"1b1bcb41-17","name":"for.ts"},{"uid":"1b1bcb41-19","name":"ref.ts"},{"uid":"1b1bcb41-21","name":"defer.ts"},{"uid":"1b1bcb41-23","name":"lazy.ts"},{"uid":"1b1bcb41-25","name":"map-array.ts"},{"uid":"1b1bcb41-27","name":"portal.ts"},{"uid":"1b1bcb41-29","name":"props.ts"},{"uid":"1b1bcb41-31","name":"show.ts"},{"uid":"1b1bcb41-33","name":"style.ts"},{"uid":"1b1bcb41-35","name":"suspense.ts"},{"uid":"1b1bcb41-37","name":"index.ts"}]}]},{"name":"jsx-dev-runtime.js","children":[{"name":"src/jsx-dev-runtime.ts","uid":"1b1bcb41-39"}]},{"name":"jsx-runtime.js","children":[{"name":"src/jsx-runtime.ts","uid":"1b1bcb41-41"}]},{"name":"_chunks/h-CYSD6aBx.js","children":[{"name":"src/h.ts","uid":"1b1bcb41-43"}]}],"isRoot":true},"nodeParts":{"1b1bcb41-1":{"renderedLength":3090,"gzipLength":1316,"brotliLength":0,"metaUid":"1b1bcb41-0"},"1b1bcb41-3":{"renderedLength":1908,"gzipLength":881,"brotliLength":0,"metaUid":"1b1bcb41-2"},"1b1bcb41-5":{"renderedLength":3173,"gzipLength":1409,"brotliLength":0,"metaUid":"1b1bcb41-4"},"1b1bcb41-7":{"renderedLength":2346,"gzipLength":1033,"brotliLength":0,"metaUid":"1b1bcb41-6"},"1b1bcb41-9":{"renderedLength":5216,"gzipLength":2147,"brotliLength":0,"metaUid":"1b1bcb41-8"},"1b1bcb41-11":{"renderedLength":490,"gzipLength":292,"brotliLength":0,"metaUid":"1b1bcb41-10"},"1b1bcb41-13":{"renderedLength":1990,"gzipLength":950,"brotliLength":0,"metaUid":"1b1bcb41-12"},"1b1bcb41-15":{"renderedLength":1666,"gzipLength":843,"brotliLength":0,"metaUid":"1b1bcb41-14"},"1b1bcb41-17":{"renderedLength":700,"gzipLength":478,"brotliLength":0,"metaUid":"1b1bcb41-16"},"1b1bcb41-19":{"renderedLength":86,"gzipLength":98,"brotliLength":0,"metaUid":"1b1bcb41-18"},"1b1bcb41-21":{"renderedLength":4387,"gzipLength":1891,"brotliLength":0,"metaUid":"1b1bcb41-20"},"1b1bcb41-23":{"renderedLength":461,"gzipLength":273,"brotliLength":0,"metaUid":"1b1bcb41-22"},"1b1bcb41-25":{"renderedLength":1018,"gzipLength":571,"brotliLength":0,"metaUid":"1b1bcb41-24"},"1b1bcb41-27":{"renderedLength":818,"gzipLength":491,"brotliLength":0,"metaUid":"1b1bcb41-26"},"1b1bcb41-29":{"renderedLength":6310,"gzipLength":2344,"brotliLength":0,"metaUid":"1b1bcb41-28"},"1b1bcb41-31":{"renderedLength":2022,"gzipLength":854,"brotliLength":0,"metaUid":"1b1bcb41-30"},"1b1bcb41-33":{"renderedLength":1858,"gzipLength":825,"brotliLength":0,"metaUid":"1b1bcb41-32"},"1b1bcb41-35":{"renderedLength":1104,"gzipLength":614,"brotliLength":0,"metaUid":"1b1bcb41-34"},"1b1bcb41-37":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"1b1bcb41-36"},"1b1bcb41-39":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"1b1bcb41-38"},"1b1bcb41-41":{"renderedLength":1789,"gzipLength":834,"brotliLength":0,"metaUid":"1b1bcb41-40"},"1b1bcb41-43":{"renderedLength":1813,"gzipLength":957,"brotliLength":0,"metaUid":"1b1bcb41-42"}},"nodeMetas":{"1b1bcb41-0":{"id":"/src/lifecycle.ts","moduleParts":{"index.js":"1b1bcb41-1"},"imported":[],"importedBy":[{"uid":"1b1bcb41-36"},{"uid":"1b1bcb41-2"},{"uid":"1b1bcb41-8"},{"uid":"1b1bcb41-14"},{"uid":"1b1bcb41-20"}]},"1b1bcb41-2":{"id":"/src/component.ts","moduleParts":{"index.js":"1b1bcb41-3"},"imported":[{"uid":"1b1bcb41-0"}],"importedBy":[{"uid":"1b1bcb41-36"},{"uid":"1b1bcb41-14"}]},"1b1bcb41-4":{"id":"/src/compat-marker.ts","moduleParts":{"index.js":"1b1bcb41-5"},"imported":[],"importedBy":[{"uid":"1b1bcb41-36"},{"uid":"1b1bcb41-14"}]},"1b1bcb41-6":{"id":"/src/compat-shared.ts","moduleParts":{"index.js":"1b1bcb41-7"},"imported":[],"importedBy":[{"uid":"1b1bcb41-36"}]},"1b1bcb41-8":{"id":"/src/context.ts","moduleParts":{"index.js":"1b1bcb41-9"},"imported":[{"uid":"1b1bcb41-44"},{"uid":"1b1bcb41-0"}],"importedBy":[{"uid":"1b1bcb41-36"}]},"1b1bcb41-10":{"id":"/src/dynamic.ts","moduleParts":{"index.js":"1b1bcb41-11"},"imported":[{"uid":"1b1bcb41-42"}],"importedBy":[{"uid":"1b1bcb41-36"}]},"1b1bcb41-12":{"id":"/src/telemetry.ts","moduleParts":{"index.js":"1b1bcb41-13"},"imported":[{"uid":"1b1bcb41-44"}],"importedBy":[{"uid":"1b1bcb41-36"},{"uid":"1b1bcb41-14"}]},"1b1bcb41-14":{"id":"/src/error-boundary.ts","moduleParts":{"index.js":"1b1bcb41-15"},"imported":[{"uid":"1b1bcb41-44"},{"uid":"1b1bcb41-4"},{"uid":"1b1bcb41-2"},{"uid":"1b1bcb41-0"},{"uid":"1b1bcb41-12"}],"importedBy":[{"uid":"1b1bcb41-36"}]},"1b1bcb41-16":{"id":"/src/for.ts","moduleParts":{"index.js":"1b1bcb41-17"},"imported":[],"importedBy":[{"uid":"1b1bcb41-36"}]},"1b1bcb41-18":{"id":"/src/ref.ts","moduleParts":{"index.js":"1b1bcb41-19"},"imported":[],"importedBy":[{"uid":"1b1bcb41-36"},{"uid":"1b1bcb41-20"}]},"1b1bcb41-20":{"id":"/src/defer.ts","moduleParts":{"index.js":"1b1bcb41-21"},"imported":[{"uid":"1b1bcb41-44"},{"uid":"1b1bcb41-42"},{"uid":"1b1bcb41-0"},{"uid":"1b1bcb41-18"}],"importedBy":[{"uid":"1b1bcb41-36"}]},"1b1bcb41-22":{"id":"/src/lazy.ts","moduleParts":{"index.js":"1b1bcb41-23"},"imported":[{"uid":"1b1bcb41-44"},{"uid":"1b1bcb41-42"}],"importedBy":[{"uid":"1b1bcb41-36"}]},"1b1bcb41-24":{"id":"/src/map-array.ts","moduleParts":{"index.js":"1b1bcb41-25"},"imported":[],"importedBy":[{"uid":"1b1bcb41-36"}]},"1b1bcb41-26":{"id":"/src/portal.ts","moduleParts":{"index.js":"1b1bcb41-27"},"imported":[],"importedBy":[{"uid":"1b1bcb41-36"}]},"1b1bcb41-28":{"id":"/src/props.ts","moduleParts":{"index.js":"1b1bcb41-29"},"imported":[],"importedBy":[{"uid":"1b1bcb41-36"}]},"1b1bcb41-30":{"id":"/src/show.ts","moduleParts":{"index.js":"1b1bcb41-31"},"imported":[],"importedBy":[{"uid":"1b1bcb41-36"}]},"1b1bcb41-32":{"id":"/src/style.ts","moduleParts":{"index.js":"1b1bcb41-33"},"imported":[],"importedBy":[{"uid":"1b1bcb41-36"}]},"1b1bcb41-34":{"id":"/src/suspense.ts","moduleParts":{"index.js":"1b1bcb41-35"},"imported":[{"uid":"1b1bcb41-42"}],"importedBy":[{"uid":"1b1bcb41-36"}]},"1b1bcb41-36":{"id":"/src/index.ts","moduleParts":{"index.js":"1b1bcb41-37"},"imported":[{"uid":"1b1bcb41-2"},{"uid":"1b1bcb41-4"},{"uid":"1b1bcb41-6"},{"uid":"1b1bcb41-8"},{"uid":"1b1bcb41-10"},{"uid":"1b1bcb41-14"},{"uid":"1b1bcb41-16"},{"uid":"1b1bcb41-42"},{"uid":"1b1bcb41-20"},{"uid":"1b1bcb41-22"},{"uid":"1b1bcb41-0"},{"uid":"1b1bcb41-24"},{"uid":"1b1bcb41-26"},{"uid":"1b1bcb41-28"},{"uid":"1b1bcb41-18"},{"uid":"1b1bcb41-30"},{"uid":"1b1bcb41-32"},{"uid":"1b1bcb41-34"},{"uid":"1b1bcb41-12"}],"importedBy":[],"isEntry":true},"1b1bcb41-38":{"id":"/src/jsx-dev-runtime.ts","moduleParts":{"jsx-dev-runtime.js":"1b1bcb41-39"},"imported":[{"uid":"1b1bcb41-40"}],"importedBy":[],"isEntry":true},"1b1bcb41-40":{"id":"/src/jsx-runtime.ts","moduleParts":{"jsx-runtime.js":"1b1bcb41-41"},"imported":[{"uid":"1b1bcb41-42"}],"importedBy":[{"uid":"1b1bcb41-38"}],"isEntry":true},"1b1bcb41-42":{"id":"/src/h.ts","moduleParts":{"_chunks/h-CYSD6aBx.js":"1b1bcb41-43"},"imported":[],"importedBy":[{"uid":"1b1bcb41-36"},{"uid":"1b1bcb41-10"},{"uid":"1b1bcb41-20"},{"uid":"1b1bcb41-22"},{"uid":"1b1bcb41-34"},{"uid":"1b1bcb41-40"}]},"1b1bcb41-44":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"1b1bcb41-8"},{"uid":"1b1bcb41-14"},{"uid":"1b1bcb41-20"},{"uid":"1b1bcb41-22"},{"uid":"1b1bcb41-12"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
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
- return [...getStack()];
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
@@ -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.23.0",
3
+ "version": "0.24.0",
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.23.0"
56
+ "@pyreon/reactivity": "^0.24.0"
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
- // Shallow copy — each frame (Map) is shared by reference, which is
191
- // correct because providers don't mutate frames after creation.
192
- 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
193
286
  }
194
287
 
195
288
  /**
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export {
8
8
  captureContextStack,
9
9
  createContext,
10
10
  createReactiveContext,
11
+ getContextStackLength,
11
12
  popContext,
12
13
  provide,
13
14
  pushContext,
@@ -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
+ })