@pyreon/runtime-dom 0.24.5 → 0.24.6

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.
Files changed (53) hide show
  1. package/package.json +5 -9
  2. package/src/delegate.ts +0 -98
  3. package/src/devtools.ts +0 -339
  4. package/src/env.d.ts +0 -6
  5. package/src/hydrate.ts +0 -450
  6. package/src/hydration-debug.ts +0 -129
  7. package/src/index.ts +0 -83
  8. package/src/keep-alive-entry.ts +0 -3
  9. package/src/keep-alive.ts +0 -83
  10. package/src/manifest.ts +0 -236
  11. package/src/mount.ts +0 -597
  12. package/src/nodes.ts +0 -896
  13. package/src/props.ts +0 -474
  14. package/src/template.ts +0 -523
  15. package/src/tests/callback-ref-unmount.browser.test.ts +0 -62
  16. package/src/tests/callback-ref-unmount.test.ts +0 -52
  17. package/src/tests/compiler-integration.test.tsx +0 -508
  18. package/src/tests/coverage-gaps.test.ts +0 -3183
  19. package/src/tests/coverage.test.ts +0 -1140
  20. package/src/tests/ctx-stack-growth-repro.test.tsx +0 -158
  21. package/src/tests/dev-gate-pattern.test.ts +0 -46
  22. package/src/tests/dev-gate-treeshake.test.ts +0 -256
  23. package/src/tests/error-boundary-stack-leak-repro.test.tsx +0 -133
  24. package/src/tests/fanout-repro.test.tsx +0 -219
  25. package/src/tests/hydration-integration.test.tsx +0 -540
  26. package/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts +0 -140
  27. package/src/tests/lifecycle-integration.test.tsx +0 -342
  28. package/src/tests/lis-prepend.browser.test.ts +0 -99
  29. package/src/tests/manifest-snapshot.test.ts +0 -85
  30. package/src/tests/mount.test.ts +0 -3529
  31. package/src/tests/native-markers.test.ts +0 -19
  32. package/src/tests/props.test.ts +0 -581
  33. package/src/tests/reactive-props.test.ts +0 -270
  34. package/src/tests/real-world-integration.test.tsx +0 -714
  35. package/src/tests/rs-collapse-dyn-h.browser.test.ts +0 -303
  36. package/src/tests/rs-collapse-dyn.browser.test.ts +0 -316
  37. package/src/tests/rs-collapse-h.browser.test.ts +0 -152
  38. package/src/tests/rs-collapse-h.test.ts +0 -237
  39. package/src/tests/rs-collapse.browser.test.ts +0 -128
  40. package/src/tests/runtime-dom.browser.test.ts +0 -409
  41. package/src/tests/setup.ts +0 -3
  42. package/src/tests/show-context.test.ts +0 -270
  43. package/src/tests/show-of-for-batched-toggle.browser.test.ts +0 -122
  44. package/src/tests/ssr-xss-round-trip.browser.test.ts +0 -93
  45. package/src/tests/style-key-removal.browser.test.ts +0 -54
  46. package/src/tests/style-key-removal.test.ts +0 -88
  47. package/src/tests/template.test.ts +0 -383
  48. package/src/tests/transition-timeout-leak.test.ts +0 -126
  49. package/src/tests/transition.test.ts +0 -568
  50. package/src/tests/verified-correct-probes.test.ts +0 -56
  51. package/src/transition-entry.ts +0 -7
  52. package/src/transition-group.ts +0 -350
  53. package/src/transition.ts +0 -245
