@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.
- package/package.json +5 -9
- package/src/delegate.ts +0 -98
- package/src/devtools.ts +0 -339
- package/src/env.d.ts +0 -6
- package/src/hydrate.ts +0 -450
- package/src/hydration-debug.ts +0 -129
- package/src/index.ts +0 -83
- package/src/keep-alive-entry.ts +0 -3
- package/src/keep-alive.ts +0 -83
- package/src/manifest.ts +0 -236
- package/src/mount.ts +0 -597
- package/src/nodes.ts +0 -896
- package/src/props.ts +0 -474
- package/src/template.ts +0 -523
- package/src/tests/callback-ref-unmount.browser.test.ts +0 -62
- package/src/tests/callback-ref-unmount.test.ts +0 -52
- package/src/tests/compiler-integration.test.tsx +0 -508
- package/src/tests/coverage-gaps.test.ts +0 -3183
- package/src/tests/coverage.test.ts +0 -1140
- package/src/tests/ctx-stack-growth-repro.test.tsx +0 -158
- package/src/tests/dev-gate-pattern.test.ts +0 -46
- package/src/tests/dev-gate-treeshake.test.ts +0 -256
- package/src/tests/error-boundary-stack-leak-repro.test.tsx +0 -133
- package/src/tests/fanout-repro.test.tsx +0 -219
- package/src/tests/hydration-integration.test.tsx +0 -540
- package/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts +0 -140
- package/src/tests/lifecycle-integration.test.tsx +0 -342
- package/src/tests/lis-prepend.browser.test.ts +0 -99
- package/src/tests/manifest-snapshot.test.ts +0 -85
- package/src/tests/mount.test.ts +0 -3529
- package/src/tests/native-markers.test.ts +0 -19
- package/src/tests/props.test.ts +0 -581
- package/src/tests/reactive-props.test.ts +0 -270
- package/src/tests/real-world-integration.test.tsx +0 -714
- package/src/tests/rs-collapse-dyn-h.browser.test.ts +0 -303
- package/src/tests/rs-collapse-dyn.browser.test.ts +0 -316
- package/src/tests/rs-collapse-h.browser.test.ts +0 -152
- package/src/tests/rs-collapse-h.test.ts +0 -237
- package/src/tests/rs-collapse.browser.test.ts +0 -128
- package/src/tests/runtime-dom.browser.test.ts +0 -409
- package/src/tests/setup.ts +0 -3
- package/src/tests/show-context.test.ts +0 -270
- package/src/tests/show-of-for-batched-toggle.browser.test.ts +0 -122
- package/src/tests/ssr-xss-round-trip.browser.test.ts +0 -93
- package/src/tests/style-key-removal.browser.test.ts +0 -54
- package/src/tests/style-key-removal.test.ts +0 -88
- package/src/tests/template.test.ts +0 -383
- package/src/tests/transition-timeout-leak.test.ts +0 -126
- package/src/tests/transition.test.ts +0 -568
- package/src/tests/verified-correct-probes.test.ts +0 -56
- package/src/transition-entry.ts +0 -7
- package/src/transition-group.ts +0 -350
- 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
|
-
})
|