Skip to content

Commit 68656d1

Browse files
fix(hydration): route mismatches via handleError when consumer set
Hydration mismatch errors were never routed through Vue's error handling pipeline, so onErrorCaptured and app.config.errorHandler could not catch them — making it hard to wire hydration mismatch reporting into observability tools (fix #13154). logMismatchError now calls handleError(..., HYDRATION_MISMATCH, false) only when an explicit consumer is present: - app.config.errorHandler is set, or - some ancestor has registered onErrorCaptured. Without a consumer, the per-mismatch warn() calls at the call sites remain the only output, matching the prior default behavior — no 'Unhandled error during execution of hydration' warning is emitted for SSR apps that have not opted in. Adds ErrorCodes.HYDRATION_MISMATCH plus its 'hydration' info label, passes the parent component instance to logMismatchError() at every call site, and adds tests covering: app.config.errorHandler consumer, onErrorCaptured consumer, single-emit semantics across multiple mismatches, and the no-consumer path. Signed-off-by: Pierluigi Lenoci <pierluigilenoci@gmail.com>
1 parent bbdf86d commit 68656d1

3 files changed

Lines changed: 132 additions & 11 deletions

File tree

packages/runtime-core/__tests__/hydration.spec.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
defineComponent,
2121
h,
2222
nextTick,
23+
onErrorCaptured,
2324
onMounted,
2425
onServerPrefetch,
2526
openBlock,
@@ -36,6 +37,7 @@ import type { HMRRuntime } from '../src/hmr'
3637
import { type SSRContext, renderToString } from '@vue/server-renderer'
3738
import { PatchFlags, normalizeStyle } from '@vue/shared'
3839
import { vShowOriginalDisplay } from '../../runtime-dom/src/directives/vShow'
40+
import { resetHydrationMismatchState } from '../src/hydration'
3941

4042
declare var __VUE_HMR_RUNTIME__: HMRRuntime
4143
const { createRecord, reload } = __VUE_HMR_RUNTIME__
@@ -2627,4 +2629,89 @@ describe('SSR hydration', () => {
26272629
expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
26282630
})
26292631
})
2632+
2633+
describe('hydration mismatch error handler', () => {
2634+
beforeEach(() => {
2635+
resetHydrationMismatchState()
2636+
})
2637+
2638+
test('app.config.errorHandler catches hydration mismatch', () => {
2639+
const container = document.createElement('div')
2640+
container.innerHTML = `<div><span>server</span></div>`
2641+
const handler = vi.fn()
2642+
const App = defineComponent({
2643+
render() {
2644+
return h('div', [h('span', 'client')])
2645+
},
2646+
})
2647+
const app = createSSRApp(App)
2648+
app.config.errorHandler = handler
2649+
app.mount(container)
2650+
expect(handler).toHaveBeenCalledTimes(1)
2651+
expect(handler.mock.calls[0][0]).toBeInstanceOf(Error)
2652+
expect(handler.mock.calls[0][0].message).toBe(
2653+
'Hydration completed but contains mismatches.',
2654+
)
2655+
expect(`Hydration text content mismatch`).toHaveBeenWarned()
2656+
})
2657+
2658+
test('onErrorCaptured catches hydration mismatch', () => {
2659+
const container = document.createElement('div')
2660+
container.innerHTML = `<div><span>server</span></div>`
2661+
const handler = vi.fn((_err: unknown) => false)
2662+
const Child = defineComponent({
2663+
render() {
2664+
return h('span', 'client')
2665+
},
2666+
})
2667+
const App = defineComponent({
2668+
setup() {
2669+
onErrorCaptured(handler)
2670+
return () => h('div', [h(Child)])
2671+
},
2672+
})
2673+
const app = createSSRApp(App)
2674+
app.mount(container)
2675+
expect(handler).toHaveBeenCalledTimes(1)
2676+
const err = handler.mock.calls[0][0]
2677+
expect(err).toBeInstanceOf(Error)
2678+
expect((err as Error).message).toBe(
2679+
'Hydration completed but contains mismatches.',
2680+
)
2681+
expect(`Hydration text content mismatch`).toHaveBeenWarned()
2682+
})
2683+
2684+
test('hydration mismatch error is only reported once', () => {
2685+
const container = document.createElement('div')
2686+
container.innerHTML = `<div><span>a</span><span>b</span></div>`
2687+
const handler = vi.fn()
2688+
const App = defineComponent({
2689+
render() {
2690+
return h('div', [h('span', 'x'), h('span', 'y')])
2691+
},
2692+
})
2693+
const app = createSSRApp(App)
2694+
app.config.errorHandler = handler
2695+
app.mount(container)
2696+
// Only one error even though there are two mismatches
2697+
expect(handler).toHaveBeenCalledTimes(1)
2698+
expect(`Hydration text content mismatch`).toHaveBeenWarned()
2699+
})
2700+
2701+
test('no Unhandled error warning when no errorHandler/onErrorCaptured', () => {
2702+
const container = document.createElement('div')
2703+
container.innerHTML = `<div><span>server</span></div>`
2704+
const App = defineComponent({
2705+
render() {
2706+
return h('div', [h('span', 'client')])
2707+
},
2708+
})
2709+
const app = createSSRApp(App)
2710+
app.mount(container)
2711+
expect(`Hydration text content mismatch`).toHaveBeenWarned()
2712+
expect(
2713+
`Unhandled error during execution of hydration`,
2714+
).not.toHaveBeenWarned()
2715+
})
2716+
})
26302717
})

