@pyreon/runtime-dom 0.12.12 → 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 +63 -12
- 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 +54 -10
- package/src/tests/callback-ref-unmount.browser.test.ts +62 -0
- package/src/tests/callback-ref-unmount.test.ts +52 -0
- package/src/tests/coverage.test.ts +4 -2
- 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/props.test.ts +5 -4
- 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
|
@@ -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', () => {
|
package/src/tests/props.test.ts
CHANGED
|
@@ -258,14 +258,15 @@ describe('applyProp — innerHTML', () => {
|
|
|
258
258
|
expect(el.innerHTML).not.toContain('<script>')
|
|
259
259
|
})
|
|
260
260
|
|
|
261
|
-
test('dangerouslySetInnerHTML bypasses sanitization', () => {
|
|
261
|
+
test('dangerouslySetInnerHTML bypasses sanitization (no warning — name is the warning, like React)', () => {
|
|
262
262
|
const el = document.createElement('div')
|
|
263
263
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
264
264
|
applyProp(el, 'dangerouslySetInnerHTML', { __html: '<em>raw</em>' })
|
|
265
265
|
expect(el.innerHTML).toBe('<em>raw</em>')
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
266
|
+
// No warning — the name "dangerouslySetInnerHTML" is the warning.
|
|
267
|
+
// React doesn't log here, neither do we. Previously this warned on
|
|
268
|
+
// every prop application, flooding the console on every re-render.
|
|
269
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
269
270
|
warnSpy.mockRestore()
|
|
270
271
|
})
|
|
271
272
|
})
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { For, h, Portal } from '@pyreon/core'
|
|
2
|
+
import { signal } from '@pyreon/reactivity'
|
|
3
|
+
import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { hydrateRoot, mount, Transition } from '../index'
|
|
6
|
+
|
|
7
|
+
// Real-Chromium smoke suite for @pyreon/runtime-dom. Catches environment-
|
|
8
|
+
// divergence bugs that happy-dom hides: SVG namespace property setters,
|
|
9
|
+
// real PointerEvent sequencing, `import.meta.env.DEV` literal-replacement,
|
|
10
|
+
// and the keyed reconciler under live signal updates.
|
|
11
|
+
|
|
12
|
+
describe('runtime-dom in real browser', () => {
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.restoreAllMocks()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('mounts and patches DOM when a signal updates', async () => {
|
|
18
|
+
const count = signal(0)
|
|
19
|
+
const { container, unmount } = mountInBrowser(
|
|
20
|
+
h('span', { id: 'n' }, () => String(count())),
|
|
21
|
+
)
|
|
22
|
+
expect(container.querySelector('#n')?.textContent).toBe('0')
|
|
23
|
+
|
|
24
|
+
count.set(42)
|
|
25
|
+
await flush()
|
|
26
|
+
expect(container.querySelector('#n')?.textContent).toBe('42')
|
|
27
|
+
unmount()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('keyed <For> reconciler inserts at the right index when a list grows', async () => {
|
|
31
|
+
type Row = { id: number; label: string }
|
|
32
|
+
const rows = signal<Row[]>([
|
|
33
|
+
{ id: 1, label: 'a' },
|
|
34
|
+
{ id: 2, label: 'b' },
|
|
35
|
+
])
|
|
36
|
+
const { container, unmount } = mountInBrowser(
|
|
37
|
+
h(
|
|
38
|
+
'ul',
|
|
39
|
+
{ id: 'list' },
|
|
40
|
+
For({
|
|
41
|
+
each: rows,
|
|
42
|
+
by: (r: Row) => r.id,
|
|
43
|
+
children: (r: Row) => h('li', { 'data-id': String(r.id) }, r.label),
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
let items = container.querySelectorAll<HTMLLIElement>('#list li')
|
|
49
|
+
expect(items).toHaveLength(2)
|
|
50
|
+
expect(Array.from(items).map((el) => el.textContent)).toEqual(['a', 'b'])
|
|
51
|
+
|
|
52
|
+
rows.set([
|
|
53
|
+
{ id: 1, label: 'a' },
|
|
54
|
+
{ id: 3, label: 'c' },
|
|
55
|
+
{ id: 2, label: 'b' },
|
|
56
|
+
])
|
|
57
|
+
await flush()
|
|
58
|
+
|
|
59
|
+
items = container.querySelectorAll<HTMLLIElement>('#list li')
|
|
60
|
+
expect(items).toHaveLength(3)
|
|
61
|
+
expect(Array.from(items).map((el) => el.dataset.id)).toEqual(['1', '3', '2'])
|
|
62
|
+
expect(Array.from(items).map((el) => el.textContent)).toEqual(['a', 'c', 'b'])
|
|
63
|
+
unmount()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('creates SVG with the SVG namespace and updates reactive attributes via setAttribute', async () => {
|
|
67
|
+
const x = signal(10)
|
|
68
|
+
const { container, unmount } = mountInBrowser(
|
|
69
|
+
h(
|
|
70
|
+
'svg',
|
|
71
|
+
{ id: 'svg', width: '100', height: '100' },
|
|
72
|
+
h('rect', { id: 'r', x: () => x(), y: '0', width: '20', height: '20' }),
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
const svg = container.querySelector('#svg')
|
|
77
|
+
const rect = container.querySelector('#r')
|
|
78
|
+
expect(svg?.namespaceURI).toBe('http://www.w3.org/2000/svg')
|
|
79
|
+
expect(rect?.namespaceURI).toBe('http://www.w3.org/2000/svg')
|
|
80
|
+
// SVGRectElement.x is a read-only SVGAnimatedLength getter — applying
|
|
81
|
+
// via property would crash. setAttribute is the only safe path.
|
|
82
|
+
expect(rect?.getAttribute('x')).toBe('10')
|
|
83
|
+
|
|
84
|
+
x.set(55)
|
|
85
|
+
await flush()
|
|
86
|
+
expect(rect?.getAttribute('x')).toBe('55')
|
|
87
|
+
unmount()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('dispatches a real PointerEvent and fires the onClick handler', async () => {
|
|
91
|
+
const clicks = signal(0)
|
|
92
|
+
const { container, unmount } = mountInBrowser(
|
|
93
|
+
h(
|
|
94
|
+
'button',
|
|
95
|
+
{
|
|
96
|
+
id: 'btn',
|
|
97
|
+
onClick: () => clicks.set(clicks() + 1),
|
|
98
|
+
},
|
|
99
|
+
() => `clicks: ${clicks()}`,
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const btn = container.querySelector<HTMLButtonElement>('#btn')!
|
|
104
|
+
expect(btn.textContent).toBe('clicks: 0')
|
|
105
|
+
|
|
106
|
+
btn.dispatchEvent(
|
|
107
|
+
new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' }),
|
|
108
|
+
)
|
|
109
|
+
btn.dispatchEvent(
|
|
110
|
+
new PointerEvent('pointerup', { bubbles: true, pointerType: 'mouse' }),
|
|
111
|
+
)
|
|
112
|
+
btn.click()
|
|
113
|
+
await flush()
|
|
114
|
+
expect(btn.textContent).toBe('clicks: 1')
|
|
115
|
+
unmount()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('emits the duplicate-key __DEV__ warning under Vite (DEV=true)', async () => {
|
|
119
|
+
// import.meta.env.DEV is true in this dev-mode browser run, which is the
|
|
120
|
+
// exact replacement Vite/Rolldown apply at build-time. The warning must
|
|
121
|
+
// fire here. The companion `runtime-dom.prod-bundle.test.ts` Node test
|
|
122
|
+
// proves the same code path is dead in a prod bundle (DEV=false).
|
|
123
|
+
expect(import.meta.env.DEV).toBe(true)
|
|
124
|
+
|
|
125
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
126
|
+
const dupes = signal([
|
|
127
|
+
{ id: 1, label: 'a' },
|
|
128
|
+
{ id: 1, label: 'b' },
|
|
129
|
+
])
|
|
130
|
+
const { unmount } = mountInBrowser(
|
|
131
|
+
h(
|
|
132
|
+
'div',
|
|
133
|
+
null,
|
|
134
|
+
For({
|
|
135
|
+
each: dupes,
|
|
136
|
+
by: (r: { id: number }) => r.id,
|
|
137
|
+
children: (r: { id: number; label: string }) => h('span', { class: 'dup' }, r.label),
|
|
138
|
+
}),
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
await flush()
|
|
142
|
+
|
|
143
|
+
const calls = warn.mock.calls.flat().join('\n')
|
|
144
|
+
expect(calls).toMatch(/Duplicate key/i)
|
|
145
|
+
unmount()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('hydrateRoot — attaches reactive listeners to existing SSR markup without rerender', async () => {
|
|
149
|
+
// Simulate SSR-rendered HTML in the container.
|
|
150
|
+
const container = document.createElement('div')
|
|
151
|
+
container.innerHTML = '<button id="ssr-btn" type="button">clicks: 0</button>'
|
|
152
|
+
document.body.appendChild(container)
|
|
153
|
+
|
|
154
|
+
const ssrButtonRef = container.querySelector<HTMLButtonElement>('#ssr-btn')!
|
|
155
|
+
const count = signal(0)
|
|
156
|
+
const cleanup = hydrateRoot(
|
|
157
|
+
container,
|
|
158
|
+
h(
|
|
159
|
+
'button',
|
|
160
|
+
{
|
|
161
|
+
id: 'ssr-btn',
|
|
162
|
+
type: 'button',
|
|
163
|
+
onClick: () => count.set(count() + 1),
|
|
164
|
+
},
|
|
165
|
+
() => `clicks: ${count()}`,
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
// Same DOM node — hydrate adopts it, doesn't replace.
|
|
170
|
+
expect(container.querySelector('#ssr-btn')).toBe(ssrButtonRef)
|
|
171
|
+
|
|
172
|
+
// Click triggers the hydrated handler + reactive text update.
|
|
173
|
+
ssrButtonRef.click()
|
|
174
|
+
await flush()
|
|
175
|
+
expect(ssrButtonRef.textContent).toBe('clicks: 1')
|
|
176
|
+
|
|
177
|
+
cleanup()
|
|
178
|
+
container.remove()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('Portal — children render in a different DOM subtree (not the wrapper)', async () => {
|
|
182
|
+
const target = document.createElement('div')
|
|
183
|
+
target.id = 'portal-target'
|
|
184
|
+
document.body.appendChild(target)
|
|
185
|
+
|
|
186
|
+
const { container, unmount } = mountInBrowser(
|
|
187
|
+
h(
|
|
188
|
+
'div',
|
|
189
|
+
{ id: 'src' },
|
|
190
|
+
h(Portal, { target }, h('span', { id: 'teleported' }, 'over there')),
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
// Portal child is in target, NOT in container.
|
|
195
|
+
expect(container.querySelector('#teleported')).toBeNull()
|
|
196
|
+
expect(target.querySelector('#teleported')?.textContent).toBe('over there')
|
|
197
|
+
unmount()
|
|
198
|
+
target.remove()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('Transition — show=false applies leave classes; transitionend removes element', async () => {
|
|
202
|
+
const visible = signal(true)
|
|
203
|
+
const { container, unmount } = mountInBrowser(
|
|
204
|
+
h(
|
|
205
|
+
Transition,
|
|
206
|
+
{ name: 'fade', show: () => visible() },
|
|
207
|
+
// Real CSS transition so transitionend actually fires when the
|
|
208
|
+
// class swap changes opacity (not just instantly).
|
|
209
|
+
h('div', { id: 'fading', style: 'transition: opacity 30ms; opacity: 1' }, 'hello'),
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
await flush()
|
|
213
|
+
expect(container.querySelector('#fading')).not.toBeNull()
|
|
214
|
+
|
|
215
|
+
visible.set(false)
|
|
216
|
+
// After two rAFs the leave-active + leave-to classes are applied.
|
|
217
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
|
218
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
|
219
|
+
|
|
220
|
+
const stillRendered = container.querySelector('#fading')
|
|
221
|
+
if (stillRendered) {
|
|
222
|
+
// Expect at least one of the fade-leave classes during the
|
|
223
|
+
// active phase.
|
|
224
|
+
expect(stillRendered.className).toMatch(/fade-leave/)
|
|
225
|
+
// Manually fire transitionend to short-circuit the 5s safety
|
|
226
|
+
// timeout (we don't care about real timing here, only that the
|
|
227
|
+
// event-driven cleanup path works).
|
|
228
|
+
stillRendered.dispatchEvent(new Event('transitionend', { bubbles: true }))
|
|
229
|
+
}
|
|
230
|
+
await flush()
|
|
231
|
+
await new Promise<void>((r) => setTimeout(r, 16))
|
|
232
|
+
expect(container.querySelector('#fading')).toBeNull()
|
|
233
|
+
unmount()
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('two mount() roots stay isolated — events on one do not affect the other', async () => {
|
|
237
|
+
const c1 = signal(0)
|
|
238
|
+
const c2 = signal(0)
|
|
239
|
+
const root1 = document.createElement('div')
|
|
240
|
+
const root2 = document.createElement('div')
|
|
241
|
+
document.body.append(root1, root2)
|
|
242
|
+
|
|
243
|
+
const u1 = mount(
|
|
244
|
+
h('button', { id: 'b1', onClick: () => c1.set(c1() + 1) }, () => `c1=${c1()}`),
|
|
245
|
+
root1,
|
|
246
|
+
)
|
|
247
|
+
const u2 = mount(
|
|
248
|
+
h('button', { id: 'b2', onClick: () => c2.set(c2() + 1) }, () => `c2=${c2()}`),
|
|
249
|
+
root2,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
root1.querySelector<HTMLButtonElement>('#b1')!.click()
|
|
253
|
+
root1.querySelector<HTMLButtonElement>('#b1')!.click()
|
|
254
|
+
root2.querySelector<HTMLButtonElement>('#b2')!.click()
|
|
255
|
+
await flush()
|
|
256
|
+
|
|
257
|
+
expect(c1()).toBe(2)
|
|
258
|
+
expect(c2()).toBe(1)
|
|
259
|
+
|
|
260
|
+
u1()
|
|
261
|
+
u2()
|
|
262
|
+
root1.remove()
|
|
263
|
+
root2.remove()
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('event delegation — multi-word event names like onPointerDown actually fire', async () => {
|
|
267
|
+
// Regression for the bug fixed alongside this PR:
|
|
268
|
+
// `onPointerDown` was being lowercased to `pointerDown` for the
|
|
269
|
+
// DELEGATED_EVENTS lookup, missing the all-lowercase entry, so the
|
|
270
|
+
// handler was attached via addEventListener('pointerDown', ...) which
|
|
271
|
+
// never fires. Same for mousedown, dblclick, touchstart, etc.
|
|
272
|
+
let pointerDownFired = 0
|
|
273
|
+
let dblClickFired = 0
|
|
274
|
+
const { container, unmount } = mountInBrowser(
|
|
275
|
+
h('div', {
|
|
276
|
+
id: 'evt',
|
|
277
|
+
onPointerDown: () => {
|
|
278
|
+
pointerDownFired++
|
|
279
|
+
},
|
|
280
|
+
onDblClick: () => {
|
|
281
|
+
dblClickFired++
|
|
282
|
+
},
|
|
283
|
+
}),
|
|
284
|
+
)
|
|
285
|
+
const target = container.querySelector('#evt')!
|
|
286
|
+
target.dispatchEvent(
|
|
287
|
+
new PointerEvent('pointerdown', { bubbles: true, pointerId: 1 }),
|
|
288
|
+
)
|
|
289
|
+
target.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
|
|
290
|
+
await flush()
|
|
291
|
+
expect(pointerDownFired).toBe(1)
|
|
292
|
+
expect(dblClickFired).toBe(1)
|
|
293
|
+
unmount()
|
|
294
|
+
})
|
|
295
|
+
})
|