@pyreon/core 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { n as Fragment, r as h, t as EMPTY_PROPS } from "./_chunks/h-CYSD6aBx.js";
1
2
  import { effect, getReactiveTrace, setSnapshotCapture, signal } from "@pyreon/reactivity";
2
3
 
3
4
  //#region src/lifecycle.ts
@@ -145,8 +146,19 @@ const _errorBoundaryStack = [];
145
146
  function pushErrorBoundary(handler) {
146
147
  _errorBoundaryStack.push(handler);
147
148
  }
148
- function popErrorBoundary() {
149
- _errorBoundaryStack.pop();
149
+ /**
150
+ * Remove a SPECIFIC handler from the error-boundary stack by reference
151
+ * identity. Each `ErrorBoundary` registers `onUnmount(() => popErrorBoundary(handler))`
152
+ * with its OWN handler — so unmount in any order (LIFO, FIFO, middle-out)
153
+ * correctly removes the right handler.
154
+ */
155
+ function popErrorBoundary(handler) {
156
+ if (handler === void 0) {
157
+ _errorBoundaryStack.pop();
158
+ return;
159
+ }
160
+ const idx = _errorBoundaryStack.lastIndexOf(handler);
161
+ if (idx !== -1) _errorBoundaryStack.splice(idx, 1);
150
162
  }
151
163
  /**
152
164
  * Dispatch an error to the nearest active ErrorBoundary.
@@ -334,11 +346,59 @@ process.env.NODE_ENV;
334
346
  function pushContext(values) {
335
347
  getStack().push(values);
336
348
  }
349
+ /**
350
+ * Pop the LAST frame from the context stack.
351
+ *
352
+ * NOTE: position-based pop. Safe ONLY when the caller can guarantee that the
353
+ * top of the stack is the frame they want to remove (the strict LIFO contract).
354
+ * The `provide()` helper does NOT use this — it uses identity-based removal
355
+ * via `removeContextFrame` because reactive boundaries can push snapshot
356
+ * frames between a component's `provide(ctx, value)` and its eventual
357
+ * unmount, making the top-of-stack unsafe to assume.
358
+ */
337
359
  function popContext() {
338
360
  const stack = getStack();
339
361
  if (stack.length === 0) return;
340
362
  stack.pop();
341
363
  }
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
+ /**
382
+ * Remove a SPECIFIC frame from the context stack by reference identity.
383
+ *
384
+ * Internal — used by `provide()` and `withContext()` to safely clean up
385
+ * their pushed frame on unmount even when other frames have been pushed
386
+ * between push and pop (e.g. a reactive boundary's `restoreContextStack`
387
+ * pushing snapshot frames during the descendant's lifecycle). The
388
+ * symmetric position-based `popContext()` would pop the wrong frame in
389
+ * that case and orphan the descendant's provider frame on the live stack
390
+ * — the root cause of the 321k-entry context-stack leak under repeated
391
+ * reactive remounts.
392
+ *
393
+ * Uses `lastIndexOf` (LIFO match) — picks the most-recently-pushed frame
394
+ * with that exact reference, so `provide(ctx, a); provide(ctx, b)` followed
395
+ * by two unmounts removes them in reverse order.
396
+ */
397
+ function removeContextFrame(frame) {
398
+ const stack = getStack();
399
+ const idx = stack.lastIndexOf(frame);
400
+ if (idx !== -1) stack.splice(idx, 1);
401
+ }
342
402
  function useContext(context) {
343
403
  const stack = getStack();
344
404
  for (let i = stack.length - 1; i >= 0; i--) {
@@ -359,31 +419,95 @@ function useContext(context) {
359
419
  * }
360
420
  */
361
421
  function provide(context, value) {
362
- pushContext(new Map([[context.id, value]]));
363
- onUnmount(() => popContext());
422
+ const frame = new Map([[context.id, value]]);
423
+ pushContext(frame);
424
+ onUnmount(() => removeContextFrame(frame));
364
425
  }
365
426
  /**
366
427
  * Provide a value for `context` during `fn()`.
367
428
  * Used by the renderer when it encounters a `<Provider>` component.
368
429
  */
369
430
  function withContext(context, value, fn) {
370
- pushContext(new Map([[context.id, value]]));
431
+ const frame = new Map([[context.id, value]]);
432
+ pushContext(frame);
371
433
  try {
372
434
  fn();
373
435
  } finally {
374
- popContext();
436
+ removeContextFrame(frame);
375
437
  }
376
438
  }
377
439
  /**
378
- * 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.
379
442
  *
380
443
  * Used by `mountReactive` to preserve the context that was active when a
381
444
  * reactive boundary (e.g. `<Show>`, `<For>`) was set up. When the boundary
382
445
  * later mounts new children inside an effect, the snapshot is restored so
383
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).
384
493
  */
385
494
  function captureContextStack() {
386
- 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;
387
511
  }
388
512
  /**
389
513
  * Execute `fn()` with a previously captured context stack active.
@@ -400,12 +524,16 @@ function captureContextStack() {
400
524
  */
401
525
  function restoreContextStack(snapshot, fn) {
402
526
  const stack = getStack();
403
- const insertIndex = stack.length;
404
527
  for (const frame of snapshot) stack.push(frame);
405
528
  try {
406
529
  return fn();
407
530
  } finally {
408
- stack.splice(insertIndex, snapshot.length);
531
+ for (let i = snapshot.length - 1; i >= 0; i--) {
532
+ const frame = snapshot[i];
533
+ if (!frame) continue;
534
+ const idx = stack.lastIndexOf(frame);
535
+ if (idx !== -1) stack.splice(idx, 1);
536
+ }
409
537
  }
410
538
  }
411
539
  setSnapshotCapture({
@@ -413,52 +541,6 @@ setSnapshotCapture({
413
541
  restore: (snap, fn) => restoreContextStack(snap, fn)
414
542
  });
415
543
 
416
- //#endregion
417
- //#region src/h.ts
418
- /**
419
- * Marker for fragment nodes — renders children without a wrapper element.
420
- *
421
- * MUST use `Symbol.for(...)` (global registry, keyed by string), NOT
422
- * `Symbol(...)` (fresh per evaluation). `h.ts` is inlined into BOTH the
423
- * main `lib/index.js` and the `lib/jsx-runtime.js` published bundles —
424
- * each bundle's evaluation of a bare `Symbol(...)` would produce a
425
- * DISTINCT Symbol identity. JSX `<>` compiles to `jsx(Fragment, ...)` and
426
- * resolves to jsx-runtime's identity; `runtime-server` checks
427
- * `vnode.type === Fragment` against the main-entry identity. Mismatch
428
- * fell through to `renderElement` and crashed SSG with
429
- * `TypeError: Cannot convert a Symbol value to a string`.
430
- * `Symbol.for()` keys by string in a global registry shared across all
431
- * bundle evaluations — same identity everywhere.
432
- */
433
- const Fragment = Symbol.for("Pyreon.Fragment");
434
- /**
435
- * Hyperscript function — the compiled output of JSX.
436
- * `<div class="x">hello</div>` → `h("div", { class: "x" }, "hello")`
437
- *
438
- * Generic on P so TypeScript validates props match the component's signature
439
- * at the call site, then stores the result in the loosely-typed VNode.
440
- */
441
- /** Shared empty props sentinel — identity-checked in mountElement to skip applyProps. */
442
- const EMPTY_PROPS = {};
443
- function h(type, props, ...children) {
444
- return {
445
- type,
446
- props: props ?? EMPTY_PROPS,
447
- children: normalizeChildren(children),
448
- key: props?.key ?? null
449
- };
450
- }
451
- function normalizeChildren(children) {
452
- for (let i = 0; i < children.length; i++) if (Array.isArray(children[i])) return flattenChildren(children);
453
- return children;
454
- }
455
- function flattenChildren(children) {
456
- const result = [];
457
- for (const child of children) if (Array.isArray(child)) result.push(...flattenChildren(child));
458
- else result.push(child);
459
- return result;
460
- }
461
-
462
544
  //#endregion
463
545
  //#region src/dynamic.ts
464
546
  const __DEV__$4 = process.env.NODE_ENV !== "production";
@@ -577,7 +659,7 @@ function ErrorBoundary(props) {
577
659
  return true;
578
660
  };
579
661
  pushErrorBoundary(handler);
580
- onUnmount(() => popErrorBoundary());
662
+ onUnmount(() => popErrorBoundary(handler));
581
663
  return () => {
582
664
  const err = error();
583
665
  if (err != null) return props.fallback(err, reset);
@@ -1198,5 +1280,5 @@ function Suspense(props) {
1198
1280
  }
1199
1281
 
1200
1282
  //#endregion
1201
- 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, 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 };
1202
1284
  //# sourceMappingURL=index.js.map
@@ -1,98 +1,4 @@
1
- //#region src/h.ts
2
- /**
3
- * Marker for fragment nodes — renders children without a wrapper element.
4
- *
5
- * MUST use `Symbol.for(...)` (global registry, keyed by string), NOT
6
- * `Symbol(...)` (fresh per evaluation). `h.ts` is inlined into BOTH the
7
- * main `lib/index.js` and the `lib/jsx-runtime.js` published bundles —
8
- * each bundle's evaluation of a bare `Symbol(...)` would produce a
9
- * DISTINCT Symbol identity. JSX `<>` compiles to `jsx(Fragment, ...)` and
10
- * resolves to jsx-runtime's identity; `runtime-server` checks
11
- * `vnode.type === Fragment` against the main-entry identity. Mismatch
12
- * fell through to `renderElement` and crashed SSG with
13
- * `TypeError: Cannot convert a Symbol value to a string`.
14
- * `Symbol.for()` keys by string in a global registry shared across all
15
- * bundle evaluations — same identity everywhere.
16
- */
17
- const Fragment = Symbol.for("Pyreon.Fragment");
18
- /**
19
- * Hyperscript function — the compiled output of JSX.
20
- * `<div class="x">hello</div>` → `h("div", { class: "x" }, "hello")`
21
- *
22
- * Generic on P so TypeScript validates props match the component's signature
23
- * at the call site, then stores the result in the loosely-typed VNode.
24
- */
25
- /** Shared empty props sentinel — identity-checked in mountElement to skip applyProps. */
26
- const EMPTY_PROPS = {};
27
- function h(type, props, ...children) {
28
- return {
29
- type,
30
- props: props ?? EMPTY_PROPS,
31
- children: normalizeChildren(children),
32
- key: props?.key ?? null
33
- };
34
- }
35
- function normalizeChildren(children) {
36
- for (let i = 0; i < children.length; i++) if (Array.isArray(children[i])) return flattenChildren(children);
37
- return children;
38
- }
39
- function flattenChildren(children) {
40
- const result = [];
41
- for (const child of children) if (Array.isArray(child)) result.push(...flattenChildren(child));
42
- else result.push(child);
43
- return result;
44
- }
1
+ import { n as Fragment } from "./_chunks/h-CYSD6aBx.js";
2
+ import { jsx, jsxs } from "./jsx-runtime.js";
45
3
 
46
- //#endregion
47
- //#region src/jsx-runtime.ts
48
- /**
49
- * JSX automatic runtime.
50
- *
51
- * When tsconfig has `"jsxImportSource": "@pyreon/core"`, the TS/bundler compiler
52
- * rewrites JSX to imports from this file automatically:
53
- * <div class="x" /> → jsx("div", { class: "x" })
54
- *
55
- * The triple-slash reference above makes this file self-declare its DOM-lib
56
- * dependency. Without it, any consumer whose tsconfig has `lib: ["ESNext"]`
57
- * (no DOM) — e.g. backend-only packages like @pyreon/cli — fails to typecheck
58
- * once `@pyreon/core` becomes resolvable from their dependency graph (e.g. via
59
- * a transitive devDep), because tsc auto-resolves jsxImportSource and pulls
60
- * jsx-runtime.ts into the consumer's compilation unit.
61
- */
62
- function jsx(type, props, key) {
63
- const descriptors = Object.getOwnPropertyDescriptors(props);
64
- let hasGetter = false;
65
- for (const k in descriptors) if (descriptors[k].get) {
66
- hasGetter = true;
67
- break;
68
- }
69
- const children = props.children;
70
- if (!hasGetter) {
71
- const { children: _ignored, ...rest } = props;
72
- const propsWithKey = key != null ? {
73
- ...rest,
74
- key
75
- } : rest;
76
- if (typeof type === "function") return h(type, children !== void 0 ? {
77
- ...propsWithKey,
78
- children
79
- } : propsWithKey);
80
- return h(type, propsWithKey, ...children === void 0 ? [] : Array.isArray(children) ? children : [children]);
81
- }
82
- const propsWithKey = {};
83
- for (const k in descriptors) {
84
- if (k === "children") continue;
85
- Object.defineProperty(propsWithKey, k, descriptors[k]);
86
- }
87
- if (key != null) propsWithKey.key = key;
88
- if (typeof type === "function") {
89
- if (children !== void 0) propsWithKey.children = children;
90
- return h(type, propsWithKey);
91
- }
92
- return h(type, propsWithKey, ...children === void 0 ? [] : Array.isArray(children) ? children : [children]);
93
- }
94
- const jsxs = jsx;
95
-
96
- //#endregion
97
- export { Fragment, jsx as jsxDEV, jsxs };
98
- //# sourceMappingURL=jsx-dev-runtime.js.map
4
+ export { Fragment, jsx as jsxDEV, jsxs };
@@ -1,49 +1,5 @@
1
- //#region src/h.ts
2
- /**
3
- * Marker for fragment nodes — renders children without a wrapper element.
4
- *
5
- * MUST use `Symbol.for(...)` (global registry, keyed by string), NOT
6
- * `Symbol(...)` (fresh per evaluation). `h.ts` is inlined into BOTH the
7
- * main `lib/index.js` and the `lib/jsx-runtime.js` published bundles —
8
- * each bundle's evaluation of a bare `Symbol(...)` would produce a
9
- * DISTINCT Symbol identity. JSX `<>` compiles to `jsx(Fragment, ...)` and
10
- * resolves to jsx-runtime's identity; `runtime-server` checks
11
- * `vnode.type === Fragment` against the main-entry identity. Mismatch
12
- * fell through to `renderElement` and crashed SSG with
13
- * `TypeError: Cannot convert a Symbol value to a string`.
14
- * `Symbol.for()` keys by string in a global registry shared across all
15
- * bundle evaluations — same identity everywhere.
16
- */
17
- const Fragment = Symbol.for("Pyreon.Fragment");
18
- /**
19
- * Hyperscript function — the compiled output of JSX.
20
- * `<div class="x">hello</div>` → `h("div", { class: "x" }, "hello")`
21
- *
22
- * Generic on P so TypeScript validates props match the component's signature
23
- * at the call site, then stores the result in the loosely-typed VNode.
24
- */
25
- /** Shared empty props sentinel — identity-checked in mountElement to skip applyProps. */
26
- const EMPTY_PROPS = {};
27
- function h(type, props, ...children) {
28
- return {
29
- type,
30
- props: props ?? EMPTY_PROPS,
31
- children: normalizeChildren(children),
32
- key: props?.key ?? null
33
- };
34
- }
35
- function normalizeChildren(children) {
36
- for (let i = 0; i < children.length; i++) if (Array.isArray(children[i])) return flattenChildren(children);
37
- return children;
38
- }
39
- function flattenChildren(children) {
40
- const result = [];
41
- for (const child of children) if (Array.isArray(child)) result.push(...flattenChildren(child));
42
- else result.push(child);
43
- return result;
44
- }
1
+ import { n as Fragment, r as h } from "./_chunks/h-CYSD6aBx.js";
45
2
 
46
- //#endregion
47
3
  //#region src/jsx-runtime.ts
48
4
  /**
49
5
  * JSX automatic runtime.
@@ -247,7 +247,49 @@ declare function createReactiveContext<T>(defaultValue: T): ReactiveContext<T>;
247
247
  */
248
248
  declare function setContextStackProvider(fn: () => Map<symbol, unknown>[]): void;
249
249
  declare function pushContext(values: Map<symbol, unknown>): void;
250
+ /**
251
+ * Pop the LAST frame from the context stack.
252
+ *
253
+ * NOTE: position-based pop. Safe ONLY when the caller can guarantee that the
254
+ * top of the stack is the frame they want to remove (the strict LIFO contract).
255
+ * The `provide()` helper does NOT use this — it uses identity-based removal
256
+ * via `removeContextFrame` because reactive boundaries can push snapshot
257
+ * frames between a component's `provide(ctx, value)` and its eventual
258
+ * unmount, making the top-of-stack unsafe to assume.
259
+ */
250
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;
276
+ /**
277
+ * Remove a SPECIFIC frame from the context stack by reference identity.
278
+ *
279
+ * Internal — used by `provide()` and `withContext()` to safely clean up
280
+ * their pushed frame on unmount even when other frames have been pushed
281
+ * between push and pop (e.g. a reactive boundary's `restoreContextStack`
282
+ * pushing snapshot frames during the descendant's lifecycle). The
283
+ * symmetric position-based `popContext()` would pop the wrong frame in
284
+ * that case and orphan the descendant's provider frame on the live stack
285
+ * — the root cause of the 321k-entry context-stack leak under repeated
286
+ * reactive remounts.
287
+ *
288
+ * Uses `lastIndexOf` (LIFO match) — picks the most-recently-pushed frame
289
+ * with that exact reference, so `provide(ctx, a); provide(ctx, b)` followed
290
+ * by two unmounts removes them in reverse order.
291
+ */
292
+ declare function removeContextFrame(frame: Map<symbol, unknown>): void;
251
293
  /**
252
294
  * Read the nearest provided value for a context.
253
295
  * Falls back to `context.defaultValue` if none found.
@@ -276,12 +318,59 @@ declare function provide<T>(context: Context<T>, value: T): void;
276
318
  declare function withContext<T>(context: Context<T>, value: T, fn: () => void): void;
277
319
  type ContextSnapshot = Map<symbol, unknown>[];
278
320
  /**
279
- * 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.
280
323
  *
281
324
  * Used by `mountReactive` to preserve the context that was active when a
282
325
  * reactive boundary (e.g. `<Show>`, `<For>`) was set up. When the boundary
283
326
  * later mounts new children inside an effect, the snapshot is restored so
284
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).
285
374
  */
286
375
  declare function captureContextStack(): ContextSnapshot;
287
376
  /**
@@ -1416,5 +1505,5 @@ declare function registerErrorHandler(handler: ErrorHandler): () => void;
1416
1505
  */
1417
1506
  declare function reportError(ctx: ErrorContext): void;
1418
1507
  //#endregion
1419
- 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, 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 };
1420
1509
  //# sourceMappingURL=index2.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/core",
3
- "version": "0.22.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.22.0"
56
+ "@pyreon/reactivity": "^0.24.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@pyreon/manifest": "0.13.1"
package/src/component.ts CHANGED
@@ -46,6 +46,22 @@ export function propagateError(err: unknown, hooks: LifecycleHooks): boolean {
46
46
  // Module-level stack of active ErrorBoundary handlers (innermost last).
47
47
  // ErrorBoundary pushes during its own setup (before children mount) so that
48
48
  // any child mountComponent error can dispatch up to the nearest boundary.
49
+ //
50
+ // Mutation contract: removal is IDENTITY-based (`lastIndexOf + splice`), not
51
+ // position-based (`pop`). Sibling boundaries unmount in an order that's
52
+ // driven by the renderer (keyed `<For>` reconciliation, `<Show>` flips,
53
+ // route nav), NOT in strict LIFO push order. A position-based `pop()` would
54
+ // remove the wrong frame whenever the unmount order diverges from the push
55
+ // order — the first boundary's `onUnmount` would pop the last boundary's
56
+ // handler, orphaning the first boundary's handler on the stack and removing
57
+ // the surviving boundary's handler from it. Subsequent errors would then
58
+ // route to the orphan (whose owning boundary's signal is already disposed,
59
+ // so the error vanishes silently) and the surviving boundary's children's
60
+ // errors would fall through to whichever boundary happens to sit at
61
+ // `stack[length-1]`. Same root-cause shape as the `popContext()` bug
62
+ // fixed in #725 for `provide()` — see
63
+ // `.claude/rules/anti-patterns.md` "Position-based pop for stack frames
64
+ // that may be pushed by reactive boundaries".
49
65
 
50
66
  const _errorBoundaryStack: ((err: unknown) => boolean)[] = []
51
67
 
@@ -53,8 +69,23 @@ export function pushErrorBoundary(handler: (err: unknown) => boolean): void {
53
69
  _errorBoundaryStack.push(handler)
54
70
  }
55
71
 
56
- export function popErrorBoundary(): void {
57
- _errorBoundaryStack.pop()
72
+ /**
73
+ * Remove a SPECIFIC handler from the error-boundary stack by reference
74
+ * identity. Each `ErrorBoundary` registers `onUnmount(() => popErrorBoundary(handler))`
75
+ * with its OWN handler — so unmount in any order (LIFO, FIFO, middle-out)
76
+ * correctly removes the right handler.
77
+ */
78
+ export function popErrorBoundary(handler?: (err: unknown) => boolean): void {
79
+ if (handler === undefined) {
80
+ // Back-compat: legacy callers that don't pass a handler get the old
81
+ // pop-last behaviour. Internal `ErrorBoundary` setup always passes
82
+ // its handler now; any external direct callers (tests, advanced
83
+ // consumers) keep working with no-arg form.
84
+ _errorBoundaryStack.pop()
85
+ return
86
+ }
87
+ const idx = _errorBoundaryStack.lastIndexOf(handler)
88
+ if (idx !== -1) _errorBoundaryStack.splice(idx, 1)
58
89
  }
59
90
 
60
91
  /**