@pyreon/runtime-dom 0.12.13 → 0.12.15

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.13",
3
+ "version": "0.12.15",
4
4
  "description": "DOM renderer for Pyreon",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/runtime-dom#readme",
6
6
  "bugs": {
@@ -37,18 +37,24 @@
37
37
  "build": "vl_rolldown_build",
38
38
  "dev": "vl_rolldown_build-watch",
39
39
  "test": "vitest run",
40
+ "test:browser": "vitest run --config ./vitest.browser.config.ts",
40
41
  "typecheck": "tsc --noEmit",
41
42
  "lint": "oxlint .",
42
43
  "prepublishOnly": "bun run build"
43
44
  },
44
45
  "dependencies": {
45
- "@pyreon/core": "^0.12.13",
46
- "@pyreon/reactivity": "^0.12.13"
46
+ "@pyreon/core": "^0.12.15",
47
+ "@pyreon/reactivity": "^0.12.15"
47
48
  },
48
49
  "devDependencies": {
49
50
  "@happy-dom/global-registrator": "^20.8.9",
50
- "@pyreon/compiler": "^0.12.13",
51
- "@pyreon/runtime-server": "^0.12.13",
52
- "happy-dom": "^20.8.3"
51
+ "@pyreon/compiler": "^0.12.15",
52
+ "@pyreon/runtime-server": "^0.12.15",
53
+ "@pyreon/test-utils": "^0.12.10",
54
+ "@vitest/browser-playwright": "^4.1.4",
55
+ "esbuild": "^0.28.0",
56
+ "happy-dom": "^20.8.3",
57
+ "rolldown": "^1.0.0-rc.15",
58
+ "vite": "^8.0.8"
53
59
  }
54
60
  }
package/src/hydrate.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * Falls back to mountChild() whenever DOM structure doesn't match the VNode.
17
17
  */
18
18
 
19
- import type { ComponentFn, Ref, VNode, VNodeChild } from '@pyreon/core'
19
+ import type { ComponentFn, RefProp, VNode, VNodeChild } from '@pyreon/core'
20
20
  import {
21
21
  dispatchToErrorBoundary,
22
22
  ForSymbol,
@@ -114,9 +114,13 @@ function hydrateReactiveChild(
114
114
  )
115
115
  }
116
116
 
117
- const marker = insertMarker(parent, domNode, 'pyreon')
118
- const cleanup = mountReactive(child, parent, marker, mountChild)
117
+ // Reactive accessor that produces a VNode/NativeItem subtree.
119
118
  const next = domNode ? nextReal(domNode) : null
119
+ if (domNode && domNode.parentNode) {
120
+ domNode.parentNode.removeChild(domNode)
121
+ }
122
+ const marker = insertMarker(parent, next, 'pyreon')
123
+ const cleanup = mountReactive(child, parent, marker, mountChild)
120
124
  return [cleanup, next]
121
125
  }
122
126
 
@@ -213,6 +217,29 @@ function hydrateChild(
213
217
  return [cleanup, domNode]
214
218
  }
215
219
 
220
+ // NativeItem — output of the compiler's `_tpl()` template fast path. The
221
+ // client builds a fresh DOM subtree in memory (cloned + reactively bound).
222
+ // We don't yet have a true hydration mode for `_tpl` (which would adopt
223
+ // existing DOM nodes and rebind without remount). For now, swap the SSR
224
+ // subtree at this position for the freshly-mounted one — same final DOM,
225
+ // no duplication, reactivity intact. This is correctness-first; a true
226
+ // adopting hydration is a separate compiler-side change.
227
+ if ((child as unknown as { __isNative?: boolean })?.__isNative === true) {
228
+ const native = child as unknown as { __isNative: true; el: Node; cleanup?: () => void }
229
+ const next = domNode ? nextReal(domNode) : null
230
+ if (domNode && domNode.parentNode) {
231
+ domNode.parentNode.replaceChild(native.el, domNode)
232
+ } else {
233
+ parent.insertBefore(native.el, anchor)
234
+ }
235
+ const cleanup = () => {
236
+ native.cleanup?.()
237
+ const p = native.el.parentNode
238
+ if (p && (p as Element).isConnected !== false) p.removeChild(native.el)
239
+ }
240
+ return [cleanup, next]
241
+ }
242
+
216
243
  return hydrateVNode(child as VNode, domNode, parent, anchor, path)
217
244
  }
218
245
 
