@pyreon/runtime-dom 0.14.0 → 0.15.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.
@@ -1,18 +1,42 @@
1
1
  /**
2
- * Hydration mismatch warnings.
2
+ * Hydration mismatch warnings + telemetry hook.
3
3
  *
4
- * Enabled automatically in development (NODE_ENV !== "production").
5
- * Can be toggled manually for testing or verbose production debugging.
4
+ * Two complementary surfaces:
6
5
  *
7
- * @example
6
+ * 1. **Dev-mode console.warn** — enabled automatically when
7
+ * `NODE_ENV !== "production"` (and silent otherwise, matching React /
8
+ * Vue / Solid). Toggle manually with `enableHydrationWarnings()` /
9
+ * `disableHydrationWarnings()` if you need verbose production debugging.
10
+ *
11
+ * 2. **Telemetry callback** — register a handler with
12
+ * `onHydrationMismatch(handler)` to forward every mismatch into your
13
+ * error-tracking pipeline (Sentry, Datadog, etc.). Fires on EVERY
14
+ * mismatch, in development AND production, regardless of the warn
15
+ * toggle. Returns an unregister function.
16
+ *
17
+ * The dev warn and the telemetry callback are independent: a production
18
+ * deployment can install Sentry forwarding via `onHydrationMismatch`
19
+ * WITHOUT enabling the noisy console output.
20
+ *
21
+ * @example — dev console
8
22
  * import { enableHydrationWarnings } from "@pyreon/runtime-dom"
9
23
  * enableHydrationWarnings()
24
+ *
25
+ * @example — production telemetry
26
+ * import { onHydrationMismatch } from "@pyreon/runtime-dom"
27
+ * import * as Sentry from "@sentry/browser"
28
+ *
29
+ * onHydrationMismatch(ctx => {
30
+ * Sentry.captureMessage(`Hydration mismatch (${ctx.type})`, {
31
+ * extra: { expected: ctx.expected, actual: ctx.actual, path: ctx.path },
32
+ * level: 'warning',
33
+ * })
34
+ * })
10
35
  */
11
36
 
12
37
  // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
13
38
  // uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
14
- // @ts-ignore `import.meta.env.DEV` is provided by Vite/Rolldown at build time
15
- const __DEV__ = import.meta.env?.DEV === true
39
+ const __DEV__ = process.env.NODE_ENV !== 'production'
16
40
 
17
41
  let _enabled = __DEV__
18
42
 
@@ -24,6 +48,43 @@ export function disableHydrationWarnings(): void {
24
48
  _enabled = false
25
49
  }
26
50
 
51
+ // ─── Telemetry callback ─────────────────────────────────────────────────────
52
+
53
+ export type HydrationMismatchType = 'tag' | 'text' | 'missing'
54
+
55
+ export interface HydrationMismatchContext {
56
+ /** Kind of mismatch */
57
+ type: HydrationMismatchType
58
+ /** What the VNode expected */
59
+ expected: unknown
60
+ /** What the DOM had */
61
+ actual: unknown
62
+ /** Human-readable path in the tree, e.g. "root > div > span" */
63
+ path: string
64
+ /** Unix timestamp (ms) */
65
+ timestamp: number
66
+ }
67
+
68
+ export type HydrationMismatchHandler = (ctx: HydrationMismatchContext) => void
69
+
70
+ let _handlers: HydrationMismatchHandler[] = []
71
+
72
+ /**
73
+ * Register a hydration mismatch handler. Called on every mismatch in BOTH
74
+ * development and production, independent of the dev-mode warn toggle.
75
+ *
76
+ * Mirrors `@pyreon/core`'s `registerErrorHandler` pattern — multiple
77
+ * handlers can be registered; each is called in registration order;
78
+ * handler errors are swallowed so they don't propagate into the
79
+ * framework. Returns an unregister function.
80
+ */
81
+ export function onHydrationMismatch(handler: HydrationMismatchHandler): () => void {
82
+ _handlers.push(handler)
83
+ return () => {
84
+ _handlers = _handlers.filter((h) => h !== handler)
85
+ }
86
+ }
87
+
27
88
  /**
28
89
  * Emit a hydration mismatch warning.
29
90
  * @param type - Kind of mismatch
@@ -32,13 +93,37 @@ export function disableHydrationWarnings(): void {
32
93
  * @param path - Human-readable path in the tree, e.g. "root > div > span"
33
94
  */
