@pyreon/runtime-dom 0.14.0 → 0.16.0

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,219 @@
1
+ /** @jsxImportSource @pyreon/core */
2
+ /**
3
+ * Reproduction of the deferred bug from PR #490 (queryReactiveKey-1000 journey).
4
+ *
5
+ * The pure-reactivity unit test (packages/core/reactivity/src/tests/fanout-repro.test.ts)
6
+ * passes — many effects subscribing to one signal all fire on every external
7
+ * .set. So the bug must involve actual Pyreon mount frames (provide/onMount/etc),
8
+ * not just the reactivity primitives.
9
+ *
10
+ * This test mounts a real Pyreon component with N effects subscribing to a
11
+ * shared signal, then writes to that signal in a tight external loop —
12
+ * mirroring the real shape from the queryReactiveKey-1000 journey.
13
+ */
14
+ import { For, h } from '@pyreon/core'
15
+ import { effect, signal } from '@pyreon/reactivity'
16
+ import { describe, expect, it } from 'vitest'
17
+ import { mount } from '../index'
18
+
19
+ describe('signal fan-out under tight external write loop — INSIDE mount frame', () => {
20
+ it('100 effects in a Pyreon component — each fires on every external .set', () => {
21
+ const sig = signal(0)
22
+ const counts = new Array(100).fill(0)
23
+
24
+ const root = document.createElement('div')
25
+ document.body.appendChild(root)
26
+
27
+ const Component = () => {
28
+ for (let i = 0; i < 100; i++) {
29
+ const idx = i
30
+ effect(() => {
31
+ sig()
32
+ counts[idx]++
33
+ })
34
+ }
35
+ return h('div', null, 'mounted')
36
+ }
37
+
38
+ const dispose = mount(h(Component, null), root)
39
+
40
+ // All 100 effects ran their initial setup.
41
+ for (const c of counts) expect(c).toBe(1)
42
+
43
+ // 10 external writes outside any batch.
44
+ for (let i = 1; i <= 10; i++) sig.set(i)
45
+
46
+ // Each effect should have re-fired 10 more times → total = 11.
47
+ let failed = 0
48
+ for (let i = 0; i < counts.length; i++) {
49
+ if (counts[i] !== 11) failed++
50
+ }
51
+ expect(failed, `effects with wrong count (out of 100)`).toBe(0)
52
+
53
+ dispose()
54
+ root.remove()
55
+ })
56
+
57
+ it('100 effects + an extra effect AFTER the loop — all fire on each .set', () => {
58
+ // Real-bug shape: a diagnostic effect placed AFTER the useQuery loop
59
+ // saw 0 re-runs across 10 flips, while a BEFORE-loop one saw 1 of 10.
60
+ const sig = signal(0)
61
+ const counts = new Array(100).fill(0)
62
+ let beforeRuns = 0
63
+ let afterRuns = 0
64
+
65
+ const root = document.createElement('div')
66
+ document.body.appendChild(root)
67
+
68
+ const Component = () => {
69
+ effect(() => {
70
+ sig()
71
+ beforeRuns++
72
+ })
73
+ for (let i = 0; i < 100; i++) {
74
+ const idx = i
75
+ effect(() => {
76
+ sig()
77
+ counts[idx]++
78
+ })
79
+ }
80
+ effect(() => {
81
+ sig()
82
+ afterRuns++
83
+ })
84
+ return h('div', null, 'mounted')
85
+ }
86
+
87
+ const dispose = mount(h(Component, null), root)
88
+
89
+ expect(beforeRuns).toBe(1)
90
+ expect(afterRuns).toBe(1)
91
+ for (const c of counts) expect(c).toBe(1)
92
+
93
+ for (let i = 1; i <= 10; i++) sig.set(i)
94
+
95
+ expect(beforeRuns, 'before-loop effect runs').toBe(11)
96
+ expect(afterRuns, 'after-loop effect runs').toBe(11)
97
+ let failed = 0
98
+ for (let i = 0; i < counts.length; i++) {
99
+ if (counts[i] !== 11) failed++
100
+ }
101
+ expect(failed, `effects with wrong count`).toBe(0)
102
+
103
+ dispose()
104
+ root.remove()
105
+ })
106
+
107
+ it('the body of each effect ALSO writes to a per-effect local signal (mimics useQuery slot writes)', () => {
108
+ // useQuery's effect body calls observer.setOptions which triggers the
109
+ // observer's subscribe callback which does batch(() => 9 signal.sets).
110
+ // Approximate that with: each outer effect's body creates its own local
111
+ // signal and writes to it in a batch.
112
+ const sig = signal(0)
113
+ const counts = new Array(100).fill(0)
114
+
115
+ const root = document.createElement('div')
116
+ document.body.appendChild(root)
117
+
118
+ const Component = () => {
119
+ for (let i = 0; i < 100; i++) {
120
+ const idx = i
121
+ const slot = signal('')
122
+ effect(() => {
123
+ sig() // subscribe
124
+ // Mimic batched writes inside the effect body
125
+ slot.set(`run-${counts[idx]}`)
126
+ counts[idx]++
127
+ })
128
+ }
129
+ return h('div', null, 'mounted')
130
+ }
131
+
132
+ const dispose = mount(h(Component, null), root)
133
+
134
+ for (const c of counts) expect(c).toBe(1)
135
+
136
+ for (let i = 1; i <= 10; i++) sig.set(i)
137
+
138
+ let failed = 0
139
+ for (let i = 0; i < counts.length; i++) {
140
+ if (counts[i] !== 11) failed++
141
+ }
142
+ expect(failed, `effects with wrong count`).toBe(0)
143
+
144
+ dispose()
145
+ root.remove()
146
+ })
147
+
148
+ // ─── The real bug shape: <For> wrapping the queries ───────────────────────
149
+ //
150
+ // mountFor (`packages/core/runtime-dom/src/nodes.ts`) wraps its body in
151
+ // effect() but does NOT untrack the child mountChild calls (mountReactive
152
+ // does — line 92 of nodes.ts). As a result, any signal read during a
153
+ // child component's setup tracks against the For effect's run. When the
154
+ // tracked signal flips, For's effect re-runs → runCleanup() disposes ALL
155
+ // inner effects (the per-item setOptions effects) → handleIncrementalUpdate
156
+ // sees keys unchanged → does not re-mount → setOptions effects gone +
157
+ // never recreated. signalWrite fires N times but effectRun stays at the
158
+ // initial-mount count — the exact PR #490 observation.
159
+
160
+ it('REGRESSION: 100 effects mounted under <For> re-fire when shared signal flips', () => {
161
+ // Mirrors the queryReactiveKey-1000 shape: a mode/count tuple keys the
162
+ // For so its body mounts QueryAtScale-equivalent ONCE. Inside, N effects
163
+ // subscribe to a separate `reactKey` signal. External flips of reactKey
164
+ // must propagate to all N inner effects.
165
+ const reactKey = signal(0)
166
+ const counts = new Array(100).fill(0)
167
+ const root = document.createElement('div')
168
+ document.body.appendChild(root)
169
+
170
+ // Stable single-item array — For mounts the inner component exactly once.
171
+ const items = [{ id: 1 }] as const
172
+ type Item = (typeof items)[number]
173
+
174
+ const Inner = (props: { item: Item }) => {
175
+ // Read props.item to keep the component honest — same shape as
176
+ // QueryAtScale (which reads props.mode + props.count). That read
177
+ // tracks against the OUTER For effect via makeReactiveProps' getter.
178
+ void props.item.id
179
+
180
+ // Mimic useQuery's "read signal at construction time, OUTSIDE the
181
+ // inner effect" pattern. This is what `new QueryObserver(client,
182
+ // options())` does — options() reads reactKey while activeEffect is
183
+ // the outer effect (For's run), leaking the subscription up.
184
+ const _seed = reactKey() // ← this is the leak
185
+
186
+ // Plus: 100 effects each subscribing to reactKey via their own bodies.
187
+ for (let i = 0; i < 100; i++) {
188
+ const idx = i
189
+ effect(() => {
190
+ reactKey()
191
+ counts[idx]++
192
+ })
193
+ }
194
+ return h('div', { 'data-testid': 'inner' }, `mounted ${_seed}`)
195
+ }
196
+
197
+ const dispose = mount(
198
+ h(For, {
199
+ each: items,
200
+ by: (it: Item) => it.id,
201
+ children: (it: Item) => h(Inner, { item: it }),
202
+ }),
203
+ root,
204
+ )
205
+
206
+ for (const c of counts) expect(c).toBe(1)
207
+
208
+ for (let i = 1; i <= 10; i++) reactKey.set(i)
209
+
210
+ let failed = 0
211
+ for (let i = 0; i < counts.length; i++) {
212
+ if (counts[i] !== 11) failed++
213
+ }
214
+ expect(failed, `effects with wrong count after 10 flips`).toBe(0)
215
+
216
+ dispose()
217
+ root.remove()
218
+ })
219
+ })
@@ -5,7 +5,7 @@
5
5
  * hydrate on client, verify signals work and DOM is reused.