@@ -245,14 +272,17 @@ function hydrateElement(
245
272
  cleanups.push(childCleanup)
246
273
 
247
274
  // Set ref
248
- const ref = vnode.props.ref as Ref<Element> | ((el: Element) => void) | undefined
275
+ const ref = vnode.props.ref as RefProp<Element> | undefined
249
276
  if (ref) {
250
277
  if (typeof ref === 'function') ref(el)
251
278
  else ref.current = el
252
279
  }
253
280
 
254
281
  const cleanup = () => {
255
- if (ref && typeof ref === 'object') ref.current = null
282
+ if (ref) {
283
+ if (typeof ref === 'function') ref(null)
284
+ else ref.current = null
285
+ }
256
286
  for (const c of cleanups) c()
257
287
  el.remove()
258
288
  }
package/src/mount.ts CHANGED
@@ -268,14 +268,20 @@ function mountElement(vnode: VNode, parent: Node, anchor: Node | null): Cleanup
268
268
  }
269
269
  const refToClean = ref
270
270
  return () => {
271
- if (refToClean && typeof refToClean === 'object') refToClean.current = null
271
+ if (refToClean) {
272
+ if (typeof refToClean === 'function') refToClean(null)
273
+ else refToClean.current = null
274
+ }
272
275
  if (propCleanup) propCleanup()
273
276
  childCleanup()
274
277
  }
275
278
  }
276
279
 
277
280
  return () => {
278
- if (ref && typeof ref === 'object') ref.current = null
281
+ if (ref) {
282
+ if (typeof ref === 'function') ref(null)
283
+ else ref.current = null
284
+ }
279
285
  if (propCleanup) propCleanup()
280
286
  childCleanup()
281
287
  const p = el.parentNode
package/src/props.ts CHANGED
@@ -221,7 +221,15 @@ function applyEventProp(el: Element, key: string, value: unknown): Cleanup | nul
221
221
  }
222
222
  return null
223
223
  }
224
- const eventName = key[2]?.toLowerCase() + key.slice(3)
224
+ // `onPointerDown` -> `pointerdown`. Multi-word DOM event names are
225
+ // all-lowercase (`pointerdown`, `dblclick`, `mouseover`), so we
226
+ // lowercase the WHOLE name — not just the first letter, as a previous
227
+ // version did. That bug silently dropped delegation for every
228
+ // multi-word event (pointerdown/up/move, mousedown/up/move, dblclick,
229
+ // touchstart/end/move, etc.) — the handler was attached via
230
+ // `addEventListener('pointerDown', ...)` which never fires because
231
+ // real events use the lowercase name.
232
+ const eventName = (key[2]?.toLowerCase() + key.slice(3)).toLowerCase()
225
233
  const handler = value as EventListener
226
234
 
227
235
  if (DELEGATED_EVENTS.has(eventName)) {
@@ -237,37 +245,76 @@ function applyEventProp(el: Element, key: string, value: unknown): Cleanup | nul
237
245
  return () => el.removeEventListener(eventName, batched)
238
246
  }
239
247
 
240
- export function applyProp(el: Element, key: string, value: unknown): Cleanup | null {
241
- // Event listener: onClick "click"
242
- 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
+ }
243
275
 
244
- // innerHTML — sanitized via Sanitizer API or fallback allowlist sanitizer
276
+ // innerHTML — sanitized via Sanitizer API or fallback allowlist sanitizer.
245
277
  if (key === 'innerHTML') {
278
+ const html = String(value ?? '')
246
279
  if (typeof (el as HTMLElement & { setHTML?: (h: string) => void }).setHTML === 'function') {
247
- ;(el as HTMLElement & { setHTML: (h: string) => void }).setHTML(value as string)
280
+ ;(el as HTMLElement & { setHTML: (h: string) => void }).setHTML(html)
248
281
  } else {
249
- ;(el as HTMLElement).innerHTML = sanitizeHtml(value as string)
282
+ ;(el as HTMLElement).innerHTML = sanitizeHtml(html)
250
283
  }
251
- return null
284
+ return
252
285
  }
253
- // dangerouslySetInnerHTML — intentionally raw, developer owns sanitization (same as React).
254
- // The name itself is the warning React doesn't log, neither should we.
255
- // Previously this warned on every prop application, flooding the console
256
- // 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.
257
290
  if (key === 'dangerouslySetInnerHTML') {
258
- ;(el as HTMLElement).innerHTML = (value as { __html: string }).__html
259
- return null
291
+ const v = value as { __html: string } | null | undefined
292
+ ;(el as HTMLElement).innerHTML = v?.__html ?? ''
293
+ return
260
294
  }
261
295
 
262
- // Reactive prop — function that returns the actual value
263
- // Uses renderEffect (lighter than effect — no scope registration, no WeakMap)
264
- // 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.
265
313
  if (typeof value === 'function') {
266
- const dispose = renderEffect(() => setStaticProp(el, key, (value as () => unknown)()))
267
- return dispose
314
+ return renderEffect(() => applyStaticProp(el, key, (value as () => unknown)()))
268
315
  }