34
95
  export function warnHydrationMismatch(
35
- _type: 'tag' | 'text' | 'missing',
36
- _expected: unknown,
37
- _actual: unknown,
38
- _path: string,
96
+ type: HydrationMismatchType,
97
+ expected: unknown,
98
+ actual: unknown,
99
+ path: string,
39
100
  ): void {
40
- if (!_enabled) return
41
- console.warn(
42
- `[Pyreon] Hydration mismatch (${_type}): expected ${String(_expected)}, got ${String(_actual)} at ${_path}`,
43
- )
101
+ // Dev-mode console.warn — gated on _enabled (default __DEV__).
102
+ if (_enabled) {
103
+ // oxlint-disable-next-line no-console
104
+ console.warn(
105
+ `[Pyreon] Hydration mismatch (${type}): expected ${String(expected)}, got ${String(actual)} at ${path}`,
106
+ )
107
+ }
108
+
109
+ // Telemetry callbacks — fire in BOTH dev and prod, independent of the
110
+ // warn toggle. This is the production observability hook (Sentry,
111
+ // Datadog, etc.) that pre-fix was missing entirely.
112
+ if (_handlers.length > 0) {
113
+ const ctx: HydrationMismatchContext = {
114
+ type,
115
+ expected,
116
+ actual,
117
+ path,
118
+ timestamp: Date.now(),
119
+ }
120
+ for (const h of _handlers) {
121
+ try {
122
+ h(ctx)
123
+ } catch {
124
+ // handler errors must never propagate back into the hydration
125
+ // pipeline — a broken Sentry SDK shouldn't crash the app.
126
+ }
127
+ }
128
+ }
44
129
  }
package/src/index.ts CHANGED
@@ -3,7 +3,16 @@
3
3
  export { DELEGATED_EVENTS, delegatedPropName, setupDelegation } from './delegate'
4
4
  export type { DevtoolsComponentEntry, PyreonDevtools } from './devtools'
5
5
  export { hydrateRoot } from './hydrate'
6
- export { disableHydrationWarnings, enableHydrationWarnings } from './hydration-debug'
6
+ export type {
7
+ HydrationMismatchContext,
8
+ HydrationMismatchHandler,
9
+ HydrationMismatchType,
10
+ } from './hydration-debug'
11
+ export {
12
+ disableHydrationWarnings,
13
+ enableHydrationWarnings,
14
+ onHydrationMismatch,
15
+ } from './hydration-debug'
7
16
  export type { KeepAliveProps } from './keep-alive'
8
17
  export { KeepAlive } from './keep-alive'
9
18
  export { mountChild } from './mount'
@@ -28,8 +37,7 @@ import { mountChild } from './mount'
28
37
 
29
38
  // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
30
39
  // uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
31
- // @ts-ignore `import.meta.env.DEV` is provided by Vite/Rolldown at build time
32
- const __DEV__ = import.meta.env?.DEV === true
40
+ const __DEV__ = process.env.NODE_ENV !== 'production'
33
41
 
34
42
  // Dev-time counter sink — see packages/internals/perf-harness for contract.
35
43
  const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
package/src/keep-alive.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Props, VNodeChild } from '@pyreon/core'
2
- import { createRef, h, onMount } from '@pyreon/core'
2
+ import { createRef, h, nativeCompat, onMount } from '@pyreon/core'
3
3
  import { effect } from '@pyreon/reactivity'
4
4
  import { mountChild } from './mount'
5
5
 
@@ -70,3 +70,7 @@ export function KeepAlive(props: KeepAliveProps): VNodeChild {
70
70
  // (children appear as if directly in the parent flow)
71
71
  return h('div', { ref: containerRef, style: 'display: contents' })
72
72
  }
73
+
74
+ // Mark as native so compat-mode jsx() runtimes skip wrapCompatComponent —
75
+ // KeepAlive uses onMount + effect + mountChild that need Pyreon's setup frame.
76
+ nativeCompat(KeepAlive)
package/src/mount.ts CHANGED
@@ -25,8 +25,7 @@ import { applyProps } from './props'
25
25
 
26
26
  // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
27
27
  // uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
28
- // @ts-ignore `import.meta.env.DEV` is provided by Vite/Rolldown at build time
29
- const __DEV__ = import.meta.env?.DEV === true
28
+ const __DEV__ = process.env.NODE_ENV !== 'production'
30
29
 
31
30
  // Dev-time counter sink — see packages/internals/perf-harness for contract.
32
31
  const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
package/src/nodes.ts CHANGED
@@ -7,8 +7,7 @@ import { effect, runUntracked } from '@pyreon/reactivity'
7
7
 
8
8
  // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
9
9
  // uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
10
- // @ts-ignore `import.meta.env.DEV` is provided by Vite/Rolldown at build time
11
- const __DEV__ = import.meta.env?.DEV === true
10
+ const __DEV__ = process.env.NODE_ENV !== 'production'
12
11
 
13
12
  // Dev-time counter sink — see packages/internals/perf-harness for contract.
14
13
  const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
package/src/props.ts CHANGED
@@ -8,8 +8,7 @@ type Cleanup = () => void
8
8
 
9
9
  // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
10
10
  // uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
