@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.
- package/README.md +13 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +167 -26
- 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 +101 -22
- 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 +95 -5
- package/src/tests/props.test.ts +117 -0
- 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 +126 -0
- package/src/tests/verified-correct-probes.test.ts +56 -0
- package/src/transition-group.ts +80 -8
- package/src/transition.ts +46 -3
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":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/runtime-dom",
|
|
3
|
-
"version": "0.12.
|
|
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.
|
|
46
|
-
"@pyreon/reactivity": "^0.12.
|
|
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.
|
|
51
|
-
"@pyreon/runtime-server": "^0.12.
|
|
52
|
-
"
|
|
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,
|
|
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)) {
|
|
@@ -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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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(
|
|
280
|
+
;(el as HTMLElement & { setHTML: (h: string) => void }).setHTML(html)
|
|
248
281
|
} else {
|
|
249
|
-
;(el as HTMLElement).innerHTML = sanitizeHtml(
|
|
282
|
+
;(el as HTMLElement).innerHTML = sanitizeHtml(html)
|
|
250
283
|
}
|
|
251
|
-
return
|
|
284
|
+
return
|
|
252
285
|
}
|
|
253
|
-
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
286
|
+
|
|
287
|
+
// dangerouslySetInnerHTML — intentionally 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
|
-
|
|
259
|
-
|
|
291
|
+
const v = value as { __html: string } | null | undefined
|
|
292
|
+
;(el as HTMLElement).innerHTML = v?.__html ?? ''
|
|
293
|
+
return
|
|
260
294
|
}
|
|
261
295
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
267
|
-
return dispose
|
|
314
|
+
return renderEffect(() => applyStaticProp(el, key, (value as () => unknown)()))
|
|
268
315
|
}
|
|
269
316
|
|
|
270
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
+
})
|