packages/runtime-core/src/errorHandling.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export enum ErrorCodes {
2929
SCHEDULER,
3030
COMPONENT_UPDATE,
3131
APP_UNMOUNT_CLEANUP,
32+
HYDRATION_MISMATCH,
3233
}
3334

3435
export const ErrorTypeStrings: Record<ErrorTypes, string> = {
@@ -63,6 +64,7 @@ export const ErrorTypeStrings: Record<ErrorTypes, string> = {
6364
[ErrorCodes.SCHEDULER]: 'scheduler flush',
6465
[ErrorCodes.COMPONENT_UPDATE]: 'component update',
6566
[ErrorCodes.APP_UNMOUNT_CLEANUP]: 'app unmount cleanup function',
67+
[ErrorCodes.HYDRATION_MISMATCH]: 'hydration',
6668
}
6769

6870
export type ErrorTypes = LifecycleHooks | ErrorCodes | WatchErrorCodes

packages/runtime-core/src/hydration.ts

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { flushPostFlushCbs } from './scheduler'
1414
import type { ComponentInternalInstance, ComponentOptions } from './component'
1515
import { invokeDirectiveHook } from './directives'
1616
import { warn } from './warning'
17+
import { ErrorCodes, handleError } from './errorHandling'
1718
import {
1819
PatchFlags,
1920
ShapeFlags,
@@ -56,13 +57,44 @@ export enum DOMNodeTypes {
5657
}
5758

5859
let hasLoggedMismatchError = false
59-
const logMismatchError = () => {
60-
if (__TEST__ || hasLoggedMismatchError) {
60+
const logMismatchError = (
61+
instance: ComponentInternalInstance | null = null,
62+
) => {
63+
if (hasLoggedMismatchError) {
6164
return
6265
}
63-
// this error should show up in production
64-
console.error('Hydration completed but contains mismatches.')
6566
hasLoggedMismatchError = true
67+
68+
// Only route through handleError when there is an explicit consumer:
69+
// without one, it would surface an "Unhandled error" warning on top of
70+
// the per-mismatch warn() calls already produced at the call sites.
71+
if (
72+
instance &&
73+
(instance.appContext.config.errorHandler || hasErrorCaptured(instance))
74+
) {
75+
handleError(
76+
new Error('Hydration completed but contains mismatches.'),
77+
instance,
78+
ErrorCodes.HYDRATION_MISMATCH,
79+
false,
80+
)
81+
}
82+
}
83+
84+
function hasErrorCaptured(instance: ComponentInternalInstance): boolean {
85+
let cur = instance.parent
86+
while (cur) {
87+
if (cur.ec && cur.ec.length) return true
88+
cur = cur.parent
89+
}
90+
return false
91+
}
92+
93+
/**
94+
* @internal
95+
*/
96+
export function resetHydrationMismatchState(): void {
97+
hasLoggedMismatchError = false
6698
}
6799

68100
const isSVGContainer = (container: Element) =>
@@ -191,7 +223,7 @@ export function createHydrationFunctions(
191223
)}` +
192224
`\n - expected on client: ${JSON.stringify(vnode.children)}`,
193225
)
194-
logMismatchError()
226+
logMismatchError(parentComponent)
195227
;(node as Text).data = vnode.children as string
196228
}
197229
nextNode = nextSibling(node)
@@ -441,7 +473,7 @@ export function createHydrationFunctions(
441473
)
442474
hasWarned = true
443475
}
444-
logMismatchError()
476+
logMismatchError(parentComponent)
445477
}
446478

447479
// The SSRed DOM contains more nodes than it should. Remove them.
@@ -474,7 +506,7 @@ export function createHydrationFunctions(
474506
`\n - rendered on server: ${textContent}` +
475507
`\n - expected on client: ${clientText}`,
476508
)
477-
logMismatchError()
509+
logMismatchError(parentComponent)
478510
}
479511
el.textContent = vnode.children as string
480512
}
@@ -499,7 +531,7 @@ export function createHydrationFunctions(
499531
!(dirs && dirs.some(d => d.dir.created)) &&
500532
propHasMismatch(el, key, props[key], vnode, parentComponent)
501533
) {
502-
logMismatchError()
534+
logMismatchError(parentComponent)
503535
}
504536
if (
505537
(forcePatch &&
@@ -617,7 +649,7 @@ export function createHydrationFunctions(
617649
)
618650
hasWarned = true
619651
}
620-
logMismatchError()
652+
logMismatchError(parentComponent)
621653
}
622654

623655
// the SSRed DOM didn't contain enough nodes. Mount the missing ones.
@@ -666,7 +698,7 @@ export function createHydrationFunctions(
666698
} else {
667699
// fragment didn't hydrate successfully, since we didn't get a end anchor
668700
// back. This should have led to node/children mismatch warnings.
669-
logMismatchError()
701+
logMismatchError(parentComponent)
670702

671703
// since the anchor is missing, we need to create one and insert it
672704
insert((vnode.anchor = createComment(`]`)), container, next)
@@ -695,7 +727,7 @@ export function createHydrationFunctions(
695727
`\n- expected on client:`,
696728
vnode.type,
697729
)
698-
logMismatchError()
730+
logMismatchError(parentComponent)
699731
}
700732

701733
vnode.el = null

0 commit comments

Comments
 (0)