@pyreon/runtime-dom 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.
@@ -1,5 +1,5 @@
1
- import { Fragment, createRef, h, nativeCompat, onUnmount } from "@pyreon/core";
2
1
  import { effect, runUntracked, signal } from "@pyreon/reactivity";
2
+ import { Fragment, createRef, h, nativeCompat, onUnmount } from "@pyreon/core";
3
3
 
4
4
  //#region src/transition.ts
5
5
  const __DEV__ = process.env.NODE_ENV !== "production";
@@ -372,6 +372,112 @@ declare function _rsCollapse(html: string, lightClass: string, darkClass: string
372
372
  * sliced source spans `detectPartialCollapsibleShape` returned.
373
373
  */
374
374
  declare function _rsCollapseH(html: string, lightClass: string, darkClass: string, isDark: () => boolean, handlers: Record<string, unknown>, bind?: ((el: HTMLElement) => (() => void) | null) | null): NativeItem;
375
+ /**
376
+ * Compiler-emitted DYNAMIC-prop collapsed rocketstyle call site — PR 1
377
+ * of the dynamic-prop partial-collapse build (next bite after the
378
+ * `on*`-handler partial-collapse `_rsCollapseH`, `.claude/plans/open-work-2026-q3.md`
379
+ * → #1 dynamic-prop bucket = 15.3% of all real-corpus sites).
380
+ *
381
+ * Generalises {@link _rsCollapse}'s 2-class (light/dark) dispatch to an
382
+ * N-class dispatch for sites where one dimension prop is an enumerable
383
+ * dynamic expression (e.g. `<Button state={cond ? 'primary' : 'secondary'}>`).
384
+ * The compiler resolves EVERY value of that prop through the existing
385
+ * SSR-render resolver (so each value gets its own light + dark class
386
+ * baked in, byte-identical to a `_rsCollapse` site for that value), and
387
+ * the runtime picks the right `(value × mode)` class via the user's
388
+ * expression.
389
+ *
390
+ * Class layout in `classes` is **stride-2, value-major**: index
391
+ * `2 * valueIndex + (isDark ? 1 : 0)`. For the canonical ternary case:
392
+ *
393
+ * ```
394
+ * <Button state={cond ? 'primary' : 'secondary'}>Save</Button>
395
+ * →
396
+ * __rsCollapseDyn(
397
+ * "<button>Save</button>",
398
+ * ["btn-primary-light", "btn-primary-dark", "btn-secondary-light", "btn-secondary-dark"],
399
+ * () => cond ? 0 : 1,
400
+ * () => __pyrMode() === "dark"
401
+ * )
402
+ * ```
403
+ *
404
+ * Both the value expression AND the mode accessor are reactive: a change
405
+ * to either re-runs ONLY this className assignment, no remount (same
406
+ * contract as `_rsCollapse`'s mode flip). Both dispatches share a single
407
+ * `_bindDirect` so reading both inside one effect subscribes once per
408
+ * source — Pyreon's effect dedupe handles the rest.
409
+ *
410
+ * The structural HTML template is shared across every value (asserted
411
+ * by the resolver — divergent markup between values bails the collapse).
412
+ * Mirrors `_rsCollapse`'s mode-divergence-bails invariant.
413
+ *
414
+ * `bind` follows the same contract as `_rsCollapse` — standard `_tpl`
415
+ * child/event binder, runs after class binding, disposers chained.
416
+ *
417
+ * @param html static element HTML WITHOUT the root `class=` attr
418
+ * @param classes flat array of `2 × valueCount` class strings,
419
+ * indexed `[v0_light, v0_dark, v1_light, v1_dark, ...]`. The runtime
420
+ * does no validation — the compiler is the source of truth (an
421
+ * out-of-range `valueIndex()` would coerce to `undefined` className,
422
+ * which is correct-for-zero-style — never crashes)
423
+ * @param valueIndex user expression returning 0..valueCount-1 — reactive
424
+ * @param isDark app mode accessor — reactive
425
+ * @param bind standard _tpl binder for children/events (or null)
426
+ */
427
+ declare function _rsCollapseDyn(html: string, classes: readonly string[], valueIndex: () => number, isDark: () => boolean, bind?: ((el: HTMLElement) => (() => void) | null) | null): NativeItem;
428
+ /**
429
+ * Compiler-emitted DYNAMIC-prop + HANDLER collapsed rocketstyle call
430
+ * site — closes the largest remaining real-corpus dynamic-collapse
431
+ * gap (`.claude/plans/open-work-2026-q3.md` → #1 dynamic-prop bucket
432
+ * = 15.4% of all real-corpus sites; the strict no-handler subset was
433
+ * only 0.2% measured; this helper unlocks the handler-combined slice
434
+ * that was bailed by `tryDynamicCollapse` in PR #767 by design).
435
+ *
436
+ * Combines {@link _rsCollapseDyn}'s value-major class dispatch with
437
+ * {@link _rsCollapseH}'s handler re-attachment. Handlers are orthogonal
438
+ * to both the SSR-resolved styler class AND the value dispatcher (a
439
+ * `state={cond ? 'a' : 'b'} onClick={h}` site's onClick is identical
440
+ * for both `state="a"` and `state="b"` resolutions — the styler class
441
+ * varies, the handler does not). So this helper is structurally the
442
+ * union of the two, no new behavior:
443
+ *
444
+ * ```
445
+ * <Button state={cond ? 'primary' : 'secondary'} onClick={go}>Save</Button>
446
+ * →
447
+ * __rsCollapseDynH(
448
+ * "<button>Save</button>",
449
+ * ["pri-L", "pri-D", "sec-L", "sec-D"],
450
+ * () => cond ? 0 : 1,
451
+ * () => __pyrMode() === "dark",
452
+ * { onClick: go }
453
+ * )
454
+ * ```
455
+ *
456
+ * Class layout matches `_rsCollapseDyn` (stride-2 value-major):
457
+ * `index = 2 * valueIndex + (isDark ? 1 : 0)`. Handler attachment
458
+ * matches `_rsCollapseH` — routed through the canonical `_bindEvent`
459
+ * → `applyEventProp` path (delegation + batching + name
460
+ * normalization). All three reactives (valueIndex, isDark, handlers
461
+ * — though handler identity is captured at the call site) compose
462
+ * cleanly: a value flip OR a mode flip patches className IN PLACE
463
+ * on the SAME node, handlers stay attached across both.
464
+ *
465
+ * Layer-pure: no styler / ui-core imports (the styler injection is
466
+ * the emitted code's job via `__rsSheet.injectRules`).
467
+ *
468
+ * @param html static element HTML WITHOUT the root `class=` attr
469
+ * @param classes flat array of `2 × valueCount` class strings, indexed
470
+ * `[v0_L, v0_D, v1_L, v1_D, …]`
471
+ * @param valueIndex user expression returning 0..valueCount-1 — reactive
472
+ * @param isDark app mode accessor — reactive
473
+ * @param handlers `{ onClick: fn, onPointerEnter: fn, … }` — the
474
+ * residual handlers peeled off the call site by the
475
+ * compiler's emit (sliced source spans re-emitted
476
+ * verbatim, paren-wrapped to keep arrow / sequence
477
+ * expressions a single value)
478
+ * @param bind standard _tpl binder for children/events (or null)
479
+ */
480
+ declare function _rsCollapseDynH(html: string, classes: readonly string[], valueIndex: () => number, isDark: () => boolean, handlers: Record<string, unknown>, bind?: ((el: HTMLElement) => (() => void) | null) | null): NativeItem;
375
481
  /**
376
482
  * Mount a children slot inside a template.
377
483
  *
@@ -513,5 +619,5 @@ declare function mount(root: VNodeChild, container: Element): () => void;
513
619
  /** Alias for `mount` */
514
620
  declare const render: typeof mount;
515
621
  //#endregion
516
- export { DELEGATED_EVENTS, type DevtoolsComponentEntry, type HydrationMismatchContext, type HydrationMismatchHandler, type HydrationMismatchType, KeepAlive, type KeepAliveProps, type PyreonDevtools, type SanitizeFn, Transition, TransitionGroup, type TransitionGroupProps, type TransitionProps, applyProps as _applyProps, applyProps, _bindDirect, _bindText, _mountSlot, _rsCollapse, _rsCollapseH, _tpl, applyProp, createTemplate, delegatedPropName, disableHydrationWarnings, enableHydrationWarnings, hydrateRoot, mount, mountChild, onHydrationMismatch, render, sanitizeHtml, setSanitizer, setupDelegation };
622
+ export { DELEGATED_EVENTS, type DevtoolsComponentEntry, type HydrationMismatchContext, type HydrationMismatchHandler, type HydrationMismatchType, KeepAlive, type KeepAliveProps, type PyreonDevtools, type SanitizeFn, Transition, TransitionGroup, type TransitionGroupProps, type TransitionProps, applyProps as _applyProps, applyProps, _bindDirect, _bindText, _mountSlot, _rsCollapse, _rsCollapseDyn, _rsCollapseDynH, _rsCollapseH, _tpl, applyProp, createTemplate, delegatedPropName, disableHydrationWarnings, enableHydrationWarnings, hydrateRoot, mount, mountChild, onHydrationMismatch, render, sanitizeHtml, setSanitizer, setupDelegation };
517
623
  //# sourceMappingURL=index2.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/runtime-dom",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "DOM renderer for Pyreon",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/runtime-dom#readme",
6
6
  "bugs": {
@@ -54,15 +54,15 @@
54
54
  "prepublishOnly": "bun run build"
55
55
  },
56
56
  "dependencies": {
57
- "@pyreon/core": "^0.22.0",
58
- "@pyreon/reactivity": "^0.22.0"
57
+ "@pyreon/core": "^0.24.0",
58
+ "@pyreon/reactivity": "^0.24.0"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@happy-dom/global-registrator": "^20.8.9",
62
- "@pyreon/compiler": "^0.22.0",
62
+ "@pyreon/compiler": "^0.24.0",
63
63
  "@pyreon/manifest": "0.13.1",
64
- "@pyreon/runtime-server": "^0.22.0",
65
- "@pyreon/test-utils": "^0.13.9",
64
+ "@pyreon/runtime-server": "^0.24.0",
65
+ "@pyreon/test-utils": "^0.13.11",
66
66
  "@vitest/browser-playwright": "^4.1.4",
67
67
  "esbuild": "^0.28.0",
68
68
  "happy-dom": "^20.8.3",
package/src/index.ts CHANGED
@@ -29,6 +29,8 @@ export {
29
29
  _bindText,
30
30
  _mountSlot,
31
31
  _rsCollapse,
32
+ _rsCollapseDyn,
33
+ _rsCollapseDynH,
32
34
  _rsCollapseH,
33
35
  _tpl,
34
36
  createTemplate,
package/src/nodes.ts CHANGED
@@ -100,8 +100,18 @@ export function mountReactive(
100
100
  // Child components set up their OWN effects for reactivity
101
101
  // (e.g. DynamicStyled's class swap effect). Those effects track
102
102
  // their own dependencies independently.
103
+ //
104
+ // Use the marker's LIVE parent (not the closure-captured `parent`):
105
+ // when this mountReactive was created inside a DocumentFragment that
106
+ // mountFor later moved into the live tree via `insertBefore(frag, ...)`,
107
+ // the captured `parent` becomes a stale reference to the now-empty
108
+ // fragment. The marker, in contrast, was moved with the fragment's
109
+ // contents and `marker.parentNode` reflects the current live parent.
110
+ // Falling back to the captured `parent` only when the marker is
111
+ // detached (cleanup edge case) preserves prior behavior.
112
+ const liveParent = marker.parentNode ?? parent
103
113
  const cleanup = runUntracked(() =>
104
- restoreContextStack(contextSnapshot, () => mount(value, parent, marker)),
114
+ restoreContextStack(contextSnapshot, () => mount(value, liveParent, marker)),
105
115
  )
106
116
  // Guard: a re-entrant signal update (e.g. ErrorBoundary catching a child
107
117
  // throw) may have already re-run this effect and updated currentCleanup.
@@ -292,15 +302,15 @@ export function mountKeyedList(
292
302
  }
293
303
  }
294
304
 
295
- const mountNewEntries = (newList: VNode[]) => {
305
+ const mountNewEntries = (newList: VNode[], liveParent: Node) => {
296
306
  for (const vnode of newList) {
297
307
  const key = vnode.key
298
308
  if (key === null || key === undefined) continue
299
309
  if (cache.has(key)) continue
300
310
  const anchor = document.createComment('')
301
311
  _keyedAnchors.add(anchor)
302
- parent.insertBefore(anchor, tailMarker)
303
- const cleanup = mountVNode(vnode, parent, tailMarker)
312
+ liveParent.insertBefore(anchor, tailMarker)
313
+ const cleanup = mountVNode(vnode, liveParent, tailMarker)
304
314
  cache.set(key, { anchor, cleanup })
305
315
  }
306
316
  }
@@ -311,6 +321,17 @@ export function mountKeyedList(
311
321
  // Same untracking rationale as mountFor — see comment there. Child
312
322
  // mounts via mountVNode must not re-track on this effect's run.
313
323
  runUntracked(() => {
324
+ // Use the marker's LIVE parent (not the closure-captured `parent`).
325
+ // Same bug class fixed in #776 for mountReactive: when this
326
+ // mountKeyedList was created inside a DocumentFragment that mountFor
327
+ // later moved via `liveParent.insertBefore(frag, tailMarker)`, the
328
+ // captured `parent` becomes a stale reference to the now-empty
329
+ // fragment. The markers were moved with the fragment's contents
330
+ // and their `parentNode` reflects the current live parent.
331
+ // Fallback to the captured `parent` only when the marker is
332
+ // detached (cleanup edge case) preserves prior behavior.
333
+ const liveParent = tailMarker.parentNode ?? parent
334
+
314
335
  if (n === 0 && cache.size > 0) {
315
336
  for (const entry of cache.values()) {
316
337
  _emitCleanup()
@@ -325,10 +346,10 @@ export function mountKeyedList(
325
346
 
326
347
  const { newKeyOrder, newKeySet } = collectKeyOrder(newList)
327
348
  removeStaleEntries(newKeySet)
328
- mountNewEntries(newList)
349
+ mountNewEntries(newList, liveParent)
329
350
 
330
351
  if (currentKeyOrder.length > 0 && n > 0) {
331
- lis = keyedListReorder(lis, n, newKeyOrder, curPos, cache, parent, tailMarker)
352
+ lis = keyedListReorder(lis, n, newKeyOrder, curPos, cache, liveParent, tailMarker)
332
353
  }
333
354
 
334
355
  curPos.clear()
package/src/template.ts CHANGED
@@ -280,7 +280,192 @@ export function _rsCollapseH(
280
280
  el.className = v ? darkClass : lightClass
281
281
  })
282
282
  const handlerDisposers: (() => void)[] = []
283
- for (const key in handlers) {
283
+ // `Object.keys` (not `for...in`) so an attacker who pollutes
284
+ // `Object.prototype` can't inject a fake handler via inherited
285
+ // enumerable properties. Defense-in-depth — the compiler emits a
286
+ // clean object literal so this matters defensively, not in
287
+ // practice, but the cost is zero.
288
+ for (const key of Object.keys(handlers)) {
289
+ const d = _bindEvent(el, key, handlers[key])
290
+ if (d) handlerDisposers.push(d)
291
+ }
292
+ const disposeChildren = bind ? bind(el) : null
293
+ return () => {
294
+ disposeClass()
295
+ for (const d of handlerDisposers) d()
296
+ if (disposeChildren) disposeChildren()
297
+ }
298
+ })
299
+ }
300
+
301
+ /**
302
+ * Compiler-emitted DYNAMIC-prop collapsed rocketstyle call site — PR 1
303
+ * of the dynamic-prop partial-collapse build (next bite after the
304
+ * `on*`-handler partial-collapse `_rsCollapseH`, `.claude/plans/open-work-2026-q3.md`
305
+ * → #1 dynamic-prop bucket = 15.3% of all real-corpus sites).
306
+ *
307
+ * Generalises {@link _rsCollapse}'s 2-class (light/dark) dispatch to an
308
+ * N-class dispatch for sites where one dimension prop is an enumerable
309
+ * dynamic expression (e.g. `<Button state={cond ? 'primary' : 'secondary'}>`).
310
+ * The compiler resolves EVERY value of that prop through the existing
311
+ * SSR-render resolver (so each value gets its own light + dark class
312
+ * baked in, byte-identical to a `_rsCollapse` site for that value), and
313
+ * the runtime picks the right `(value × mode)` class via the user's
314
+ * expression.
315
+ *
316
+ * Class layout in `classes` is **stride-2, value-major**: index
317
+ * `2 * valueIndex + (isDark ? 1 : 0)`. For the canonical ternary case:
318
+ *
319
+ * ```
320
+ * <Button state={cond ? 'primary' : 'secondary'}>Save</Button>
321
+ * →
322
+ * __rsCollapseDyn(
323
+ * "<button>Save</button>",
324
+ * ["btn-primary-light", "btn-primary-dark", "btn-secondary-light", "btn-secondary-dark"],
325
+ * () => cond ? 0 : 1,
326
+ * () => __pyrMode() === "dark"
327
+ * )
328
+ * ```
329
+ *
330
+ * Both the value expression AND the mode accessor are reactive: a change
331
+ * to either re-runs ONLY this className assignment, no remount (same
332
+ * contract as `_rsCollapse`'s mode flip). Both dispatches share a single
333
+ * `_bindDirect` so reading both inside one effect subscribes once per
334
+ * source — Pyreon's effect dedupe handles the rest.
335
+ *
336
+ * The structural HTML template is shared across every value (asserted
337
+ * by the resolver — divergent markup between values bails the collapse).
338
+ * Mirrors `_rsCollapse`'s mode-divergence-bails invariant.
339
+ *
340
+ * `bind` follows the same contract as `_rsCollapse` — standard `_tpl`
341
+ * child/event binder, runs after class binding, disposers chained.
342
+ *
343
+ * @param html static element HTML WITHOUT the root `class=` attr
344
+ * @param classes flat array of `2 × valueCount` class strings,
345
+ * indexed `[v0_light, v0_dark, v1_light, v1_dark, ...]`. The runtime
346
+ * does no validation — the compiler is the source of truth (an
347
+ * out-of-range `valueIndex()` would coerce to `undefined` className,
348
+ * which is correct-for-zero-style — never crashes)
349
+ * @param valueIndex user expression returning 0..valueCount-1 — reactive
350
+ * @param isDark app mode accessor — reactive
351
+ * @param bind standard _tpl binder for children/events (or null)
352
+ */
353
+ export function _rsCollapseDyn(
354
+ html: string,
355
+ classes: readonly string[],
356
+ valueIndex: () => number,
357
+ isDark: () => boolean,
358
+ bind?: ((el: HTMLElement) => (() => void) | null) | null,
359
+ ): NativeItem {
360
+ return _tpl(html, (el) => {
361
+ // One `renderEffect` drives the className from both accessors;
362
+ // reading `valueIndex()` AND `isDark()` inside the callback
363
+ // subscribes to BOTH live signals via Pyreon's tracking — a change
364
+ // to EITHER re-runs only this className assignment, no remount.
365
+ //
366
+ // Direct `renderEffect` (vs the `_bindDirect` indirection used by
367
+ // `_rsCollapse`): the `_bindDirect` fallback path calls the source
368
+ // function ONCE per re-run and passes the result to the callback.
369
+ // We were ignoring that result and calling `valueIndex()` again
370
+ // inside — i.e., a double call per re-run. Side-effecting cond
371
+ // expressions (`{(modifyState(), cond) ? 'a' : 'b'}`) would fire
372
+ // their side-effects twice. Direct `renderEffect` calls
373
+ // `valueIndex()` exactly once per re-run, matching the original
374
+ // source's call-count contract.
375
+ const disposeClass = renderEffect(() => {
376
+ const idx = (valueIndex() << 1) | (isDark() ? 1 : 0)
377
+ el.className = classes[idx] ?? ''
378
+ })
379
+ const disposeChildren = bind ? bind(el) : null
380
+ if (!disposeChildren) return disposeClass
381
+ return () => {
382
+ disposeClass()
383
+ disposeChildren()
384
+ }
385
+ })
386
+ }
387
+
388
+ /**
389
+ * Compiler-emitted DYNAMIC-prop + HANDLER collapsed rocketstyle call
390
+ * site — closes the largest remaining real-corpus dynamic-collapse
391
+ * gap (`.claude/plans/open-work-2026-q3.md` → #1 dynamic-prop bucket
392
+ * = 15.4% of all real-corpus sites; the strict no-handler subset was
393
+ * only 0.2% measured; this helper unlocks the handler-combined slice
394
+ * that was bailed by `tryDynamicCollapse` in PR #767 by design).
395
+ *
396
+ * Combines {@link _rsCollapseDyn}'s value-major class dispatch with
397
+ * {@link _rsCollapseH}'s handler re-attachment. Handlers are orthogonal
398
+ * to both the SSR-resolved styler class AND the value dispatcher (a
399
+ * `state={cond ? 'a' : 'b'} onClick={h}` site's onClick is identical
400
+ * for both `state="a"` and `state="b"` resolutions — the styler class
401
+ * varies, the handler does not). So this helper is structurally the
402
+ * union of the two, no new behavior:
403
+ *
404
+ * ```
405
+ * <Button state={cond ? 'primary' : 'secondary'} onClick={go}>Save</Button>
406
+ * →
407
+ * __rsCollapseDynH(
408
+ * "<button>Save</button>",
409
+ * ["pri-L", "pri-D", "sec-L", "sec-D"],
410
+ * () => cond ? 0 : 1,
411
+ * () => __pyrMode() === "dark",
412
+ * { onClick: go }
413
+ * )
414
+ * ```
415
+ *
416
+ * Class layout matches `_rsCollapseDyn` (stride-2 value-major):
417
+ * `index = 2 * valueIndex + (isDark ? 1 : 0)`. Handler attachment
418
+ * matches `_rsCollapseH` — routed through the canonical `_bindEvent`
419
+ * → `applyEventProp` path (delegation + batching + name
420
+ * normalization). All three reactives (valueIndex, isDark, handlers
421
+ * — though handler identity is captured at the call site) compose
422
+ * cleanly: a value flip OR a mode flip patches className IN PLACE
423
+ * on the SAME node, handlers stay attached across both.
424
+ *
425
+ * Layer-pure: no styler / ui-core imports (the styler injection is
426
+ * the emitted code's job via `__rsSheet.injectRules`).
427
+ *
428
+ * @param html static element HTML WITHOUT the root `class=` attr
429
+ * @param classes flat array of `2 × valueCount` class strings, indexed
430
+ * `[v0_L, v0_D, v1_L, v1_D, …]`
431
+ * @param valueIndex user expression returning 0..valueCount-1 — reactive
432
+ * @param isDark app mode accessor — reactive
433
+ * @param handlers `{ onClick: fn, onPointerEnter: fn, … }` — the
434
+ * residual handlers peeled off the call site by the
435
+ * compiler's emit (sliced source spans re-emitted
436
+ * verbatim, paren-wrapped to keep arrow / sequence
437
+ * expressions a single value)
438
+ * @param bind standard _tpl binder for children/events (or null)
439
+ */
440
+ export function _rsCollapseDynH(
441
+ html: string,
442
+ classes: readonly string[],
443
+ valueIndex: () => number,
444
+ isDark: () => boolean,
445
+ handlers: Record<string, unknown>,
446
+ bind?: ((el: HTMLElement) => (() => void) | null) | null,
447
+ ): NativeItem {
448
+ return _tpl(html, (el) => {
449
+ // Reactive class — identical shape to `_rsCollapseDyn`: one
450
+ // `renderEffect` reads both accessors, subscribing to both signals;
451
+ // a change to EITHER re-runs only this className assignment, no
452
+ // remount. Direct `renderEffect` (not via `_bindDirect`) so
453
+ // `valueIndex()` runs exactly once per re-run — see the
454
+ // corresponding comment in `_rsCollapseDyn`.
455
+ const disposeClass = renderEffect(() => {
456
+ const idx = (valueIndex() << 1) | (isDark() ? 1 : 0)
457
+ el.className = classes[idx] ?? ''
458
+ })
459
+ // Handler attachment — identical to `_rsCollapseH`: routes through
460
+ // the canonical `_bindEvent` path so delegation / batching / name
461
+ // normalization behave byte-identically to the 5-layer mount.
462
+ // `Object.keys` (not `for...in`) so an attacker who pollutes
463
+ // `Object.prototype` can't inject a fake handler via inherited
464
+ // enumerable properties — only OWN keys count. The compiler emits
465
+ // a clean object literal so this matters defensively, not in
466
+ // practice, but the cost is zero.
467
+ const handlerDisposers: (() => void)[] = []
468
+ for (const key of Object.keys(handlers)) {
284
469
  const d = _bindEvent(el, key, handlers[key])
285
470
  if (d) handlerDisposers.push(d)
286
471
  }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * REGRESSION: context stack does not grow unboundedly under repeated reactive
3
+ * remounts.
4
+ *
5
+ * User-reported symptom (`@pyreon/core@<=0.22.0`):
6
+ * 1 GB heap; 33 effect snapshots × ~10,000 frames each; live context stack
7
+ * contained 321,024 entries but only 47 distinct provider Map instances.
8
+ * The same handful of providers were re-referenced thousands of times each.
9
+ *
10
+ * Root cause:
11
+ * `mountReactive`'s effect re-fire flow runs the previous-mount subtree
12
+ * cleanup INSIDE the effect's snapshot-restore window. The descendant's
13
+ * `onUnmount` calls `popContext()` (position-based, `stack.pop()`) — but
14
+ * the top of the stack at that moment is the snapshot-pushed frame, NOT
15
+ * the descendant's own provider frame. `popContext()` pops the snapshot
16
+ * frame; the descendant's frame is orphaned on the live stack. Geometric
17
+ * amplification across nested reactive boundaries × repeated toggles
18
+ * produces the 321k-frame state.
19
+ *
20
+ * Fix: `provide()` registers `onUnmount(removeContextFrame(frame))` — an
21
+ * identity-based splice that finds the specific frame regardless of its
22
+ * position on the stack.
23
+ */
24
+ import { captureContextStack, createContext, h, provide, useContext } from '@pyreon/core'
25
+ import { signal } from '@pyreon/reactivity'
26
+ import { describe, expect, it } from 'vitest'
27
+ import { mount } from '..'
28
+
29
+ describe('Context stack — growth under repeated remounts', () => {
30
+ it('single reactive boundary cycling a Provider — stack stays bounded', () => {
31
+ const Ctx = createContext<string>('root')
32
+ const container = document.createElement('div')
33
+
34
+ const baseLen = captureContextStack().length
35
+ const cond = signal(true)
36
+
37
+ function InnerProvider() {
38
+ provide(Ctx, 'inner')
39
+ return h('span', null, useContext(Ctx))
40
+ }
41
+
42
+ const App = () =>
43
+ h('div', null, () => (cond() ? h(InnerProvider, null) : null))
44
+
45
+ const unmount = mount(h(App, null), container)
46
+
47
+ for (let i = 0; i < 1000; i++) {
48
+ cond.set(false)
49
+ cond.set(true)
50
+ }
51
+
52
+ const finalLen = captureContextStack().length
53
+ expect(finalLen - baseLen).toBeLessThan(10)
54
+
55
+ unmount()
56
+ })
57
+
58
+ it('REGRESSION: nested reactive boundaries with providers — no orphan frames', () => {
59
+ // The exact shape that produced the 321k-entry live stack in 0.22.0:
60
+ // two NESTED reactive boundaries, each containing a provider. The
61
+ // outer's cleanup chain unmounts the inner; the inner's provider's
62
+ // onUnmount popContext used to pop the wrong (snapshot) frame, orphaning
63
+ // the provider's frame on the live stack.
64
+ const A = createContext<string>('A_default')
65
+ const B = createContext<string>('B_default')
66
+ const container = document.createElement('div')
67
+ const baseLen = captureContextStack().length
68
+
69
+ const toggleA = signal(true)
70
+ const toggleB = signal(true)
71
+
72
+ function PA() {
73
+ provide(A, 'A_value')
74
+ return h('div', null, () => (toggleB() ? h(PB, null) : null))
75
+ }
76
+ function PB() {
77
+ provide(B, 'B_value')
78
+ return h('span', null, `${useContext(A)}/${useContext(B)}`)
79
+ }
80
+
81
+ const App = () =>
82
+ h('div', null, () => (toggleA() ? h(PA, null) : null))
83
+
84
+ const unmount = mount(h(App, null), container)
85
+
86
+ // 500 full cycles. Without the fix, the stack grows ~1 frame per cycle
87
+ // (502 after 500 iterations of toggleB/toggleA off/on).
88
+ for (let i = 0; i < 500; i++) {
89
+ toggleB.set(false)
90
+ toggleB.set(true)
91
+ toggleA.set(false)
92
+ toggleA.set(true)
93
+ }
94
+
95
+ const finalLen = captureContextStack().length
96
+ expect(finalLen - baseLen).toBeLessThan(10)
97
+
98
+ unmount()
99
+ })
100
+
101
+ it('signal-driven re-mount of a provider — stack stays bounded across many updates', () => {
102
+ const Ctx = createContext<string>('root')
103
+ const container = document.createElement('div')
104
+ const baseLen = captureContextStack().length
105
+ const inner = signal('a')
106
+
107
+ function InnerProvider() {
108
+ provide(Ctx, inner())
109
+ return h('span', null, useContext(Ctx))
110
+ }
111
+
112
+ const App = () => h('div', null, () => h(InnerProvider, null))
113
+ const unmount = mount(h(App, null), container)
114
+
115
+ for (let i = 0; i < 2000; i++) inner.set(`v${i}`)
116
+
117
+ const finalLen = captureContextStack().length
118
+ expect(finalLen - baseLen).toBeLessThan(10)
119
+
120
+ unmount()
121
+ })
122
+
123
+ it('contextSnapshot used in restoreContextStack still finds inherited providers post-remount', () => {
124
+ // Read-side correctness: the snapshot mechanism's whole point is that
125
+ // useContext from a descendant inside a reactive boundary still finds
126
+ // the ancestor provider. The fix must not break this.
127
+ const Ctx = createContext<string>('root')
128
+ const container = document.createElement('div')
129
+ const cond = signal(true)
130
+ const seen: string[] = []
131
+
132
+ function Reader() {
133
+ seen.push(useContext(Ctx))
134
+ return h('span', null, useContext(Ctx))
135
+ }
136
+
137
+ function Provider() {
138
+ provide(Ctx, 'inherited')
139
+ return h('div', null, () => (cond() ? h(Reader, null) : null))
140
+ }
141
+
142
+ const unmount = mount(h(Provider, null), container)
143
+
144
+ // Initial render must see 'inherited'
145
+ expect(seen[seen.length - 1]).toBe('inherited')
146
+
147
+ // Toggle a few times — every re-mount of Reader must see the inherited
148
+ // value, NOT the default 'root'.
149
+ for (let i = 0; i < 10; i++) {
150
+ cond.set(false)
151
+ cond.set(true)
152
+ }
153
+ // The most recent mount also saw inherited
154
+ expect(seen[seen.length - 1]).toBe('inherited')
155
+
156
+ unmount()
157
+ })
158
+ })