269
316
 
270
- setStaticProp(el, key, value)
317
+ applyStaticProp(el, key, value)
271
318
  return null
272
319
  }
273
320
 
@@ -275,16 +322,48 @@ export function applyProp(el: Element, key: string, value: unknown): Cleanup | n
275
322
  const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'poster', 'cite', 'data'])
276
323
  const UNSAFE_URL_RE = /^\s*(?:javascript|data):/i
277
324
 
325
+ // Track the CSS property names an element's last-applied style object set,
326
+ // so a reactive style going from `{ color, fontSize }` to `{ color }` removes
327
+ // the stale `fontSize`. React/Vue/Solid all do this diff; previously Pyreon
328
+ // only applied new keys, leaking the removed ones onto the DOM.
329
+ const _prevStyleKeys: WeakMap<HTMLElement, Set<string>> = new WeakMap()
330
+
278
331
  /** Apply a style prop (string or object). */
279
332
  function applyStyleProp(el: HTMLElement, value: unknown): void {
280
333
  if (typeof value === 'string') {
334
+ // cssText replaces everything — drop any tracked object-mode keys.
281
335
  el.style.cssText = value
282
- } else if (value != null && typeof value === 'object') {
336
+ _prevStyleKeys.delete(el)
337
+ return
338
+ }
339
+
340
+ const prev = _prevStyleKeys.get(el)
341
+
342
+ if (value == null) {
343
+ // Explicit null/undefined: clear whatever object-mode keys we set.
344
+ if (prev) {
345
+ for (const propName of prev) el.style.removeProperty(propName)
346
+ _prevStyleKeys.delete(el)
347
+ }
348
+ return
349
+ }
350
+
351
+ if (typeof value === 'object') {
283
352
  const obj = value as Record<string, unknown>
353
+ const next = new Set<string>()
284
354
  for (const k in obj) {
355
+ const propName = k.startsWith('--') ? k : toKebabCase(k)
356
+ next.add(propName)
285
357
  const css = normalizeStyleValue(k, obj[k])
286
- el.style.setProperty(k.startsWith('--') ? k : toKebabCase(k), css)
358
+ el.style.setProperty(propName, css)
359
+ }
360
+ if (prev) {
361
+ for (const propName of prev) {
362
+ if (!next.has(propName)) el.style.removeProperty(propName)
363
+ }
287
364
  }
365
+ if (next.size === 0) _prevStyleKeys.delete(el)
366
+ else _prevStyleKeys.set(el, next)
288
367
  }
289
368
  }
290
369
 
@@ -0,0 +1,62 @@
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
+ })
@@ -0,0 +1,52 @@
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
+ })
@@ -0,0 +1,40 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ const here = path.dirname(fileURLToPath(import.meta.url))
7
+ const SRC = path.resolve(here, '..')
8
+
9
+ // Source-pattern regression test for the dev-mode warning gate. Pairs with
10
+ // the browser test in `runtime-dom.browser.test.ts` (which proves the gate
11
+ // fires in dev) — this asserts the gate is written using the pattern that
12
+ // Vite/Rolldown can literal-replace at build time, NOT the broken
13
+ // `typeof process` pattern that PR #200 cleaned up.
14
+ //
15
+ // Same shape as `flow/src/tests/integration.test.ts:warnIgnoredOptions`.
16
+ //
17
+ // The lint rule `pyreon/no-process-dev-gate` (introduced in PR #220) is the
18
+ // CI-wide enforcement for this. This test is the narrow, package-local
19
+ // safety net so a regression in runtime-dom is caught even if the lint
20
+ // configuration drifts.
21
+
22
+ const FILES_WITH_DEV_GATE = ['nodes.ts', 'hydration-debug.ts']
23
+
24
+ describe('runtime-dom dev-warning gate (source pattern)', () => {
25
+ for (const file of FILES_WITH_DEV_GATE) {
26
+ it(`${file} uses import.meta.env.DEV, not typeof process`, async () => {
27
+ const source = await readFile(path.join(SRC, file), 'utf8')
28
+ // Strip line + block comments so referencing the broken pattern in
29
+ // documentation doesn't false-positive.
30
+ const code = source
31
+ .replace(/\/\*[\s\S]*?\*\//g, '')
32
+ .replace(/(^|[^:])\/\/.*$/gm, '$1')
33
+
34
+ // The gate constant must exist, defined via Vite's literal-replaced env.
35
+ expect(code).toMatch(/const\s+__DEV__\s*=\s*import\.meta\.env\??\.DEV/)
36
+ // The broken pattern must not appear anywhere in executable code.
37
+ expect(code).not.toMatch(/typeof\s+process\s*!==?\s*['"]undefined['"]/)
38
+ })
39
+ }
40
+ })