@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.
@@ -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
+ })
@@ -219,21 +219,22 @@ describe('mount — refs', () => {
219
219
  expect(refEl).toBeInstanceOf(HTMLDivElement)
220
220
  })
221
221
 
222
- test('callback ref element is not nulled on unmount', () => {
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', () => {
@@ -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
- expect(warnSpy).toHaveBeenCalledWith(
267
- expect.stringContaining('dangerouslySetInnerHTML bypasses sanitization'),
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
+ })