6
6
  */
7
7
  import type { VNodeChild } from '@pyreon/core'
8
- import { For, Fragment, h, Show } from '@pyreon/core'
8
+ import { _rp, For, Fragment, h, Show } from '@pyreon/core'
9
9
  import { signal } from '@pyreon/reactivity'
10
10
  import { renderToString } from '@pyreon/runtime-server'
11
11
  import { disableHydrationWarnings, enableHydrationWarnings, hydrateRoot } from '../index'
@@ -373,3 +373,168 @@ describe('hydration integration — mismatch recovery', () => {
373
373
  cleanup()
374
374
  })
375
375
  })
376
+
377
+ // ─── onHydrationMismatch telemetry hook ────────────────────────────────────
378
+ //
379
+ // Pre-fix: runtime-dom emitted hydration mismatches via console.warn ONLY,
380
+ // gated on __DEV__. Production deployments (Sentry, Datadog) had no
381
+ // integration point — mismatches surfaced as silent recovery (text
382
+ // rewritten or DOM remounted) with no telemetry signal. The asymmetry
383
+ // with `@pyreon/core`'s `registerErrorHandler` (which captures component
384
+ // + reactivity errors via the `__pyreon_report_error__` bridge) was the
385
+ // gap.
386
+ //
387
+ // Post-fix: `onHydrationMismatch(handler)` registers a callback fired on
388
+ // EVERY mismatch in dev AND prod, independent of the warn toggle.
389
+ // Mirrors core's `registerErrorHandler` shape.
390
+ describe('hydration integration — onHydrationMismatch telemetry hook', () => {
391
+ test('handler fires with full mismatch context on tag mismatch', async () => {
392
+ const { onHydrationMismatch } = await import('../hydration-debug')
393
+ const captured: Array<{ type: string; expected: unknown; actual: unknown; path: string; timestamp: number }> = []
394
+ const unsub = onHydrationMismatch((ctx) => {
395
+ captured.push({
396
+ type: ctx.type,
397
+ expected: ctx.expected,
398
+ actual: ctx.actual,
399
+ path: ctx.path,
400
+ timestamp: ctx.timestamp,
401
+ })
402
+ })
403
+
404
+ const el = container()
405
+ el.innerHTML = '<div>server content</div>'
406
+
407
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
408
+ const cleanup = hydrateRoot(el, h('span', null, 'client content'))
409
+
410
+ expect(captured.length).toBeGreaterThan(0)
411
+ const tagMismatch = captured.find((c) => c.type === 'tag')
412
+ expect(tagMismatch).toBeDefined()
413
+ expect(tagMismatch?.expected).toBe('span')
414
+ expect(typeof tagMismatch?.path).toBe('string')
415
+ expect(typeof tagMismatch?.timestamp).toBe('number')
416
+
417
+ cleanup()
418
+ unsub()
419
+ warnSpy.mockRestore()
420
+ })
421
+
422
+ test('handler fires for tag mismatch in production-style silence (warn disabled)', () => {
423
+ const el = container()
424
+ el.innerHTML = '<div>server content</div>'
425
+
426
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
427
+ disableHydrationWarnings() // simulate production: warns silenced
428
+
429
+ return import('../hydration-debug').then(({ onHydrationMismatch }) => {
430
+ const captured: Array<{ type: string }> = []
431
+ const unsub = onHydrationMismatch((ctx) => {
432
+ captured.push({ type: ctx.type })
433
+ })
434
+
435
+ const cleanup = hydrateRoot(el, h('span', null, 'client content'))
436
+
437
+ // Telemetry hook fired even with warn disabled — independent.
438
+ expect(captured.length).toBeGreaterThan(0)
439
+ expect(captured.some((c) => c.type === 'tag')).toBe(true)
440
+ // console.warn was NOT called (production-style silence).
441
+ expect(warnSpy).not.toHaveBeenCalled()
442
+
443
+ cleanup()
444
+ unsub()
445
+ warnSpy.mockRestore()
446
+ enableHydrationWarnings()
447
+ })
448
+ })
449
+
450
+ test('multiple handlers all receive forwarded mismatches; unsub stops one cleanly', async () => {
451
+ const { onHydrationMismatch } = await import('../hydration-debug')
452
+ let count1 = 0
453
+ let count2 = 0
454
+ const unsub1 = onHydrationMismatch(() => count1++)
455
+ const unsub2 = onHydrationMismatch(() => count2++)
456
+
457
+ const el = container()
458
+ el.innerHTML = '<div>server</div>'
459
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
460
+
461
+ const cleanup = hydrateRoot(el, h('span', null, 'client'))
462
+
463
+ expect(count1).toBeGreaterThan(0)
464
+ expect(count1).toBe(count2)
465
+
466
+ // Unsubscribe one — only the other fires next time.
467
+ unsub1()
468
+ const before2 = count2
469
+ const el2 = container()
470
+ el2.innerHTML = '<p>foo</p>'
471
+ const cleanup2 = hydrateRoot(el2, h('article', null, 'bar'))
472
+
473
+ expect(count2).toBeGreaterThan(before2)
474
+
475
+ cleanup()
476
+ cleanup2()
477
+ unsub2()
478
+ warnSpy.mockRestore()
479
+ })
480
+
481
+ test('handler errors do not propagate into hydration', async () => {
482
+ const { onHydrationMismatch } = await import('../hydration-debug')
483
+ let goodHandlerFired = false
484
+ const unsubBad = onHydrationMismatch(() => {
485
+ throw new Error('telemetry SDK exploded')
486
+ })
487
+ const unsubGood = onHydrationMismatch(() => {
488
+ goodHandlerFired = true
489
+ })
490
+
491
+ const el = container()
492
+ el.innerHTML = '<div>server</div>'
493
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
494
+ disableHydrationWarnings()
495
+
496
+ // Hydration must complete without throwing despite bad handler.
497
+ const cleanup = hydrateRoot(el, h('span', null, 'client'))
498
+ expect(goodHandlerFired).toBe(true)
499
+ // Client content still rendered — recovery worked.
500
+ expect(el.textContent).toContain('client')
501
+
502
+ cleanup()
503
+ unsubBad()
504
+ unsubGood()
505
+ warnSpy.mockRestore()
506
+ enableHydrationWarnings()
507
+ })
508
+ })
509
+
510
+ // ─── _rp prop forwarding through SSR -> hydrate ─────────────────────────────
511
+
512
+ describe('hydration integration — `_rp`-wrapped component props (regression)', () => {
513
+ // Pre-fix, hydrate.ts skipped `makeReactiveProps` on the way into a
514
+ // component, so `props.x` returned the raw `_rp` function instead of the
515
+ // resolved value. mount.ts already did the right thing, so the failure mode
516
+ // surfaced only on cold-start SSR/hydrate (the fundamentals NavItem layout
517
+ // shape — see e2e/fundamentals/playground.spec.ts). Lock in BOTH the SSR
518
+ // emit and the post-hydration value.
519
+ test('SSR emits resolved string from `_rp` prop, hydration preserves it', async () => {
520
+ const Link = (props: { to: string }) =>
521
+ h('a', { href: `#${props.to}`, id: 'lnk' }, () => props.to)
522
+
523
+ const html = await renderToString(
524
+ h(Link, { to: _rp(() => '/about') as unknown as string }),
525
+ )
526
+ expect(html).toBe('<a href="#/about" id="lnk">/about</a>')
527
+ expect(html).not.toContain('=>')
528
+
529
+ const el = container()
530
+ el.innerHTML = html
531
+ const cleanup = hydrateRoot(
532
+ el,
533
+ h(Link, { to: _rp(() => '/about') as unknown as string }),
534
+ )
535
+ const link = el.querySelector<HTMLAnchorElement>('#lnk')!
536
+ expect(link.getAttribute('href')).toBe('#/about')
537
+ expect(link.textContent).toBe('/about')
538
+ cleanup()
539
+ })
540
+ })
@@ -7,12 +7,14 @@ import {
7
7
  For,
8
8
  Fragment,
9
9
  h,
10
+ lazy,
10
11
  Match,
11
12
  onMount,
12
13
  onUnmount,
13
14
  onUpdate,
14
15
  Portal,
15
16
  Show,
17
+ Suspense as _Suspense,
16
18
  Switch,
17
19
  } from '@pyreon/core'
