@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.
- package/README.md +115 -91
- package/lib/_chunks/keep-alive-DznjF_h1.js +1659 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +145 -1823
- package/lib/keep-alive-entry.js +2 -1380
- package/lib/transition-entry.js +1 -1
- package/lib/types/index.d.ts +107 -1
- package/package.json +6 -6
- package/src/index.ts +2 -0
- package/src/nodes.ts +27 -6
- package/src/template.ts +186 -1
- package/src/tests/ctx-stack-growth-repro.test.tsx +158 -0
- package/src/tests/error-boundary-stack-leak-repro.test.tsx +133 -0
- package/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts +140 -0
- package/src/tests/rs-collapse-dyn-h.browser.test.ts +303 -0
- package/src/tests/rs-collapse-dyn.browser.test.ts +316 -0
- package/src/tests/show-of-for-batched-toggle.browser.test.ts +122 -0
- package/lib/analysis/keep-alive-entry.js.html +0 -5406
- package/lib/analysis/transition-entry.js.html +0 -5406
package/lib/transition-entry.js
CHANGED
|
@@ -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";
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
58
|
-
"@pyreon/reactivity": "^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.
|
|
62
|
+
"@pyreon/compiler": "^0.24.0",
|
|
63
63
|
"@pyreon/manifest": "0.13.1",
|
|
64
|
-
"@pyreon/runtime-server": "^0.
|
|
65
|
-
"@pyreon/test-utils": "^0.13.
|
|
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
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,
|
|
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
|
-
|
|
303
|
-
const cleanup = mountVNode(vnode,
|
|
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,
|
|
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
|
-
|
|
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
|
+
})
|