@pyreon/runtime-dom 0.12.13 → 0.12.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +62 -10
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +12 -6
- package/src/hydrate.ts +35 -5
- package/src/mount.ts +8 -2
- package/src/props.ts +43 -3
- package/src/tests/callback-ref-unmount.browser.test.ts +62 -0
- package/src/tests/callback-ref-unmount.test.ts +52 -0
- package/src/tests/dev-gate-pattern.test.ts +40 -0
- package/src/tests/dev-gate-treeshake.test.ts +262 -0
- package/src/tests/mount.test.ts +6 -5
- package/src/tests/runtime-dom.browser.test.ts +295 -0
- package/src/tests/ssr-xss-round-trip.browser.test.ts +93 -0
- package/src/tests/style-key-removal.browser.test.ts +54 -0
- package/src/tests/style-key-removal.test.ts +88 -0
- package/src/tests/transition-timeout-leak.test.ts +81 -0
- package/src/tests/verified-correct-probes.test.ts +56 -0
- package/src/transition.ts +20 -2
package/lib/types/index.d.ts.map
CHANGED
|
@@ -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":";;;;;;AAoBA;;;;;AA8BA;;;;;AAWA;;;;cAzCa,gBAAA,EAAgB,GAAA;;;;ACJ7B;iBDkCgB,iBAAA,CAAkB,SAAA;;;;;iBAWlB,eAAA,CAAgB,SAAA,EAAW,OAAA;;;;;;AAzC3C;;;;;AA8BA;;;;;AAWA;;UC7CiB,sBAAA;EACf,EAAA;EACA,IAAA;;EAEA,EAAA,EAAI,OAAA;EACJ,QAAA;EACA,QAAA;AAAA;AAAA,UAGe,cAAA;EAAA,SACN,OAAA;EACT,gBAAA,IAAoB,sBAAA;EACpB,gBAAA,IAAoB,sBAAA;EACpB,SAAA,CAAU,EAAA;EACV,gBAAA,CAAiB,EAAA,GAAK,KAAA,EAAO,sBAAA;EAC7B,kBAAA,CAAmB,EAAA,GAAK,EAAA;EATxB;EAWA,aAAA;EACA,cAAA;AAAA;;;;;;AAlBF;;;;;;;;;;;
|
|
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":";;;;;;AAoBA;;;;;AA8BA;;;;;AAWA;;;;cAzCa,gBAAA,EAAgB,GAAA;;;;ACJ7B;iBDkCgB,iBAAA,CAAkB,SAAA;;;;;iBAWlB,eAAA,CAAgB,SAAA,EAAW,OAAA;;;;;;AAzC3C;;;;;AA8BA;;;;;AAWA;;UC7CiB,sBAAA;EACf,EAAA;EACA,IAAA;;EAEA,EAAA,EAAI,OAAA;EACJ,QAAA;EACA,QAAA;AAAA;AAAA,UAGe,cAAA;EAAA,SACN,OAAA;EACT,gBAAA,IAAoB,sBAAA;EACpB,gBAAA,IAAoB,sBAAA;EACpB,SAAA,CAAU,EAAA;EACV,gBAAA,CAAiB,EAAA,GAAK,KAAA,EAAO,sBAAA;EAC7B,kBAAA,CAAmB,EAAA,GAAK,EAAA;EATxB;EAWA,aAAA;EACA,cAAA;AAAA;;;;;;AAlBF;;;;;;;;;;;iBCmagB,WAAA,CAAY,SAAA,EAAW,OAAA,EAAS,KAAA,EAAO,UAAA;;;;;;AF/ZvD;;;;;AA8BA;;iBGhCgB,uBAAA,CAAA;AAAA,iBAIA,wBAAA,CAAA;;;UCjBC,cAAA,SAAuB,KAAA;;AJexC;;;;;EIRE,MAAA;EACA,QAAA,GAAW,UAAA;AAAA;;;AJgDb;;;;;;;;AC7CA;;;;;;;;;;;;AASA;;iBGegB,SAAA,CAAU,KAAA,EAAO,cAAA,GAAiB,UAAA;;;KCV7C,SAAA;;ALVL;;;;;AA8BA;iBKCgB,UAAA,CACd,KAAA,EAAO,UAAA,GAAa,UAAA,YAAsB,UAAA,GAAa,UAAA,KACvD,MAAA,EAAQ,IAAA,EACR,MAAA,GAAQ,IAAA,UACP,SAAA;;;KCjDE,OAAA;AAAA,KASO,UAAA,IAAc,IAAA;ANK1B;;;;;AA8BA;;;;;AAWA;;;;;;AAzCA,iBMegB,YAAA,CAAa,EAAA,EAAI,UAAA;;ALnBjC;;;iBK8IgB,YAAA,CAAa,IAAA;;;;;;iBAgBb,UAAA,CAAW,EAAA,EAAI,OAAA,EAAS,KAAA,EAAO,KAAA,GAAQ,OAAA;AAAA,iBAyEvC,SAAA,CAAU,EAAA,EAAI,OAAA,EAAS,GAAA,UAAa,KAAA,YAAiB,OAAA;;;;;ANnOrE;;;;;AA8BA;;;;;AAWA;;;;;;;;AC7CA;;;;;iBMagB,cAAA,GAAA,CACd,IAAA,UACA,IAAA,GAAO,EAAA,EAAI,WAAA,EAAa,IAAA,EAAM,CAAA,4BAC5B,IAAA,EAAM,CAAA,KAAM,UAAA;;;;;;;ANPhB;;;;;;;;;;iBMqCgB,SAAA,CACd,MAAA;EAAU,EAAA;EAAc,MAAA,IAAU,EAAA;AAAA,GAClC,IAAA,EAAM,IAAA;;;;;;;;;;;;;;;;ALmXR;iBK9UgB,WAAA,CACd,MAAA;EAAU,EAAA;EAAc,MAAA,IAAU,EAAA;AAAA,GAClC,OAAA,GAAU,KAAA;;;;;;;;;AJrFZ;;;;;AAIA;;;;;;;;ACjBA;;iBG0IgB,IAAA,CAAK,IAAA,UAAc,IAAA,GAAO,EAAA,EAAI,WAAA,2BAAsC,UAAA;;;;;;;;AHvGpF;;;;;;iBGgIgB,UAAA,CACd,QAAA,EAAU,UAAA,GAAa,UAAA,IACvB,MAAA,EAAQ,IAAA,EACR,WAAA,EAAa,IAAA;;;UC/JE,eAAA;;ARQjB;;;;EQFE,IAAA;ERgCc;EQ9Bd,IAAA;;;;ARyCF;EQpCE,MAAA;EAEA,SAAA;EACA,WAAA;EACA,OAAA;EACA,SAAA;EACA,WAAA;EACA,OAAA;EAEA,aAAA,IAAiB,EAAA,EAAI,WAAA;EACrB,YAAA,IAAgB,EAAA,EAAI,WAAA;EACpB,aAAA,IAAiB,EAAA,EAAI,WAAA;EACrB,YAAA,IAAgB,EAAA,EAAI,WAAA;EPpBpB;;;;EOyBA,QAAA,GAAW,UAAA;AAAA;;;APjBb;;;;;;;;;;;;;;;;;;;;iBO0CgB,UAAA,CAAW,KAAA,EAAO,eAAA,GAAkB,UAAA;;;UC9DnC,oBAAA;;EAEf,GAAA;ETqCA;ESnCA,IAAA;ETW2B;EST3B,MAAA;EAEA,SAAA;EACA,WAAA;EACA,OAAA;EACA,SAAA;EACA,WAAA;EACA,OAAA;ET2C6B;ESzC7B,SAAA;ETyCyC;ESvCzC,KAAA,QAAa,CAAA;;EAEb,KAAA,GAAQ,IAAA,EAAM,CAAA,EAAG,KAAA;;ARRnB;;;;EQcE,MAAA,GAAS,IAAA,EAAM,CAAA,EAAG,KAAA,aAAkB,KAAA;EAEpC,aAAA,IAAiB,EAAA,EAAI,WAAA;EACrB,YAAA,IAAgB,EAAA,EAAI,WAAA;EACpB,aAAA,IAAiB,EAAA,EAAI,WAAA;EACrB,YAAA,IAAgB,EAAA,EAAI,WAAA;AAAA;;;ARVtB;;;;;;;;;;;;;;;;;;;;;;;iBQ6CgB,eAAA,aAAA,CAA6B,KAAA,EAAO,oBAAA,CAAqB,CAAA,IAAK,UAAA;;;;;;;;;ARtD9E;;iBSmBgB,KAAA,CAAM,IAAA,EAAM,UAAA,EAAY,SAAA,EAAW,OAAA;;cAatC,MAAA,SAAM,KAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/runtime-dom",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.14",
|
|
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.
|
|
46
|
-
"@pyreon/reactivity": "^0.12.
|
|
46
|
+
"@pyreon/core": "^0.12.14",
|
|
47
|
+
"@pyreon/reactivity": "^0.12.14"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|
|
49
50
|
"@happy-dom/global-registrator": "^20.8.9",
|
|
50
|
-
"@pyreon/compiler": "^0.12.
|
|
51
|
-
"@pyreon/runtime-server": "^0.12.
|
|
52
|
-
"
|
|
51
|
+
"@pyreon/compiler": "^0.12.14",
|
|
52
|
+
"@pyreon/runtime-server": "^0.12.14",
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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)) {
|
|
@@ -275,16 +283,48 @@ export function applyProp(el: Element, key: string, value: unknown): Cleanup | n
|
|
|
275
283
|
const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'poster', 'cite', 'data'])
|
|
276
284
|
const UNSAFE_URL_RE = /^\s*(?:javascript|data):/i
|
|
277
285
|
|
|
286
|
+
// Track the CSS property names an element's last-applied style object set,
|
|
287
|
+
// so a reactive style going from `{ color, fontSize }` to `{ color }` removes
|
|
288
|
+
// the stale `fontSize`. React/Vue/Solid all do this diff; previously Pyreon
|
|
289
|
+
// only applied new keys, leaking the removed ones onto the DOM.
|
|
290
|
+
const _prevStyleKeys: WeakMap<HTMLElement, Set<string>> = new WeakMap()
|
|
291
|
+
|
|
278
292
|
/** Apply a style prop (string or object). */
|
|
279
293
|
function applyStyleProp(el: HTMLElement, value: unknown): void {
|
|
280
294
|
if (typeof value === 'string') {
|
|
295
|
+
// cssText replaces everything — drop any tracked object-mode keys.
|
|
281
296
|
el.style.cssText = value
|
|
282
|
-
|
|
297
|
+
_prevStyleKeys.delete(el)
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const prev = _prevStyleKeys.get(el)
|
|
302
|
+
|
|
303
|
+
if (value == null) {
|
|
304
|
+
// Explicit null/undefined: clear whatever object-mode keys we set.
|
|
305
|
+
if (prev) {
|
|
306
|
+
for (const propName of prev) el.style.removeProperty(propName)
|
|
307
|
+
_prevStyleKeys.delete(el)
|
|
308
|
+
}
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (typeof value === 'object') {
|
|
283
313
|
const obj = value as Record<string, unknown>
|
|
314
|
+
const next = new Set<string>()
|
|
284
315
|
for (const k in obj) {
|
|
316
|
+
const propName = k.startsWith('--') ? k : toKebabCase(k)
|
|
317
|
+
next.add(propName)
|
|
285
318
|
const css = normalizeStyleValue(k, obj[k])
|
|
286
|
-
el.style.setProperty(
|
|
319
|
+
el.style.setProperty(propName, css)
|
|
320
|
+
}
|
|
321
|
+
if (prev) {
|
|
322
|
+
for (const propName of prev) {
|
|
323
|
+
if (!next.has(propName)) el.style.removeProperty(propName)
|
|
324
|
+
}
|
|
287
325
|
}
|
|
326
|
+
if (next.size === 0) _prevStyleKeys.delete(el)
|
|
327
|
+
else _prevStyleKeys.set(el, next)
|
|
288
328
|
}
|
|
289
329
|
}
|
|
290
330
|
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import { build } from 'vite'
|
|
7
|
+
|
|
8
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
9
|
+
const SRC = path.resolve(here, '..')
|
|
10
|
+
|
|
11
|
+
// Bundle-level regression test for the T1.1 C-2 finding.
|
|
12
|
+
//
|
|
13
|
+
// Background — the shape of the problem from PR #227 bring-up:
|
|
14
|
+
// Raw `esbuild --minify` preserves chained `__DEV__ && cond &&
|
|
15
|
+
// console.warn(...)` patterns even when `import.meta.env.DEV` is
|
|
16
|
+
// defined to `false`. That tempted a pattern-rewrite across all
|
|
17
|
+
// Pyreon sources.
|
|
18
|
+
//
|
|
19
|
+
// What the C-2 investigation actually found:
|
|
20
|
+
// Pyreon's real consumer path is Vite (which uses Rolldown under the
|
|
21
|
+
// hood plus its own import.meta.env replacement + tree-shake passes).
|
|
22
|
+
// Vite's production build DOES eliminate the chained patterns
|
|
23
|
+
// correctly — the raw esbuild baseline was misleading. Raw Rolldown
|
|
24
|
+
// alone also doesn't replicate Vite's behavior because Rolldown's
|
|
25
|
+
// `define` doesn't rewrite optional-chain access paths.
|
|
26
|
+
//
|
|
27
|
+
// This test bundles a runtime-dom entry through Vite's production
|
|
28
|
+
// build and asserts dev-warning strings are GONE. If Vite's handling
|
|
29
|
+
// ever regresses, this catches it.
|
|
30
|
+
//
|
|
31
|
+
// Scope note: the existing `dev-gate-pattern.test.ts` is the cheap
|
|
32
|
+
// source-level guard (grep for `typeof process`, require `import.meta.env.DEV`).
|
|
33
|
+
// This test is the expensive end-to-end guard for the bundle path.
|
|
34
|
+
|
|
35
|
+
interface FileContract {
|
|
36
|
+
file: string
|
|
37
|
+
/** Dev-warning strings that MUST be eliminated from the prod bundle. */
|
|
38
|
+
devWarningStrings: string[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Coverage strategy: pick representative files across the runtime-dom
|
|
42
|
+
// dev-gate landscape so a regression in any of the typical patterns is
|
|
43
|
+
// caught. `nodes.ts` covers the chained `&&` form (the original
|
|
44
|
+
// problem). `mount.ts` covers the simple `if (__DEV__)` form across
|
|
45
|
+
// multiple Portal/VNode call sites. `props.ts` covers attribute-validation
|
|
46
|
+
// warnings inside small inline `if (__DEV__) { ... }` blocks.
|
|
47
|
+
// `transition.ts` covers a single `if (__DEV__) { console.warn() }`.
|
|
48
|
+
//
|
|
49
|
+
// These four files exercise every shape of dev gate currently used in
|
|
50
|
+
// runtime-dom; if the contract holds for all of them, it holds for the
|
|
51
|
+
// rest of the file set.
|
|
52
|
+
const FILES_UNDER_TEST: FileContract[] = [
|
|
53
|
+
{
|
|
54
|
+
file: 'nodes.ts',
|
|
55
|
+
devWarningStrings: [
|
|
56
|
+
'[Pyreon] <For> `by` function returned null/undefined',
|
|
57
|
+
'[Pyreon] Duplicate key',
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
file: 'mount.ts',
|
|
62
|
+
devWarningStrings: [
|
|
63
|
+
'[Pyreon] <Portal> received a falsy `target`',
|
|
64
|
+
'[Pyreon] <Portal> target must be a DOM node',
|
|
65
|
+
'[Pyreon] Invalid VNode type',
|
|
66
|
+
'is a void element and cannot have children',
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
file: 'props.ts',
|
|
71
|
+
devWarningStrings: [
|
|
72
|
+
'[Pyreon] Event handler',
|
|
73
|
+
'[Pyreon] Blocked unsafe URL',
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
file: 'transition.ts',
|
|
78
|
+
devWarningStrings: [
|
|
79
|
+
'[Pyreon] Transition child is a component',
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
async function bundleWithVite(entry: string, dev: boolean): Promise<string> {
|
|
85
|
+
const outDir = mkdtempSync(path.join(tmpdir(), 'pyreon-vite-treeshake-'))
|
|
86
|
+
try {
|
|
87
|
+
// Vite library-mode build with explicit minify. `define` on
|
|
88
|
+
// `import.meta.env` isn't usually needed (Vite sets it automatically
|
|
89
|
+
// based on mode), but `mode: 'production'` flips DEV to false.
|
|
90
|
+
await build({
|
|
91
|
+
mode: dev ? 'development' : 'production',
|
|
92
|
+
logLevel: 'error',
|
|
93
|
+
configFile: false,
|
|
94
|
+
resolve: { conditions: ['bun'] },
|
|
95
|
+
// Explicit define — Vite in lib mode doesn't always apply the
|
|
96
|
+
// default production env replacement, so we set it ourselves.
|
|
97
|
+
define: {
|
|
98
|
+
'import.meta.env.DEV': JSON.stringify(dev),
|
|
99
|
+
'import.meta.env': JSON.stringify({ DEV: dev, PROD: !dev, MODE: dev ? 'development' : 'production' }),
|
|
100
|
+
},
|
|
101
|
+
build: {
|
|
102
|
+
// PINNED minifier: 'esbuild' is what Pyreon's reference consumers
|
|
103
|
+
// (Zero, the example apps) effectively use. If a future Vite
|
|
104
|
+
// version flips the default to oxc-minify or terser, behavior
|
|
105
|
+
// could differ silently — pinning keeps this test honest.
|
|
106
|
+
minify: dev ? false : 'esbuild',
|
|
107
|
+
target: 'esnext',
|
|
108
|
+
write: true,
|
|
109
|
+
outDir,
|
|
110
|
+
emptyOutDir: true,
|
|
111
|
+
lib: {
|
|
112
|
+
entry,
|
|
113
|
+
formats: ['es'],
|
|
114
|
+
fileName: 'out',
|
|
115
|
+
},
|
|
116
|
+
// Bundle everything — we want the tested file's strings visible
|
|
117
|
+
// in the output, not aliased to an external import.
|
|
118
|
+
rollupOptions: {
|
|
119
|
+
external: ['@pyreon/core', '@pyreon/reactivity', '@pyreon/runtime-server'],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
const outPath = path.join(outDir, 'out.js')
|
|
124
|
+
const fs = await import('node:fs')
|
|
125
|
+
return fs.readFileSync(outPath, 'utf8')
|
|
126
|
+
} finally {
|
|
127
|
+
rmSync(outDir, { recursive: true, force: true })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
describe('runtime-dom dev-warning gate (Vite production bundle)', () => {
|
|
132
|
+
for (const { file, devWarningStrings } of FILES_UNDER_TEST) {
|
|
133
|
+
it(`${file} → dev warnings eliminated in Vite production bundle`, async () => {
|
|
134
|
+
const code = await bundleWithVite(path.join(SRC, file), false)
|
|
135
|
+
|
|
136
|
+
for (const warn of devWarningStrings) {
|
|
137
|
+
expect(code, `"${warn}" survived prod tree-shake`).not.toContain(warn)
|
|
138
|
+
}
|
|
139
|
+
expect(code.length).toBeGreaterThan(0)
|
|
140
|
+
}, 5000)
|
|
141
|
+
|
|
142
|
+
it(`${file} → dev warnings PRESERVED in Vite dev bundle (sanity)`, async () => {
|
|
143
|
+
// Gate for the eliminated-when-prod test: if the strings were
|
|
144
|
+
// deleted from source entirely, the previous test would pass
|
|
145
|
+
// trivially. Bundling in dev mode should keep them.
|
|
146
|
+
if (devWarningStrings.length === 0) return
|
|
147
|
+
|
|
148
|
+
const code = await bundleWithVite(path.join(SRC, file), true)
|
|
149
|
+
|
|
150
|
+
for (const warn of devWarningStrings) {
|
|
151
|
+
expect(code, `"${warn}" missing from dev bundle (did source change?)`).toContain(warn)
|
|
152
|
+
}
|
|
153
|
+
}, 5000)
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
// ─── Non-Vite consumer runtime correctness ─────────────────────────────────
|
|
158
|
+
//
|
|
159
|
+
// What the CLAUDE.md doc claims for non-Vite consumers (webpack,
|
|
160
|
+
// bunchee, raw esbuild bundles): the dev-warning STRINGS may stay in
|
|
161
|
+
// the bundle as data, but the warnings themselves don't fire because
|
|
162
|
+
// the `import.meta.env?.DEV === true` gate evaluates to `false` when
|
|
163
|
+
// `import.meta.env.DEV` is undefined at runtime.
|
|
164
|
+
//
|
|
165
|
+
// This block bundles `nodes.ts` with raw esbuild (no `define` for
|
|
166
|
+
// import.meta.env, simulating a less-aware bundler), then asserts:
|
|
167
|
+
//
|
|
168
|
+
// 1. The dev-warning strings DO survive (proving we picked a real
|
|
169
|
+
// bundle to test, not Vite-equivalent behavior).
|
|
170
|
+
// 2. The strings are still gated — they appear next to a check
|
|
171
|
+
// involving `import.meta.env` rather than being unconditional.
|
|
172
|
+
//
|
|
173
|
+
// (2) is what makes the runtime claim true: at runtime `import.meta.env`
|
|
174
|
+
// is `undefined` in non-Vite-aware environments, so `?.DEV` returns
|
|
175
|
+
// `undefined`, `=== true` returns `false`, and the warn never fires.
|
|
176
|
+
// If a future refactor unconditionally calls console.warn (no gate),
|
|
177
|
+
// this assertion catches that the runtime contract regressed.
|
|
178
|
+
|
|
179
|
+
describe('non-Vite consumer runtime correctness', () => {
|
|
180
|
+
it('raw esbuild bundle: warning strings remain in bundle (proves we test the non-Vite path)', async () => {
|
|
181
|
+
const { build } = await import('esbuild')
|
|
182
|
+
const result = await build({
|
|
183
|
+
entryPoints: [path.join(SRC, 'nodes.ts')],
|
|
184
|
+
bundle: true,
|
|
185
|
+
write: false,
|
|
186
|
+
minify: true,
|
|
187
|
+
format: 'esm',
|
|
188
|
+
platform: 'browser',
|
|
189
|
+
external: ['@pyreon/core', '@pyreon/reactivity', '@pyreon/runtime-server'],
|
|
190
|
+
// Intentionally no `define` — simulates a non-Vite-aware bundler.
|
|
191
|
+
})
|
|
192
|
+
const code = result.outputFiles[0]?.text ?? ''
|
|
193
|
+
expect(code).toContain('Duplicate key')
|
|
194
|
+
}, 5000)
|
|
195
|
+
|
|
196
|
+
it('raw esbuild bundle: dev gate evaluates to false at runtime when import.meta.env is undefined', async () => {
|
|
197
|
+
// The real claim is RUNTIME — even when warning strings are in the
|
|
198
|
+
// bundle, the gate stops `console.warn` from firing. This test
|
|
199
|
+
// EXECUTES the bundled module with `import.meta.env` undefined
|
|
200
|
+
// (the non-Vite case) and verifies `console.warn` is never called.
|
|
201
|
+
//
|
|
202
|
+
// Bundle a synthetic harness that exposes the gated callsite as a
|
|
203
|
+
// standalone exported function, replacing the cross-package
|
|
204
|
+
// imports so we don't need a full Pyreon runtime to execute. The
|
|
205
|
+
// harness mirrors the EXACT gate pattern used in nodes.ts.
|
|
206
|
+
const { build } = await import('esbuild')
|
|
207
|
+
const harness = `
|
|
208
|
+
// Same module-scope const pattern used in real Pyreon source.
|
|
209
|
+
// @ts-ignore — \`import.meta.env\` is provided by Vite at build time
|
|
210
|
+
const __DEV__ = import.meta.env?.DEV === true
|
|
211
|
+
export function maybeWarn(seen: Set<string>, key: string): void {
|
|
212
|
+
// Mirrors nodes.ts: a chained \`__DEV__ && cond && warn\` form
|
|
213
|
+
// (Pattern B from the C-2 probe).
|
|
214
|
+
if (seen.has(key)) {
|
|
215
|
+
if (__DEV__) {
|
|
216
|
+
console.warn(\`[Pyreon] Duplicate key "\${String(key)}" in <For> list.\`)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
seen.add(key)
|
|
220
|
+
}
|
|
221
|
+
`
|
|
222
|
+
const result = await build({
|
|
223
|
+
stdin: { contents: harness, loader: 'ts', resolveDir: SRC },
|
|
224
|
+
bundle: true,
|
|
225
|
+
write: false,
|
|
226
|
+
minify: true,
|
|
227
|
+
format: 'esm',
|
|
228
|
+
platform: 'browser',
|
|
229
|
+
// No `define` — same as a non-Vite consumer.
|
|
230
|
+
})
|
|
231
|
+
const code = result.outputFiles[0]?.text ?? ''
|
|
232
|
+
|
|
233
|
+
// The string MUST be in the bundle (proves this is the non-Vite path).
|
|
234
|
+
expect(code).toContain('Duplicate key')
|
|
235
|
+
|
|
236
|
+
// Now actually execute the bundled module with `import.meta.env`
|
|
237
|
+
// resembling the non-Vite environment (undefined). Use a data:
|
|
238
|
+
// import to load the bundled ESM module. Bun supports this.
|
|
239
|
+
const dataUrl = `data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
|
|
240
|
+
const mod = (await import(/* @vite-ignore */ dataUrl)) as {
|
|
241
|
+
maybeWarn: (s: Set<string>, k: string) => void
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Spy on console.warn — the real runtime check.
|
|
245
|
+
const calls: unknown[][] = []
|
|
246
|
+
const original = console.warn
|
|
247
|
+
console.warn = (...args: unknown[]) => {
|
|
248
|
+
calls.push(args)
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const seen = new Set<string>()
|
|
252
|
+
mod.maybeWarn(seen, 'foo')
|
|
253
|
+
mod.maybeWarn(seen, 'foo') // second call → seen.has('foo') is true → would warn if gate broken
|
|
254
|
+
} finally {
|
|
255
|
+
console.warn = original
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The runtime contract: warning string is in the bundle (data),
|
|
259
|
+
// but the gate stops it from firing.
|
|
260
|
+
expect(calls).toEqual([])
|
|
261
|
+
}, 5000)
|
|
262
|
+
})
|
package/src/tests/mount.test.ts
CHANGED
|
@@ -219,21 +219,22 @@ describe('mount — refs', () => {
|
|
|
219
219
|
expect(refEl).toBeInstanceOf(HTMLDivElement)
|
|
220
220
|
})
|
|
221
221
|
|
|
222
|
-
test('callback ref
|
|
222
|
+
test('callback ref is invoked with null on unmount', () => {
|
|
223
223
|
const el = container()
|
|
224
224
|
let refEl: Element | null = null
|
|
225
225
|
const unmount = mount(
|
|
226
226
|
h('div', {
|
|
227
|
-
ref: (e: Element) => {
|
|
227
|
+
ref: (e: Element | null) => {
|
|
228
228
|
refEl = e
|
|
229
229
|
},
|
|
230
230
|
}),
|
|
231
231
|
el,
|
|
232
232
|
)
|
|
233
|
-
expect(refEl).not.toBeNull()
|
|
234
|
-
unmount()
|
|
235
|
-
// Callback refs don't get called with null on cleanup
|
|
236
233
|
expect(refEl).toBeInstanceOf(HTMLDivElement)
|
|
234
|
+
unmount()
|
|
235
|
+
// Fixed: callback refs are now called with null on cleanup
|
|
236
|
+
// to match React/Solid/Vue behavior and the RefCallback<T> type.
|
|
237
|
+
expect(refEl).toBeNull()
|
|
237
238
|
})
|
|
238
239
|
|
|
239
240
|
test('ref is not emitted as an HTML attribute', () => {
|