18
20
  import { cell, signal } from '@pyreon/reactivity'
@@ -36,6 +38,7 @@ const Transition = _Transition as unknown as ComponentFn<Record<string, unknown>
36
38
  const TransitionGroup = _TransitionGroup as unknown as ComponentFn<Record<string, unknown>>
37
39
  const ErrorBoundary = _ErrorBoundary as unknown as ComponentFn<Record<string, unknown>>
38
40
  const KeepAlive = _KeepAlive as unknown as ComponentFn<Record<string, unknown>>
41
+ const Suspense = _Suspense as unknown as ComponentFn<Record<string, unknown>>
39
42
 
40
43
  function container(): HTMLElement {
41
44
  const el = document.createElement('div')
@@ -773,6 +776,94 @@ describe('ErrorBoundary', () => {
773
776
  ;(el.querySelector('#fix') as HTMLButtonElement).click()
774
777
  expect(el.querySelector('#signal-ok')?.textContent).toBe('fixed')
775
778
  })
779
+
780
+ // ── lazy() + Suspense + ErrorBoundary integration ──
781
+ //
782
+ // The `lazy(loader)` wrapper throws synchronously when its loader's
783
+ // promise rejects (`error()` returns truthy → `throw err`).
784
+ //
785
+ // Pyreon components run ONCE — reactivity comes from reading signals
786
+ // inside reactive scopes. `lazy()`'s wrapper reads its `error` /
787
+ // `loaded` signals inline, so the surrounding context must be a
788
+ // reactive scope for signal changes to trigger re-render.
789
+ //
790
+ // `Suspense` wraps its children in `h(Fragment, null, () => ...)` —
791
+ // an explicit reactive accessor that calls `__loading()`. THAT
792
+ // accessor's reactive scope is what tracks lazy's signals: when the
793
+ // loader rejects, the accessor re-runs, the lazy child re-mounts,
794
+ // the wrapper throws, mountComponent catches, dispatches to the
795
+ // nearest `<ErrorBoundary>` on the boundary stack.
796
+ //
797
+ // Without Suspense, lazy()'s post-mount errors don't surface (no
798
+ // reactive scope to drive re-render). This is consistent with the
799
+ // framework's component-runs-once contract — but worth pinning
800
+ // down with explicit tests.
801
+
802
+ test('lazy() loader rejection surfaces to ErrorBoundary via Suspense', async () => {
803
+ const el = container()
804
+ const Comp = lazy<Record<string, never>>(() =>
805
+ Promise.reject(new Error('module load failed')),
806
+ )
807
+
808
+ mount(
809
+ h(ErrorBoundary, {
810
+ fallback: (err: unknown) =>
811
+ h('p', { id: 'lazy-fb' }, `Caught: ${(err as Error).message}`),
812
+ children: h(
813
+ Suspense,
814
+ { fallback: h('p', { id: 'spinner' }, 'loading...') },
815
+ h(Comp, {}),
816
+ ),
817
+ }),
818
+ el,
819
+ )
820
+
821
+ // Initial render: lazy is still loading → Suspense shows spinner,
822
+ // boundary fallback NOT triggered yet.
823
+ expect(el.querySelector('#spinner')).not.toBeNull()
824
+ expect(el.querySelector('#lazy-fb')).toBeNull()
825
+
826
+ // Wait for promise rejection to flush.
827
+ await new Promise((r) => setTimeout(r, 0))
828
+ // Reactive flush.
829
+ await Promise.resolve()
830
+
831
+ // After load fails: Suspense's reactive accessor re-runs → child
832
+ // wrapper throws → caught by mountComponent → dispatched to
833
+ // ErrorBoundary → fallback rendered.
834
+ expect(el.querySelector('#lazy-fb')?.textContent).toContain('module load failed')
835
+ expect(el.querySelector('#spinner')).toBeNull()
836
+ })
837
+
838
+ test('lazy() resolves successfully renders content without firing fallback', async () => {
839
+ const el = container()
840
+ const Inner: ComponentFn<Record<string, never>> = () =>
841
+ h('p', { id: 'loaded' }, 'content')
842
+ const Comp = lazy<Record<string, never>>(() => Promise.resolve({ default: Inner }))
843
+
844
+ let fallbackInvocations = 0
845
+ mount(
846
+ h(ErrorBoundary, {
847
+ fallback: () => {
848
+ fallbackInvocations++
849
+ return h('p', { id: 'should-not-appear' }, 'error')
850
+ },
851
+ children: h(
852
+ Suspense,
853
+ { fallback: h('p', { id: 'spinner' }, 'loading...') },
854
+ h(Comp, {}),
855
+ ),
856
+ }),
857
+ el,
858
+ )
859
+
860
+ await new Promise((r) => setTimeout(r, 0))
861
+ await Promise.resolve()
862
+
863
+ expect(el.querySelector('#loaded')?.textContent).toBe('content')
864
+ expect(el.querySelector('#should-not-appear')).toBeNull()
865
+ expect(fallbackInvocations).toBe(0)
866
+ })
776
867
  })