11
- // @ts-ignore `import.meta.env.DEV` is provided by Vite/Rolldown at build time
12
- const __DEV__ = import.meta.env?.DEV === true
11
+ const __DEV__ = process.env.NODE_ENV !== 'production'
13
12
 
14
13
  // ─── Configurable sanitizer ──────────────────────────────────────────────────
15
14
 
package/src/template.ts CHANGED
@@ -4,8 +4,7 @@ import { mountChild } from './mount'
4
4
 
5
5
  // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
6
6
  // uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
7
- // @ts-ignore `import.meta.env.DEV` is provided by Vite/Rolldown at build time
8
- const __DEV__ = import.meta.env?.DEV === true
7
+ const __DEV__ = process.env.NODE_ENV !== 'production'
9
8
 
10
9
  // Dev-time counter sink — see packages/internals/perf-harness for contract.
11
10
  const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
@@ -126,6 +125,22 @@ export function _bindDirect(
126
125
  // ─── Compiler-facing template API ─────────────────────────────────────────────
127
126
 
128
127
  // Cache parsed <template> elements by HTML string — parse once, clone many.
128
+ //
129
+ // LRU bound (audit bug #5): typical apps emit a small bounded set of unique
130
+ // HTML strings (one per JSX element tree the compiler hoists), so the cache
131
+ // stays in the dozens-to-hundreds in practice. But an app that constructs
132
+ // JSX from user input (or compiles many large dynamic templates) could grow
133
+ // this unbounded — every unique string holds a parsed <template> alive.
134
+ //
135
+ // Map preserves insertion order; on overflow we evict the OLDEST entry (the
136
+ // least-recently-inserted). Common HTML strings hit the cache before
137
+ // eviction; pathological inputs cycle through the cap without leaking.
138
+ //
139
+ // 1024 chosen as a balance: ~1024 unique templates × ~1KB parsed = ~1MB
140
+ // worst case — well within memory budget for any realistic app, and
141
+ // generous enough that no real codebase will hit the cap. Apps that
142
+ // genuinely need a different cap can swap their own _tpl wrapper.
143
+ const TPL_CACHE_MAX = 1024
129
144
  const _tplCache = new Map<string, HTMLTemplateElement>()
130
145
 
131
146
  /**
@@ -157,6 +172,18 @@ export function _tpl(html: string, bind: (el: HTMLElement) => (() => void) | nul
157
172
  if (!tpl) {
158
173
  tpl = document.createElement('template')
159
174
  tpl.innerHTML = html
175
+ // LRU eviction — drop the oldest entry once we hit the cap. Map
176
+ // iteration is insertion-order so the first key is always the
177
+ // oldest. delete() is O(1).
178
+ if (_tplCache.size >= TPL_CACHE_MAX) {
179
+ const oldest = _tplCache.keys().next().value
180
+ if (oldest !== undefined) _tplCache.delete(oldest)
181
+ }
182
+ _tplCache.set(html, tpl)
183
+ } else {
184
+ // LRU touch — re-insert moves to most-recent position so frequently
185
+ // used templates survive eviction.
186
+ _tplCache.delete(html)
160
187
  _tplCache.set(html, tpl)
161
188
  }
162
189
  const el = tpl.content.firstElementChild?.cloneNode(true) as HTMLElement
@@ -164,6 +191,23 @@ export function _tpl(html: string, bind: (el: HTMLElement) => (() => void) | nul
164
191
  return { __isNative: true, el, cleanup }
165
192
  }
166
193
 
194
+ /**
195
+ * Test-only: clear the template cache. Used by tests that assert on
196
+ * cache size; never called by runtime code. Not exported from the
197
+ * package's public index.
198
+ */
199
+ export function _clearTplCache(): void {
200
+ _tplCache.clear()
201
+ }
202
+
203
+ /**
204
+ * Test-only: read current cache size. Used by tests that assert
205
+ * eviction. Not exported from the package's public index.
206
+ */
207
+ export function _tplCacheSize(): number {
208
+ return _tplCache.size
209
+ }
210
+
167
211
  /**
168
212
  * Mount a children slot inside a template.
169
213
  *
@@ -8,22 +8,27 @@ const SRC = path.resolve(here, '..')
8
8
 
9
9
  // Source-pattern regression test for the dev-mode warning gate. Pairs with
10
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.
11
+ // fires in dev) — this asserts the gate is written using the bundler-agnostic
12
+ // pattern (`process.env.NODE_ENV !== 'production'`) that every modern bundler
13
+ // (Vite, Webpack/Next.js, esbuild, Rollup, Parcel, Bun) literal-replaces at
14
+ // consumer build time. The two previously-shipped broken patterns must not
15
+ // appear:
16
+ // 1. `typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'`
17
+ // — dead in Vite browser bundles.
18
+ // 2. `import.meta.env.DEV` — Vite/Rolldown-only; undefined and silent in
19
+ // Webpack/Next.js, esbuild, Rollup, Parcel, Bun.
14
20
  //
15
21
  // Same shape as `flow/src/tests/integration.test.ts:warnIgnoredOptions`.
16
22
  //
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.
23
+ // The lint rule `pyreon/no-process-dev-gate` is the CI-wide enforcement for
24
+ // this. This test is the narrow, package-local safety net so a regression in
25
+ // runtime-dom is caught even if the lint configuration drifts.
21
26
 
22
27
  const FILES_WITH_DEV_GATE = ['nodes.ts', 'hydration-debug.ts']
23
28
 
24
29
  describe('runtime-dom dev-warning gate (source pattern)', () => {
25
30
  for (const file of FILES_WITH_DEV_GATE) {
26
- it(`${file} uses import.meta.env.DEV, not typeof process`, async () => {
31
+ it(`${file} uses bundler-agnostic process.env.NODE_ENV`, async () => {
27
32
  const source = await readFile(path.join(SRC, file), 'utf8')
28
33
  // Strip line + block comments so referencing the broken pattern in
29
34
  // documentation doesn't false-positive.
@@ -31,10 +36,11 @@ describe('runtime-dom dev-warning gate (source pattern)', () => {
31
36
  .replace(/\/\*[\s\S]*?\*\//g, '')
32
37
  .replace(/(^|[^:])\/\/.*$/gm, '$1')
33
38
 
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.
39
+ // The bundler-agnostic gate must appear (bare `process.env.NODE_ENV`).
40
+ expect(code).toMatch(/process\.env\.NODE_ENV/)
41
+ // Neither broken pattern may appear in executable code.
37
42
  expect(code).not.toMatch(/typeof\s+process\s*!==?\s*['"]undefined['"]/)
43
+ expect(code).not.toMatch(/import\.meta\.env\??\.DEV/)
38
44
  })
39
45
  }
40
46
  })
@@ -8,28 +8,24 @@ import { build } from 'vite'
8
8
  const here = path.dirname(fileURLToPath(import.meta.url))
9
9
  const SRC = path.resolve(here, '..')
10
10
 
11
- // Bundle-level regression test for the T1.1 C-2 finding.
11
+ // Bundle-level regression test for the dev-warning gate.
12
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.
13
+ // runtime-dom uses bundler-agnostic `process.env.NODE_ENV !== 'production'`
14
+ // for dev gates the cross-bundler library convention used by React, Vue,
15
+ // Preact, Solid, MobX, Redux. Every modern bundler (Vite, Webpack/Next.js,
16
+ // esbuild, Rollup, Parcel, Bun) auto-replaces `process.env.NODE_ENV` at
17
+ // consumer build time. This test bundles each representative runtime-dom
18
+ // file through Vite's production build and asserts dev-warning strings
19
+ // are GONE from the output — proving literal-replacement + dead-code
20
+ // elimination work end-to-end.
18
21
  //
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.
22
+ // The test uses Vite because that's Pyreon's reference consumer pipeline
23
+ // today; the same files under Webpack / esbuild / Rollup etc. tree-shake
24
+ // equivalently because they all replace `process.env.NODE_ENV`. Vite is
25
+ // just the most-tested path.
26
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`).
27
+ // Scope note: `dev-gate-pattern.test.ts` is the cheap source-level guard
28
+ // (grep for the broken patterns, require bare `process.env.NODE_ENV`).
33
29
  // This test is the expensive end-to-end guard for the bundle path.
34
30
 
35
31
  interface FileContract {
@@ -84,19 +80,17 @@ const FILES_UNDER_TEST: FileContract[] = [
84
80
  async function bundleWithVite(entry: string, dev: boolean): Promise<string> {
85
81
  const outDir = mkdtempSync(path.join(tmpdir(), 'pyreon-vite-treeshake-'))
86
82
  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.
83
+ // Vite library-mode build with explicit minify. The bundler-agnostic
84
+ // gate uses `process.env.NODE_ENV` — Vite's library mode doesn't apply
85
+ // the default replacement automatically, so we set it ourselves to
86
+ // match what every modern bundler does at consumer build time.
90
87
  await build({
91
88
  mode: dev ? 'development' : 'production',
92
89
  logLevel: 'error',
93
90
  configFile: false,
94
91
  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
92
  define: {
98
- 'import.meta.env.DEV': JSON.stringify(dev),
99
- 'import.meta.env': JSON.stringify({ DEV: dev, PROD: !dev, MODE: dev ? 'development' : 'production' }),
93
+ 'process.env.NODE_ENV': JSON.stringify(dev ? 'development' : 'production'),
100
94
  },
101
95
  build: {
102
96
  // PINNED minifier: 'esbuild' is what Pyreon's reference consumers
@@ -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
+ })