@pyreon/runtime-dom 0.12.14 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"index2.d.ts","names":[],"sources":["../../../src/delegate.ts","../../../src/devtools.ts","../../../src/hydrate.ts","../../../src/hydration-debug.ts","../../../src/keep-alive.ts","../../../src/mount.ts","../../../src/props.ts","../../../src/template.ts","../../../src/transition.ts","../../../src/transition-group.ts","../../../src/index.ts"],"mappings":"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"}
1
+ {"version":3,"file":"index2.d.ts","names":[],"sources":["../../../src/delegate.ts","../../../src/devtools.ts","../../../src/hydrate.ts","../../../src/hydration-debug.ts","../../../src/keep-alive.ts","../../../src/mount.ts","../../../src/props.ts","../../../src/template.ts","../../../src/transition.ts","../../../src/transition-group.ts","../../../src/index.ts"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/runtime-dom",
3
- "version": "0.12.14",
3
+ "version": "0.13.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": {
@@ -43,14 +43,15 @@
43
43
  "prepublishOnly": "bun run build"
44
44
  },
45
45
  "dependencies": {
46
- "@pyreon/core": "^0.12.14",
47
- "@pyreon/reactivity": "^0.12.14"
46
+ "@pyreon/core": "^0.13.0",
47
+ "@pyreon/reactivity": "^0.13.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@happy-dom/global-registrator": "^20.8.9",
51
- "@pyreon/compiler": "^0.12.14",
52
- "@pyreon/runtime-server": "^0.12.14",
53
- "@pyreon/test-utils": "^0.12.10",
51
+ "@pyreon/compiler": "^0.13.0",
52
+ "@pyreon/manifest": "0.13.0",
53
+ "@pyreon/runtime-server": "^0.13.0",
54
+ "@pyreon/test-utils": "^0.13.0",
54
55
  "@vitest/browser-playwright": "^4.1.4",
55
56
  "esbuild": "^0.28.0",
56
57
  "happy-dom": "^20.8.3",
@@ -0,0 +1,218 @@
1
+ import { defineManifest } from '@pyreon/manifest'
2
+
3
+ export default defineManifest({
4
+ name: '@pyreon/runtime-dom',
5
+ title: 'DOM Renderer',
6
+ tagline:
7
+ 'DOM renderer, mount, hydrateRoot, Transition, TransitionGroup, KeepAlive, SVG/MathML namespace, custom elements',
8
+ description:
9
+ 'Surgical signal-to-DOM renderer with zero virtual DOM overhead. The compiler emits `_tpl()` (cloneNode-based template instantiation) + `_bind()` (per-node reactive bindings) calls that mount directly to the DOM without VNode diffing. Reactive text uses `TextNode.data` assignment (not `.textContent`) for minimal DOM mutation. Supports SVG/MathML namespace auto-detection (67 tags), custom elements (props as properties), CSS transitions via `<Transition>` / `<TransitionGroup>`, and component caching via `<KeepAlive>`. Dev-mode warnings use `import.meta.env.DEV` (not `typeof process`) so they tree-shake to zero bytes in production Vite builds.',
10
+ category: 'browser',
11
+ longExample: `import { mount, hydrateRoot, Transition, TransitionGroup, KeepAlive } from "@pyreon/runtime-dom"
12
+ import { signal } from "@pyreon/reactivity"
13
+ import { Show, For } from "@pyreon/core"
14
+
15
+ // Mount — clears container, returns unmount function
16
+ const unmount = mount(<App />, document.getElementById("app")!)
17
+
18
+ // Hydrate SSR-rendered HTML (preserves existing DOM)
19
+ hydrateRoot(<App />, document.getElementById("app")!)
20
+
21
+ // Transition — CSS-based enter/leave animations
22
+ const visible = signal(true)
23
+ const FadeExample = () => (
24
+ <Transition name="fade" mode="out-in">
25
+ <Show when={visible()}>
26
+ <div>Content</div>
27
+ </Show>
28
+ </Transition>
29
+ )
30
+ // CSS: .fade-enter-active, .fade-leave-active { transition: opacity 0.3s }
31
+ // .fade-enter-from, .fade-leave-to { opacity: 0 }
32
+
33
+ // TransitionGroup — animate list items entering/leaving
34
+ const items = signal([1, 2, 3])
35
+ const ListExample = () => (
36
+ <TransitionGroup name="list">
37
+ <For each={items()} by={i => i}>
38
+ {item => <div>{item}</div>}
39
+ </For>
40
+ </TransitionGroup>
41
+ )
42
+
43
+ // KeepAlive — cache component state across mount/unmount cycles
44
+ const tab = signal<"a" | "b">("a")
45
+ const TabExample = () => (
46
+ <KeepAlive>
47
+ <Show when={tab() === "a"}><ExpensiveA /></Show>
48
+ <Show when={tab() === "b"}><ExpensiveB /></Show>
49
+ </KeepAlive>
50
+ )`,
51
+ features: [
52
+ 'mount() — mount VNode tree into container, returns unmount function',
53
+ 'hydrateRoot() — hydrate SSR-rendered HTML, preserving existing DOM',
54
+ 'Transition — CSS-based enter/leave animations with mode support',
55
+ 'TransitionGroup — animate list item additions and removals',
56
+ 'KeepAlive — cache and restore component state across mount/unmount cycles',
57
+ '_tpl() + _bind() — compiler-driven template instantiation with zero VNode overhead',
58
+ 'SVG/MathML — 67 tags auto-detected, correct namespace URI, setAttribute-only',
59
+ 'Custom elements — props set as properties on hyphenated tag names',
60
+ 'Event delegation — synthetic event system for performance',
61
+ 'Dev-mode warnings — container validation, output validation, duplicate keys',
62
+ ],
63
+ api: [
64
+ {
65
+ name: 'mount',
66
+ kind: 'function',
67
+ signature: 'mount(root: VNodeChild, container: Element): () => void',
68
+ summary:
69
+ 'Mount a VNode tree into a container element. Clears the container first, sets up event delegation, then mounts the given child. Returns an `unmount` function that removes everything and disposes all effects. In dev mode, throws if `container` is null/undefined with an actionable error message.',
70
+ example: `import { mount } from "@pyreon/runtime-dom"
71
+
72
+ const dispose = mount(<App />, document.getElementById("app")!)
73
+
74
+ // To unmount:
75
+ dispose()`,
76
+ mistakes: [
77
+ '`createRoot(container).render(<App />)` — Pyreon uses a single function call: `mount(<App />, container)`',
78
+ '`mount(<App />, document.getElementById("app"))` without `!` — getElementById returns `Element | null`. The runtime throws in dev if null, but TypeScript needs the assertion',
79
+ '`mount(<App />, document.body)` — mounting directly to body is discouraged; use a dedicated container element',
80
+ 'Forgetting to call the returned unmount function — leaks event listeners and effects. Store and call it on cleanup',
81
+ ],
82
+ seeAlso: ['hydrateRoot', 'render'],
83
+ },
84
+ {
85
+ name: 'render',
86
+ kind: 'function',
87
+ signature: 'render(root: VNodeChild, container: Element): () => void',
88
+ summary:
89
+ 'Alias for `mount`. Provided for API familiarity — both names point to the same function.',
90
+ example: `import { render } from "@pyreon/runtime-dom"
91
+ render(<App />, document.getElementById("app")!)`,
92
+ seeAlso: ['mount'],
93
+ },
94
+ {
95
+ name: 'hydrateRoot',
96
+ kind: 'function',
97
+ signature: 'hydrateRoot(root: VNodeChild, container: Element): () => void',
98
+ summary:
99
+ 'Hydrate server-rendered HTML. Walks the existing DOM and attaches reactive bindings without recreating elements. Expects the DOM to match the VNode tree structure — mismatches emit dev-mode warnings. Returns an unmount function.',
100
+ example: `import { hydrateRoot } from "@pyreon/runtime-dom"
101
+
102
+ // Hydrate SSR-rendered HTML:
103
+ hydrateRoot(<App />, document.getElementById("app")!)`,
104
+ seeAlso: ['mount', '@pyreon/runtime-server'],
105
+ },
106
+ {
107
+ name: 'Transition',
108
+ kind: 'component',
109
+ signature: '<Transition name={name} mode={mode} onEnter={fn} onLeave={fn}>{children}</Transition>',
110
+ summary:
111
+ 'CSS-based enter/leave animation wrapper. Applies `{name}-enter-from`, `{name}-enter-active`, `{name}-enter-to` classes on enter and the corresponding `-leave-*` classes on leave. `mode` controls sequencing: `"out-in"` waits for leave to complete before entering, `"in-out"` enters first. Has a 5-second safety timeout — if `transitionend`/`animationend` never fires, the transition completes automatically.',
112
+ example: `<Transition name="fade" mode="out-in">
113
+ <Show when={visible()}>
114
+ <div>Content</div>
115
+ </Show>
116
+ </Transition>
117
+
118
+ /* CSS:
119
+ .fade-enter-active, .fade-leave-active { transition: opacity 0.3s }
120
+ .fade-enter-from, .fade-leave-to { opacity: 0 }
121
+ */`,
122
+ mistakes: [
123
+ 'Missing CSS classes — `<Transition name="fade">` does nothing without `.fade-enter-active` / `.fade-leave-active` CSS',
124
+ 'Wrapping multiple root elements — Transition expects a single child (or null). Multiple children cause undefined behavior',
125
+ 'Using `mode="in-out"` when you want sequential — `"out-in"` is almost always what you want (old leaves, then new enters)',
126
+ ],
127
+ seeAlso: ['TransitionGroup', '@pyreon/kinetic'],
128
+ },
129
+ {
130
+ name: 'TransitionGroup',
131
+ kind: 'component',
132
+ signature: '<TransitionGroup name={name} tag={tag}>{children}</TransitionGroup>',
133
+ summary:
134
+ 'Animate list item additions and removals with CSS transitions. Each item gets enter/leave classes on mount/unmount. The `tag` prop controls the wrapper element (defaults to a fragment). Works with `<For>` for reactive lists. Also applies `-move` classes for FLIP-animated reordering.',
135
+ example: `<TransitionGroup name="list" tag="ul">
136
+ <For each={items()} by={i => i.id}>
137
+ {item => <li>{item.name}</li>}
138
+ </For>
139
+ </TransitionGroup>
140
+
141
+ /* CSS:
142
+ .list-enter-active, .list-leave-active { transition: all 0.3s }
143
+ .list-enter-from, .list-leave-to { opacity: 0; transform: translateY(10px) }
144
+ .list-move { transition: transform 0.3s }
145
+ */`,
146
+ seeAlso: ['Transition', 'For'],
147
+ },
148
+ {
149
+ name: 'KeepAlive',
150
+ kind: 'component',
151
+ signature: '<KeepAlive include={pattern} exclude={pattern} max={number}>{children}</KeepAlive>',
152
+ summary:
153
+ 'Cache component instances across mount/unmount cycles so their state (signals, scroll position, form inputs) is preserved when they are toggled out and back in. `include`/`exclude` filter by component name. `max` limits cache size (LRU eviction). Useful for tab panels and multi-step forms.',
154
+ example: `const tab = signal<"a" | "b">("a")
155
+
156
+ <KeepAlive>
157
+ <Show when={tab() === "a"}><ExpensiveFormA /></Show>
158
+ <Show when={tab() === "b"}><ExpensiveFormB /></Show>
159
+ </KeepAlive>`,
160
+ seeAlso: ['Transition', 'Show'],
161
+ },
162
+ {
163
+ name: '_tpl',
164
+ kind: 'function',
165
+ signature: '_tpl(html: string): () => DocumentFragment',
166
+ summary:
167
+ 'Compiler-internal: create a template factory from an HTML string. First call parses the HTML into a `<template>` element; subsequent calls use `cloneNode(true)` for zero-parse instantiation. Not intended for direct use — the JSX compiler emits `_tpl()` calls automatically.',
168
+ example: `// Compiler output (not hand-written):
169
+ const _$t0 = _tpl("<div class=\\"container\\"><span></span></div>")`,
170
+ seeAlso: ['_bindText', '_bindDirect'],
171
+ },
172
+ {
173
+ name: '_bindText',
174
+ kind: 'function',
175
+ signature: '_bindText(fn: () => string, node: Text): void',
176
+ summary:
177
+ 'Compiler-internal: bind a reactive expression to a text node via `TextNode.data` assignment. Creates a `renderEffect` that re-runs when tracked signals change. Each text node gets its own independent binding for fine-grained reactivity.',
178
+ example: `// Compiler output for <div>{count()}</div>:
179
+ const _$t = _tpl("<div> </div>")
180
+ const _$n = _$t()
181
+ _bindText(() => count(), _$n.firstChild)`,
182
+ seeAlso: ['_tpl', '_bindDirect'],
183
+ },
184
+ {
185
+ name: 'sanitizeHtml',
186
+ kind: 'function',
187
+ signature: 'sanitizeHtml(html: string): string',
188
+ summary:
189
+ 'Sanitize an HTML string using the registered sanitizer (set via `setSanitizer()`). Falls back to the identity function if no sanitizer is registered. Used by the runtime when setting `innerHTML` on elements.',
190
+ example: `import { setSanitizer, sanitizeHtml } from "@pyreon/runtime-dom"
191
+ setSanitizer(DOMPurify.sanitize)
192
+ const clean = sanitizeHtml(userInput)`,
193
+ seeAlso: ['setSanitizer'],
194
+ },
195
+ ],
196
+ gotchas: [
197
+ {
198
+ label: 'SVG/MathML uses setAttribute only',
199
+ note: 'SVG and MathML elements ALWAYS use `setAttribute()` for prop forwarding, never property assignment. Many SVG properties (`markerWidth`, `refX`, etc.) are read-only `SVGAnimatedLength` getters — `el[key] = value` crashes. Detected by `el.namespaceURI !== "http://www.w3.org/1999/xhtml"`.',
200
+ },
201
+ {
202
+ label: 'Custom elements use property assignment',
203
+ note: 'Elements with a hyphen in their tag name (custom elements) get props set as JS properties, not HTML attributes. This matches the web components spec — attributes are strings, properties can be any type.',
204
+ },
205
+ {
206
+ label: 'Transition 5s safety timeout',
207
+ note: 'If `transitionend` or `animationend` never fires (missing CSS, display:none, zero-duration), the transition completes automatically after 5 seconds to prevent stuck UI.',
208
+ },
209
+ {
210
+ label: 'Dev warnings use import.meta.env.DEV',
211
+ note: 'All dev-mode warnings (`mount()` null container, duplicate keys, raw signal children) use `import.meta.env.DEV` — NOT `typeof process`. Vite/Rolldown literal-replaces it at build time; production bundles contain zero warning bytes. Tests run in vitest which sets DEV=true automatically.',
212
+ },
213
+ {
214
+ label: 'Event delegation',
215
+ note: '`setupDelegation(container)` is called by `mount()` — common events are delegated to the container root for performance. Direct event binding (non-delegated) is used for events that do not bubble (focus, blur, scroll, etc.).',
216
+ },
217
+ ],
218
+ })
package/src/props.ts CHANGED
@@ -245,37 +245,76 @@ function applyEventProp(el: Element, key: string, value: unknown): Cleanup | nul
245
245
  return () => el.removeEventListener(eventName, batched)
246
246
  }
247
247
 
248
- export function applyProp(el: Element, key: string, value: unknown): Cleanup | null {
249
- // Event listener: onClick "click"
250
- if (EVENT_RE.test(key)) return applyEventProp(el, key, value)
248
+ /**
249
+ * Sink for a single prop's CALLED value (always a primitive / object /
250
+ * `null` — never a function). Called both directly for static values and
251
+ * from the reactive `renderEffect` for accessor-bound values.
252
+ *
253
+ * NOTE on architecture: extracting the special-cased sinks
254
+ * (`innerHTML` / `dangerouslySetInnerHTML`) into this single dispatch
255
+ * function ensures every prop kind goes through the same reactive
256
+ * wrapping at `applyProp`'s entry. Previously each special case had its
257
+ * own early-return branch that needed to remember to handle function
258
+ * values; missing the dance once meant the closure was stringified and
259
+ * set as literal text. The structural fix (one reactive-wrap, then
260
+ * dispatch) eliminates the entire bug class.
261
+ */
262
+ function applyStaticProp(el: Element, key: string, value: unknown): void {
263
+ if (__DEV__ && typeof value === 'function') {
264
+ // Defensive: function values must be unwrapped via `renderEffect`
265
+ // before reaching here. If we see one, a NEW special-case branch
266
+ // somewhere upstream skipped the reactive-wrapping dance — exactly
267
+ // the bug class the structural refactor was meant to eliminate.
268
+ console.warn(
269
+ `[Pyreon] applyStaticProp received a function for "${key}". ` +
270
+ `This likely means a new special-cased prop sink in applyProp() ` +
271
+ `bypassed the reactive-wrap path. The closure would be stringified ` +
272
+ `and set as a literal value. Verify the dispatch in applyProp().`,
273
+ )
274
+ }
251
275
 
252
- // innerHTML — sanitized via Sanitizer API or fallback allowlist sanitizer
276
+ // innerHTML — sanitized via Sanitizer API or fallback allowlist sanitizer.
253
277
  if (key === 'innerHTML') {
278
+ const html = String(value ?? '')
254
279
  if (typeof (el as HTMLElement & { setHTML?: (h: string) => void }).setHTML === 'function') {
255
- ;(el as HTMLElement & { setHTML: (h: string) => void }).setHTML(value as string)
280
+ ;(el as HTMLElement & { setHTML: (h: string) => void }).setHTML(html)
256
281
  } else {
257
- ;(el as HTMLElement).innerHTML = sanitizeHtml(value as string)
282
+ ;(el as HTMLElement).innerHTML = sanitizeHtml(html)
258
283
  }
259
- return null
284
+ return
260
285
  }
261
- // dangerouslySetInnerHTML — intentionally raw, developer owns sanitization (same as React).
262
- // The name itself is the warning React doesn't log, neither should we.
263
- // Previously this warned on every prop application, flooding the console
264
- // on re-renders (one warning per render per instance).
286
+
287
+ // dangerouslySetInnerHTMLintentionally raw, developer owns sanitization
288
+ // (same as React). The name itself is the warning — React doesn't log,
289
+ // neither should we.
265
290
  if (key === 'dangerouslySetInnerHTML') {
266
- ;(el as HTMLElement).innerHTML = (value as { __html: string }).__html
267
- return null
291
+ const v = value as { __html: string } | null | undefined
292
+ ;(el as HTMLElement).innerHTML = v?.__html ?? ''
293
+ return
268
294
  }
269
295
 
270
- // Reactive prop — function that returns the actual value
271
- // Uses renderEffect (lighter than effect — no scope registration, no WeakMap)
272
- // since lifecycle is managed by mountElement's cleanup array.
296
+ setStaticProp(el, key, value)
297
+ }
298
+
299
+ export function applyProp(el: Element, key: string, value: unknown): Cleanup | null {
300
+ // Event listener: onClick → "click"
301
+ if (EVENT_RE.test(key)) return applyEventProp(el, key, value)
302
+
303
+ // Reactive prop — function value is an accessor closure. The JSX compiler
304
+ // emits `prop={someExpr(signal())}` as a `() => someExpr(signal())` thunk
305
+ // so the prop tracks the signal automatically. We wrap in `renderEffect`
306
+ // ONCE here, before any prop-kind dispatch, so EVERY sink gets the same
307
+ // reactive treatment. Previously special-cased sinks (innerHTML etc.) had
308
+ // early-return branches that bypassed this wrap and stringified the
309
+ // closure — the bug fixed by this restructure.
310
+ //
311
+ // Uses renderEffect (lighter than effect — no scope registration, no
312
+ // WeakMap) since lifecycle is managed by mountElement's cleanup array.
273
313
  if (typeof value === 'function') {
274
- const dispose = renderEffect(() => setStaticProp(el, key, (value as () => unknown)()))
275
- return dispose
314
+ return renderEffect(() => applyStaticProp(el, key, (value as () => unknown)()))
276
315
  }
277
316
 
278
- setStaticProp(el, key, value)
317
+ applyStaticProp(el, key, value)
279
318
  return null
280
319
  }
281
320
 
@@ -0,0 +1,84 @@
1
+ import {
2
+ renderApiReferenceEntries,
3
+ renderLlmsFullSection,
4
+ renderLlmsTxtLine,
5
+ } from '@pyreon/manifest'
6
+ import runtimeDomManifest from '../manifest'
7
+
8
+ describe('gen-docs — runtime-dom snapshot', () => {
9
+ it('renders @pyreon/runtime-dom to its expected llms.txt bullet', () => {
10
+ expect(renderLlmsTxtLine(runtimeDomManifest)).toMatchInlineSnapshot(`"- @pyreon/runtime-dom — DOM renderer, mount, hydrateRoot, Transition, TransitionGroup, KeepAlive, SVG/MathML namespace, custom elements. SVG and MathML elements ALWAYS use \`setAttribute()\` for prop forwarding, never property assignment. Many SVG properties (\`markerWidth\`, \`refX\`, etc.) are read-only \`SVGAnimatedLength\` getters — \`el[key] = value\` crashes. Detected by \`el.namespaceURI !== "http://www.w3.org/1999/xhtml"\`."`)
11
+ })
12
+
13
+ it('renders @pyreon/runtime-dom to its expected llms-full.txt section — full body snapshot', () => {
14
+ expect(renderLlmsFullSection(runtimeDomManifest)).toMatchInlineSnapshot(`
15
+ "## @pyreon/runtime-dom — DOM Renderer
16
+
17
+ Surgical signal-to-DOM renderer with zero virtual DOM overhead. The compiler emits \`_tpl()\` (cloneNode-based template instantiation) + \`_bind()\` (per-node reactive bindings) calls that mount directly to the DOM without VNode diffing. Reactive text uses \`TextNode.data\` assignment (not \`.textContent\`) for minimal DOM mutation. Supports SVG/MathML namespace auto-detection (67 tags), custom elements (props as properties), CSS transitions via \`<Transition>\` / \`<TransitionGroup>\`, and component caching via \`<KeepAlive>\`. Dev-mode warnings use \`import.meta.env.DEV\` (not \`typeof process\`) so they tree-shake to zero bytes in production Vite builds.
18
+
19
+ \`\`\`typescript
20
+ import { mount, hydrateRoot, Transition, TransitionGroup, KeepAlive } from "@pyreon/runtime-dom"
21
+ import { signal } from "@pyreon/reactivity"
22
+ import { Show, For } from "@pyreon/core"
23
+
24
+ // Mount — clears container, returns unmount function
25
+ const unmount = mount(<App />, document.getElementById("app")!)
26
+
27
+ // Hydrate SSR-rendered HTML (preserves existing DOM)
28
+ hydrateRoot(<App />, document.getElementById("app")!)
29
+
30
+ // Transition — CSS-based enter/leave animations
31
+ const visible = signal(true)
32
+ const FadeExample = () => (
33
+ <Transition name="fade" mode="out-in">
34
+ <Show when={visible()}>
35
+ <div>Content</div>
36
+ </Show>
37
+ </Transition>
38
+ )
39
+ // CSS: .fade-enter-active, .fade-leave-active { transition: opacity 0.3s }
40
+ // .fade-enter-from, .fade-leave-to { opacity: 0 }
41
+
42
+ // TransitionGroup — animate list items entering/leaving
43
+ const items = signal([1, 2, 3])
44
+ const ListExample = () => (
45
+ <TransitionGroup name="list">
46
+ <For each={items()} by={i => i}>
47
+ {item => <div>{item}</div>}
48
+ </For>
49
+ </TransitionGroup>
50
+ )
51
+
52
+ // KeepAlive — cache component state across mount/unmount cycles
53
+ const tab = signal<"a" | "b">("a")
54
+ const TabExample = () => (
55
+ <KeepAlive>
56
+ <Show when={tab() === "a"}><ExpensiveA /></Show>
57
+ <Show when={tab() === "b"}><ExpensiveB /></Show>
58
+ </KeepAlive>
59
+ )
60
+ \`\`\`
61
+
62
+ > **SVG/MathML uses setAttribute only**: SVG and MathML elements ALWAYS use \`setAttribute()\` for prop forwarding, never property assignment. Many SVG properties (\`markerWidth\`, \`refX\`, etc.) are read-only \`SVGAnimatedLength\` getters — \`el[key] = value\` crashes. Detected by \`el.namespaceURI !== "http://www.w3.org/1999/xhtml"\`.
63
+ >
64
+ > **Custom elements use property assignment**: Elements with a hyphen in their tag name (custom elements) get props set as JS properties, not HTML attributes. This matches the web components spec — attributes are strings, properties can be any type.
65
+ >
66
+ > **Transition 5s safety timeout**: If \`transitionend\` or \`animationend\` never fires (missing CSS, display:none, zero-duration), the transition completes automatically after 5 seconds to prevent stuck UI.
67
+ >
68
+ > **Dev warnings use import.meta.env.DEV**: All dev-mode warnings (\`mount()\` null container, duplicate keys, raw signal children) use \`import.meta.env.DEV\` — NOT \`typeof process\`. Vite/Rolldown literal-replaces it at build time; production bundles contain zero warning bytes. Tests run in vitest which sets DEV=true automatically.
69
+ >
70
+ > **Event delegation**: \`setupDelegation(container)\` is called by \`mount()\` — common events are delegated to the container root for performance. Direct event binding (non-delegated) is used for events that do not bubble (focus, blur, scroll, etc.).
71
+ "
72
+ `)
73
+ })
74
+
75
+ it('renders @pyreon/runtime-dom to MCP api-reference entries — one per api[] item', () => {
76
+ const record = renderApiReferenceEntries(runtimeDomManifest)
77
+ expect(Object.keys(record).length).toBe(9)
78
+ expect(Object.keys(record)).toContain('runtime-dom/mount')
79
+ // Spot-check the flagship API — mount is the primary entry point
80
+ const mount = record['runtime-dom/mount']!
81
+ expect(mount.notes).toContain('container')
82
+ expect(mount.mistakes?.split('\n').length).toBeGreaterThan(2)
83
+ })
84
+ })
@@ -3177,6 +3177,95 @@ describe('TransitionGroup — cleanup', () => {
3177
3177
  })
3178
3178
  })
3179
3179
 
3180
+ // ─── TransitionGroup — leak regression tests ─────────────────────────────────
3181
+ // Regression for the two fixes:
3182
+ // 1. No safety timeout on applyLeave meant an item whose transition never
3183
+ // fired stayed in the `entries` Map forever (`entries.delete(key)` was
3184
+ // gated on the `done` callback firing).
3185
+ // 2. Unmount during in-flight transition left the 5s safety timer running,
3186
+ // firing `onAfterEnter` / `onAfterLeave` on detached elements.
3187
+
3188
+ describe('TransitionGroup — leak regressions', () => {
3189
+ beforeEach(() => {
3190
+ vi.useFakeTimers()
3191
+ })
3192
+ afterEach(() => {
3193
+ vi.useRealTimers()
3194
+ })
3195
+
3196
+ test('onAfterLeave fires via 5s safety timeout when transitionend never fires', async () => {
3197
+ const el = container()
3198
+ const items = signal([{ id: 1 }, { id: 2 }])
3199
+ const onAfterLeave = vi.fn()
3200
+ mount(
3201
+ h(TransitionGroup, {
3202
+ tag: 'div',
3203
+ name: 'fade',
3204
+ items,
3205
+ keyFn: (item: { id: number }) => item.id,
3206
+ render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
3207
+ onAfterLeave,
3208
+ }),
3209
+ el,
3210
+ )
3211
+ await vi.advanceTimersByTimeAsync(20)
3212
+ items.set([{ id: 1 }])
3213
+ await vi.advanceTimersByTimeAsync(20)
3214
+ // transitionend never fires — before the fix this would leak forever.
3215
+ expect(onAfterLeave).not.toHaveBeenCalled()
3216
+ await vi.advanceTimersByTimeAsync(5100)
3217
+ expect(onAfterLeave).toHaveBeenCalledTimes(1)
3218
+ })
3219
+
3220
+ test('onAfterEnter does NOT fire after container unmount during in-flight enter', async () => {
3221
+ const el = container()
3222
+ const items = signal<{ id: number }[]>([])
3223
+ const onAfterEnter = vi.fn()
3224
+ const dispose = mount(
3225
+ h(TransitionGroup, {
3226
+ tag: 'div',
3227
+ name: 'fade',
3228
+ items,
3229
+ keyFn: (item: { id: number }) => item.id,
3230
+ render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
3231
+ onAfterEnter,
3232
+ }),
3233
+ el,
3234
+ )
3235
+ await vi.advanceTimersByTimeAsync(20)
3236
+ items.set([{ id: 1 }])
3237
+ await vi.advanceTimersByTimeAsync(20)
3238
+ // Mid-transition — unmount. The 5s safety timer must NOT fire the
3239
+ // callback on a detached element.
3240
+ dispose()
3241
+ await vi.advanceTimersByTimeAsync(6000)
3242
+ expect(onAfterEnter).not.toHaveBeenCalled()
3243
+ })
3244
+
3245
+ test('onAfterLeave does NOT fire after container unmount during in-flight leave', async () => {
3246
+ const el = container()
3247
+ const items = signal([{ id: 1 }])
3248
+ const onAfterLeave = vi.fn()
3249
+ const dispose = mount(
3250
+ h(TransitionGroup, {
3251
+ tag: 'div',
3252
+ name: 'fade',
3253
+ items,
3254
+ keyFn: (item: { id: number }) => item.id,
3255
+ render: (item: { id: number }) => h('span', { class: 'item' }, String(item.id)),
3256
+ onAfterLeave,
3257
+ }),
3258
+ el,
3259
+ )
3260
+ await vi.advanceTimersByTimeAsync(20)
3261
+ items.set([])
3262
+ await vi.advanceTimersByTimeAsync(20)
3263
+ dispose()
3264
+ await vi.advanceTimersByTimeAsync(6000)
3265
+ expect(onAfterLeave).not.toHaveBeenCalled()
3266
+ })
3267
+ })
3268
+
3180
3269
  // ─── Error paths (no ErrorBoundary) ──────────────────────────────────────────
3181
3270
 
3182
3271
  describe('mount — error paths', () => {