package/src/template.ts DELETED
@@ -1,523 +0,0 @@
1
- import type { NativeItem, VNodeChild } from '@pyreon/core'
2
- import { renderEffect } from '@pyreon/reactivity'
3
- import { mountChild } from './mount'
4
- import { _bindEvent } from './props'
5
-
6
- // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
7
- // uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
8
- const __DEV__ = process.env.NODE_ENV !== 'production'
9
-
10
- // Dev-time counter sink — see packages/internals/perf-harness for contract.
11
- const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
12
-
13
- /**
14
- * Creates a row/item factory backed by HTML template cloning.
15
- *
16
- * - The HTML string is parsed exactly once via <template>.innerHTML.
17
- * - Each call to the returned factory clones the root element via
18
- * cloneNode(true) — ~5-10x faster than createElement + setAttribute.
19
- * - `bind` receives the cloned element and the item; it should wire up
20
- * reactive effects and return a cleanup function.
21
- * - Returns a NativeItem directly (no VNode wrapper) — saves 2 allocations
22
- * per row vs the old VNode + props-object + children-array approach.
23
- *
24
- * @example
25
- * const rowTemplate = createTemplate<Row>(
26
- * "<tr><td></td><td></td></tr>",
27
- * (el, row) => {
28
- * const td1 = el.firstChild as HTMLElement
29
- * const td2 = td1.nextSibling as HTMLElement
30
- * td1.textContent = String(row.id)
31
- * const text = td2.firstChild as Text
32
- * text.data = row.label()
33
- * const unsub = row.label.subscribe(() => { text.data = row.label() })
34
- * return unsub
35
- * }
36
- * )
37
- */
38
- export function createTemplate<T>(
39
- html: string,
40
- bind: (el: HTMLElement, item: T) => (() => void) | null,
41
- ): (item: T) => NativeItem {
42
- const tmpl = document.createElement('template')
43
- tmpl.innerHTML = html
44
- const proto = tmpl.content.firstElementChild as HTMLElement
45
-
46
- return (item: T): NativeItem => {
47
- const el = proto.cloneNode(true) as HTMLElement
48
- const cleanup = bind(el, item)
49
- return { __isNative: true, el, cleanup }
50
- }
51
- }
52
-
53
- // ─── Direct text binding (bypasses effect system) ────────────────────────────
54
-
55
- /**
56
- * Compiler-emitted direct text binding for single-signal text nodes.
57
- *
58
- * When the compiler detects `{signal()}` as the only reactive expression
59
- * in a text binding, it emits `_bindText(signal, textNode)` instead of
60
- * `_bind(() => { textNode.data = signal() })`.
61
- *
62
- * This bypasses the effect system entirely:
63
- * - No deps array allocation
64
- * - No withTracking / setDepsCollector overhead
65
- * - No `run` closure
66
- * - Signal.subscribe is used directly (O(1) subscribe + unsubscribe)
67
- *
68
- * @param source - A signal (anything with `._v` and `.direct`)
69
- * @param node - The Text node to update
70
- */
71
- export function _bindText(
72
- source: { _v?: unknown; direct?: (fn: () => void) => () => void },
73
- node: Text,
74
- ): () => void {
75
- if (__DEV__) _countSink.__pyreon_count__?.('runtime.bindText')
76
- // Fast path: source has .direct() (signal or computed)
77
- if (source.direct) {
78
- const textUpdate = () => {
79
- const v = source._v
80
- const next = v == null || v === false ? '' : String(v as string | number)
81
- if (next !== node.data) node.data = next
82
- }
83
- textUpdate()
84
- return source.direct(textUpdate)
85
- }
86
- // Fallback: source is a plain callable (e.g. store getter, createMachine) — use renderEffect
87
- const fn = source as unknown as () => unknown
88
- return renderEffect(() => {
89
- const v = fn()
90
- const next = v == null || v === false ? '' : String(v as string | number)
91
- if (next !== node.data) node.data = next
92
- })
93
- }
94
-
95
- // ─── Direct signal binding (bypasses effect system) ──────────────────────────
96
-
97
- /**
98
- * Compiler-emitted direct binding for single-signal reactive expressions.
99
- *
100
- * Like _bindText but for arbitrary DOM updates (attributes, className, style).
101
- * When the compiler detects that a reactive expression depends on exactly one
102
- * signal call, it emits `_bindDirect(signal, updater)` instead of
103
- * `_bind(() => { updater() })`.
104
- *
105
- * Uses signal.direct() for zero-overhead registration:
106
- * - Flat array instead of Set (no hashing)
107
- * - Index-based disposal (no Set.delete)
108
- * - No deps array, no withTracking, no run closure
109
- *
110
- * @param source - A signal (anything with `._v` and `.direct`)
111
- * @param updater - Function that reads `source._v` and applies the DOM update
112
- */
113
- export function _bindDirect(
114
- source: { _v?: unknown; direct?: (fn: () => void) => () => void },
115
- updater: (value: unknown) => void,
116
- ): () => void {
117
- if (__DEV__) _countSink.__pyreon_count__?.('runtime.bindDirect')
118
- // Fast path: source has .direct() (signal or computed)
119
- if (source.direct) {
120
- updater(source._v)
121
- return source.direct(() => updater(source._v))
122
- }
123
- // Fallback: plain callable — use renderEffect
124
- const fn = source as unknown as () => unknown
125
- return renderEffect(() => updater(fn()))
126
- }
127
-
128
- // ─── Compiler-facing template API ─────────────────────────────────────────────
129
-
130
- // Cache parsed <template> elements by HTML string — parse once, clone many.
131
- //
132
- // LRU bound (audit bug #5): typical apps emit a small bounded set of unique
133
- // HTML strings (one per JSX element tree the compiler hoists), so the cache
134
- // stays in the dozens-to-hundreds in practice. But an app that constructs
135
- // JSX from user input (or compiles many large dynamic templates) could grow
136
- // this unbounded — every unique string holds a parsed <template> alive.
137
- //
138
- // Map preserves insertion order; on overflow we evict the OLDEST entry (the
139
- // least-recently-inserted). Common HTML strings hit the cache before
140
- // eviction; pathological inputs cycle through the cap without leaking.
141
- //
142
- // 1024 chosen as a balance: ~1024 unique templates × ~1KB parsed = ~1MB
143
- // worst case — well within memory budget for any realistic app, and
144
- // generous enough that no real codebase will hit the cap. Apps that
145
- // genuinely need a different cap can swap their own _tpl wrapper.
146
- const TPL_CACHE_MAX = 1024
147
- const _tplCache = new Map<string, HTMLTemplateElement>()
148
-
149
- /**
150
- * Compiler-emitted template instantiation.
151
- *
152
- * Parses `html` into a <template> element once (cached), then cloneNode(true)
153
- * for each call. The `bind` function wires up dynamic attributes, text content,
154
- * and event listeners on the cloned element tree. Returns a NativeItem that
155
- * mountChild can insert directly — no VNode allocation.
156
- *
157
- * This is the runtime half of the compiler's template optimisation. The compiler
158
- * detects static JSX element trees and emits `_tpl(html, bindFn)` instead of
159
- * nested `h()` calls. Benefits:
160
- * - cloneNode(true) is ~5-10x faster than sequential createElement + setAttribute
161
- * - Zero VNode / props-object / children-array allocations per instance
162
- * - Static attributes are baked into the HTML string (no runtime prop application)
163
- *
164
- * @example
165
- * // Compiler output for: <div class="box"><span>{text()}</span></div>
166
- * _tpl('<div class="box"><span></span></div>', (__root) => {
167
- * const __e0 = __root.children[0];
168
- * const __d0 = _re(() => { __e0.textContent = text(); });
169
- * return () => { __d0(); };
170
- * })
171
- */
172
- export function _tpl(html: string, bind: (el: HTMLElement) => (() => void) | null): NativeItem {
173
- if (__DEV__) _countSink.__pyreon_count__?.('runtime.tpl')
174
- let tpl = _tplCache.get(html)
175
- if (!tpl) {
176
- tpl = document.createElement('template')
177
- tpl.innerHTML = html
178
- // LRU eviction — drop the oldest entry once we hit the cap. Map
179
- // iteration is insertion-order so the first key is always the
180
- // oldest. delete() is O(1).
181
- if (_tplCache.size >= TPL_CACHE_MAX) {
182
- const oldest = _tplCache.keys().next().value
183
- if (oldest !== undefined) _tplCache.delete(oldest)
184
- }
185
- _tplCache.set(html, tpl)
186
- } else {
187
- // LRU touch — re-insert moves to most-recent position so frequently
188
- // used templates survive eviction.
189
- _tplCache.delete(html)
190
- _tplCache.set(html, tpl)
191
- }
192
- const el = tpl.content.firstElementChild?.cloneNode(true) as HTMLElement
193
- const cleanup = bind(el)
194
- return { __isNative: true, el, cleanup }
195
- }
196
-
197
- /**
198
- * Compiler-emitted collapsed rocketstyle call site.
199
- *
200
- * The runtime half of the P0 compile-time rocketstyle wrapper-collapse.
201
- * For a literal-prop call site like `<Button state="primary" size="md">Save</Button>`,
202
- * the build resolves the FULL rocketstyle/styler pipeline once (SSR
203
- * render of the real component) and the compiler emits ONE `_rsCollapse`
204
- * call instead of the 5-layer wrapper mount (rocketstyle → attrs HOC →
205
- * Element → Wrapper → styled). Measured 44× wall-clock, mountChild 9→1
206
- * (see examples/experiments/e2-static-rocketstyle/RESULTS.md).
207
- *
208
- * Dual-emit (RFC decision 1): both the light- and dark-resolved class
209
- * strings are baked in; `isDark` is the app's live mode accessor (the
210
- * compiler threads it from the configured provider, e.g. `useMode` from
211
- * `@pyreon/ui-core`). A whole-theme/mode swap re-runs only this binding —
212
- * no remount — preserving Pyreon's reactive mode-switch contract. The
213
- * resolved CSS rules are injected once at module-eval via the styler's
214
- * idempotent `injectRules()` (emitted alongside this call), so the
215
- * collapsed site is self-sufficient: no prior runtime mount of the real
216
- * component is needed to populate the sheet.
217
- *
218
- * `bind` is the standard `_tpl` child/event binder for the (static)
219
- * children — identical to what the compiler emits for the non-collapsed
220
- * template path, so children reactivity / event delegation is unchanged.
221
- *
222
- * @param html static element HTML WITHOUT the class attr (class is applied reactively)
223
- * @param lightClass resolved styler class string for light mode
224
- * @param darkClass resolved styler class string for dark mode
225
- * @param isDark app mode accessor — `() => boolean` (true ⇒ dark)
226
- * @param bind standard _tpl binder for children/events (or null)
227
- */
228
- export function _rsCollapse(
229
- html: string,
230
- lightClass: string,
231
- darkClass: string,
232
- isDark: () => boolean,
233
- bind?: ((el: HTMLElement) => (() => void) | null) | null,
234
- ): NativeItem {
235
- return _tpl(html, (el) => {
236
- // Reactive class: _bindDirect's plain-callable fallback wraps this in
237
- // a renderEffect, so reading the mode accessor subscribes to the live
238
- // mode signal — a mode swap re-runs ONLY this className assignment.
239
- const disposeClass = _bindDirect(isDark as unknown as { _v?: unknown }, (v) => {
240
- el.className = v ? darkClass : lightClass
241
- })
242
- const disposeChildren = bind ? bind(el) : null
243
- if (!disposeChildren) return disposeClass
244
- return () => {
245
- disposeClass()
246
- disposeChildren()
247
- }
248
- })
249
- }
250
-
251
- /**
252
- * Compiler-emitted PARTIALLY-collapsed rocketstyle call site — PR 2 of
253
- * the partial-collapse build (`.claude/plans/open-work-2026-q3.md` → #1).
254
- *
255
- * Identical to {@link _rsCollapse} (one `_tpl` cloneNode, dual-emit
256
- * reactive class, no remount on mode swap) PLUS it re-attaches the
257
- * residual event handlers `detectPartialCollapsibleShape` (compiler
258
- * PR 1) peeled off the `on*`-handler-only subset (the 7.8% the bail
259
- * census measured). Handlers are orthogonal to the SSR-resolved styler
260
- * class, so `html` / `lightClass` / `darkClass` are byte-identical to a
261
- * full-collapse site's — the ONLY delta vs `_rsCollapse` is the handler
262
- * re-attach, routed through the CANONICAL `_bindEvent` → `applyEventProp`
263
- * path (delegation + batching + name normalization), so the collapsed
264
- * node behaves byte-identically to the 5-layer mount it replaced.
265
- *
266
- * @param handlers `{ onClick: fn, onPointerEnter: fn, … }` — the peeled
267
- * residual handlers; compiler PR 3 emits this object literal from the
268
- * sliced source spans `detectPartialCollapsibleShape` returned.
269
- */
270
- export function _rsCollapseH(
271
- html: string,
272
- lightClass: string,
273
- darkClass: string,
274
- isDark: () => boolean,
275
- handlers: Record<string, unknown>,
276
- bind?: ((el: HTMLElement) => (() => void) | null) | null,
277
- ): NativeItem {
278
- return _tpl(html, (el) => {
279
- const disposeClass = _bindDirect(isDark as unknown as { _v?: unknown }, (v) => {
280
- el.className = v ? darkClass : lightClass
281
- })
282
- const handlerDisposers: (() => void)[] = []
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)) {
469
- const d = _bindEvent(el, key, handlers[key])
470
- if (d) handlerDisposers.push(d)
471
- }
472
- const disposeChildren = bind ? bind(el) : null
473
- return () => {
474
- disposeClass()
475
- for (const d of handlerDisposers) d()
476
- if (disposeChildren) disposeChildren()
477
- }
478
- })
479
- }
480
-
481
- /**
482
- * Test-only: clear the template cache. Used by tests that assert on
483
- * cache size; never called by runtime code. Not exported from the
484
- * package's public index.
485
- */
486
- export function _clearTplCache(): void {
487
- _tplCache.clear()
488
- }
489
-
490
- /**
491
- * Test-only: read current cache size. Used by tests that assert
492
- * eviction. Not exported from the package's public index.
493
- */
494
- export function _tplCacheSize(): number {
495
- return _tplCache.size
496
- }
497
-
498
- /**
499
- * Mount a children slot inside a template.
500
- *
501
- * Compiler emits this instead of `createTextNode()` when it detects a
502
- * children expression (`props.children`, `own.children`). Unlike text nodes,
503
- * children can be VNodes, arrays, or reactive accessors — all handled by
504
- * `mountChild()`.
505
- *
506
- * @param children - The children value (VNode, string, array, or accessor)
507
- * @param parent - The parent element in the cloned template
508
- * @param placeholder - The comment placeholder node to replace
509
- * @returns Cleanup function
510
- */
511
- export function _mountSlot(
512
- children: VNodeChild | VNodeChild[],
513
- parent: Node,
514
- placeholder: Node,
515
- ): (() => void) | null {
516
- if (children == null || children === false || children === true) {
517
- parent.removeChild(placeholder)
518
- return null
519
- }
520
- const cleanup = mountChild(children, parent, placeholder)
521
- parent.removeChild(placeholder)
522
- return cleanup
523
- }
@@ -1,62 +0,0 @@
1
- import { h } from '@pyreon/core'
2
- import { mountInBrowser } from '@pyreon/test-utils/browser'
3
- import { afterEach, describe, expect, it, vi } from 'vitest'
4
-
5
- // Real-Chromium smoke for the #233 callback-ref null-on-unmount fix.
6
- // happy-dom's element lifecycle isn't a fully faithful mirror of the
7
- // browser's — this suite checks the invocation sequence against a real
8
- // Chromium DOM removal path.
9
-
10
- describe('callback ref null on unmount (real browser)', () => {
11
- afterEach(() => {
12
- vi.restoreAllMocks()
13
- })
14
-
15
- it('invokes the callback with the element then null when unmounted', () => {
16
- const calls: Array<Element | null> = []
17
- const cb = (el: Element | null) => {
18
- calls.push(el)
19
- }
20
-
21
- const { unmount } = mountInBrowser(h('div', { id: 'r1', ref: cb }, 'x'))
22
-
23
- expect(calls.length).toBe(1)
24
- expect(calls[0]?.id).toBe('r1')
25
-
26
- unmount()
27
-
28
- expect(calls.length).toBe(2)
29
- expect(calls[1]).toBe(null)
30
- })
31
-
32
- it('invokes nested callback refs with null in child-then-parent order', () => {
33
- const outer: Array<Element | null> = []
34
- const inner: Array<Element | null> = []
35
-
36
- const { unmount } = mountInBrowser(
37
- h(
38
- 'section',
39
- {
40
- id: 'ro',
41
- ref: (el: Element | null) => {
42
- outer.push(el)
43
- },
44
- },
45
- h('div', {
46
- id: 'ri',
47
- ref: (el: Element | null) => {
48
- inner.push(el)
49
- },
50
- }),
51
- ),
52
- )
53
-
54
- expect(outer[0]?.id).toBe('ro')
55
- expect(inner[0]?.id).toBe('ri')
56
-
57
- unmount()
58
-
59
- expect(outer.at(-1)).toBe(null)
60
- expect(inner.at(-1)).toBe(null)
61
- })
62
- })
@@ -1,52 +0,0 @@
1
- import { h } from '@pyreon/core'
2
- import { mount } from '../index'
3
-
4
- describe('callback refs — called with null on unmount', () => {
5
- let container: HTMLDivElement
6
-
7
- beforeEach(() => {
8
- container = document.createElement('div')
9
- document.body.appendChild(container)
10
- })
11
-
12
- afterEach(() => {
13
- container.remove()
14
- })
15
-
16
- it('invokes the callback with the element on mount and null on unmount', () => {
17
- const calls: Array<Element | null> = []
18
- const myRef = (el: Element | null) => {
19
- calls.push(el)
20
- }
21
-
22
- const dispose = mount(h('div', { ref: myRef }), container)
23
-
24
- expect(calls.length).toBe(1)
25
- expect(calls[0]).toBe(container.querySelector('div'))
26
-
27
- dispose()
28
-
29
- expect(calls.length).toBe(2)
30
- expect(calls[1]).toBe(null)
31
- })
32
-
33
- it('invokes the callback with null when a nested element unmounts', () => {
34
- const calls: Array<Element | null> = []
35
- const myRef = (el: Element | null) => {
36
- calls.push(el)
37
- }
38
-
39
- const dispose = mount(
40
- h('section', null, h('div', { ref: myRef })),
41
- container,
42
- )
43
-
44
- expect(calls.length).toBe(1)
45
- expect(calls[0]?.tagName).toBe('DIV')
46
-
47
- dispose()
48
-
49
- expect(calls.length).toBe(2)
50
- expect(calls[1]).toBe(null)
51
- })
52
- })