777
868
 
778
869
  // ─── Transition component ─────────────────────────────────────────────────────
@@ -1520,7 +1611,7 @@ describe('mount — edge cases', () => {
1520
1611
 
1521
1612
  test('mounting array of children', () => {
1522
1613
  const el = container()
1523
- mount(h('div', null, ...[h('span', null, 'a'), h('span', null, 'b'), h('span', null, 'c')]), el)
1614
+ mount(h('div', null, h('span', null, 'a'), h('span', null, 'b'), h('span', null, 'c')), el)
1524
1615
  expect(el.querySelectorAll('span').length).toBe(3)
1525
1616
  })
1526
1617
 
@@ -0,0 +1,19 @@
1
+ import { isNativeCompat } from '@pyreon/core'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { KeepAlive } from '../keep-alive'
4
+ import { Transition } from '../transition'
5
+ import { TransitionGroup } from '../transition-group'
6
+
7
+ // Marker-presence assertion (PR 3 lock-in). Bisect-verified: removing
8
+ // `nativeCompat(...)` from any of these files fails the corresponding test.
9
+ describe('native-compat markers — @pyreon/runtime-dom', () => {
10
+ it('Transition is marked native', () => {
11
+ expect(isNativeCompat(Transition)).toBe(true)
12
+ })
13
+ it('TransitionGroup is marked native', () => {
14
+ expect(isNativeCompat(TransitionGroup)).toBe(true)
15
+ })
16
+ it('KeepAlive is marked native', () => {
17
+ expect(isNativeCompat(KeepAlive)).toBe(true)
18
+ })
19
+ })
@@ -149,6 +149,57 @@ describe('runtime-dom in real browser', () => {
149
149
  unmount()
150
150
  })
151
151
 
152
+ it('delegated event handler sees `currentTarget` as the bound element, not the listener root', async () => {
153
+ // Regression for a real Pyreon framework bug found via PR #329's form
154
+ // section. Pyreon's TargetedEvent<E> type promises `currentTarget` is
155
+ // the per-element type (e.g. HTMLInputElement), but native event
156
+ // delegation leaves `currentTarget` as the container (where the
157
+ // listener is registered). User code that writes
158
+ // `(ev.currentTarget as HTMLInputElement).value` would silently read
159
+ // from a <div> and get undefined.
160
+ //
161
+ // The fix is in delegate.ts: per-handler Object.defineProperty
162
+ // override of currentTarget, matching React/Vue/Solid behavior.
163
+ const { container, unmount } = mountInBrowser(
164
+ h(
165
+ 'div',
166
+ { id: 'wrap' },
167
+ h('input', {
168
+ id: 'inp',
169
+ type: 'text',
170
+ 'data-marker': 'real-input',
171
+ onInput: (ev: Event) => {
172
+ const ct = ev.currentTarget as HTMLInputElement | null
173
+ // Capture observable signals so the test can assert on them
174
+ ;(globalThis as { __test_ct_tag?: string | undefined }).__test_ct_tag = ct?.tagName
175
+ ;(globalThis as { __test_ct_value?: string | undefined }).__test_ct_value =
176
+ ct?.value
177
+ ;(globalThis as { __test_ct_marker?: string | null | undefined }).__test_ct_marker =
178
+ ct?.getAttribute('data-marker') ?? null
179
+ },
180
+ }),
181
+ ),
182
+ )
183
+
184
+ const inp = container.querySelector<HTMLInputElement>('#inp')!
185
+ inp.value = 'hello'
186
+ inp.dispatchEvent(new Event('input', { bubbles: true }))
187
+ await flush()
188
+
189
+ // Without the fix: tagName would be 'DIV' (or whatever container is),
190
+ // value would be undefined, marker would be null.
191
+ expect((globalThis as { __test_ct_tag?: string | undefined }).__test_ct_tag).toBe('INPUT')
192
+ expect((globalThis as { __test_ct_value?: string | undefined }).__test_ct_value).toBe('hello')
193
+ expect((globalThis as { __test_ct_marker?: string | null | undefined }).__test_ct_marker).toBe(
194
+ 'real-input',
195
+ )
196
+
197
+ unmount()
198
+ delete (globalThis as { __test_ct_tag?: string | undefined }).__test_ct_tag
199
+ delete (globalThis as { __test_ct_value?: string | undefined }).__test_ct_value
200
+ delete (globalThis as { __test_ct_marker?: string | null | undefined }).__test_ct_marker
201
+ })
202
+
152
203
  it('dispatches a real PointerEvent and fires the onClick handler', async () => {
153
204
  const clicks = signal(0)
154
205
  const { container, unmount } = mountInBrowser(
@@ -177,12 +228,13 @@ describe('runtime-dom in real browser', () => {
177
228
  unmount()
178
229
  })
179
230
 
180
- it('emits the duplicate-key __DEV__ warning under Vite (DEV=true)', async () => {
181
- // import.meta.env.DEV is true in this dev-mode browser run, which is the
182
- // exact replacement Vite/Rolldown apply at build-time. The warning must
183
- // fire here. The companion `runtime-dom.prod-bundle.test.ts` Node test
184
- // proves the same code path is dead in a prod bundle (DEV=false).
185
- expect(import.meta.env.DEV).toBe(true)
231
+ it('emits the duplicate-key dev warning under non-production NODE_ENV', async () => {
232
+ // process.env.NODE_ENV !== 'production' in this dev browser run the
233
+ // bundler-agnostic gate that every modern bundler auto-replaces at
234
+ // consumer build time. The warning must fire here. The companion
235
+ // `runtime-dom.prod-bundle.test.ts` Node test proves the same code path
236
+ // is dead in a prod bundle (NODE_ENV='production').
237
+ expect(process.env.NODE_ENV).not.toBe('production')
186
238
 
187
239
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
188
240
  const dupes = signal([