diff --git a/packages/runtime-core/__tests__/hydration.spec.ts b/packages/runtime-core/__tests__/hydration.spec.ts
index 92b2edb7833..e054397e6fb 100644
--- a/packages/runtime-core/__tests__/hydration.spec.ts
+++ b/packages/runtime-core/__tests__/hydration.spec.ts
@@ -20,6 +20,7 @@ import {
defineComponent,
h,
nextTick,
+ onErrorCaptured,
onMounted,
onServerPrefetch,
openBlock,
@@ -36,6 +37,7 @@ import type { HMRRuntime } from '../src/hmr'
import { type SSRContext, renderToString } from '@vue/server-renderer'
import { PatchFlags, normalizeStyle } from '@vue/shared'
import { vShowOriginalDisplay } from '../../runtime-dom/src/directives/vShow'
+import { resetHydrationMismatchState } from '../src/hydration'
declare var __VUE_HMR_RUNTIME__: HMRRuntime
const { createRecord, reload } = __VUE_HMR_RUNTIME__
@@ -2627,4 +2629,73 @@ describe('SSR hydration', () => {
expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
})
})
+
+ describe('hydration mismatch error handler', () => {
+ beforeEach(() => {
+ resetHydrationMismatchState()
+ })
+
+ test('app.config.errorHandler catches hydration mismatch', () => {
+ const container = document.createElement('div')
+ container.innerHTML = `
server
`
+ const handler = vi.fn()
+ const App = defineComponent({
+ render() {
+ return h('div', [h('span', 'client')])
+ },
+ })
+ const app = createSSRApp(App)
+ app.config.errorHandler = handler
+ app.mount(container)
+ expect(handler).toHaveBeenCalledTimes(1)
+ expect(handler.mock.calls[0][0]).toBeInstanceOf(Error)
+ expect(handler.mock.calls[0][0].message).toBe(
+ 'Hydration completed but contains mismatches.',
+ )
+ expect(`Hydration text content mismatch`).toHaveBeenWarned()
+ })
+
+ test('onErrorCaptured catches hydration mismatch', () => {
+ const container = document.createElement('div')
+ container.innerHTML = `server
`
+ const handler = vi.fn((_err: unknown) => false)
+ const Child = defineComponent({
+ render() {
+ return h('span', 'client')
+ },
+ })
+ const App = defineComponent({
+ setup() {
+ onErrorCaptured(handler)
+ return () => h('div', [h(Child)])
+ },
+ })
+ const app = createSSRApp(App)
+ app.mount(container)
+ expect(handler).toHaveBeenCalledTimes(1)
+ const err = handler.mock.calls[0][0]
+ expect(err).toBeInstanceOf(Error)
+ expect((err as Error).message).toBe(
+ 'Hydration completed but contains mismatches.',
+ )
+ expect(`Hydration text content mismatch`).toHaveBeenWarned()
+ })
+
+ test('hydration mismatch error is only reported once', () => {
+ const container = document.createElement('div')
+ container.innerHTML = `ab
`
+ const handler = vi.fn()
+ const App = defineComponent({
+ render() {
+ return h('div', [h('span', 'x'), h('span', 'y')])
+ },
+ })
+ const app = createSSRApp(App)
+ app.config.errorHandler = handler
+ app.mount(container)
+ // Only one error even though there are two mismatches
+ expect(handler).toHaveBeenCalledTimes(1)
+ expect(`Hydration text content mismatch`).toHaveBeenWarned()
+ })
+ })
})
diff --git a/packages/runtime-core/src/errorHandling.ts b/packages/runtime-core/src/errorHandling.ts
index c4bdf0baccd..76e6221426e 100644
--- a/packages/runtime-core/src/errorHandling.ts
+++ b/packages/runtime-core/src/errorHandling.ts
@@ -29,6 +29,7 @@ export enum ErrorCodes {
SCHEDULER,
COMPONENT_UPDATE,
APP_UNMOUNT_CLEANUP,
+ HYDRATION_MISMATCH,
}
export const ErrorTypeStrings: Record = {
@@ -63,6 +64,7 @@ export const ErrorTypeStrings: Record = {
[ErrorCodes.SCHEDULER]: 'scheduler flush',
[ErrorCodes.COMPONENT_UPDATE]: 'component update',
[ErrorCodes.APP_UNMOUNT_CLEANUP]: 'app unmount cleanup function',
+ [ErrorCodes.HYDRATION_MISMATCH]: 'hydration',
}
export type ErrorTypes = LifecycleHooks | ErrorCodes | WatchErrorCodes
diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts
index a687d28d380..1f973344920 100644
--- a/packages/runtime-core/src/hydration.ts
+++ b/packages/runtime-core/src/hydration.ts
@@ -14,6 +14,7 @@ import { flushPostFlushCbs } from './scheduler'
import type { ComponentInternalInstance, ComponentOptions } from './component'
import { invokeDirectiveHook } from './directives'
import { warn } from './warning'
+import { ErrorCodes, handleError } from './errorHandling'
import {
PatchFlags,
ShapeFlags,
@@ -56,13 +57,55 @@ export enum DOMNodeTypes {
}
let hasLoggedMismatchError = false
-const logMismatchError = () => {
- if (__TEST__ || hasLoggedMismatchError) {
+const logMismatchError = (
+ instance: ComponentInternalInstance | null = null,
+) => {
+ if (hasLoggedMismatchError) {
return
}
- // this error should show up in production
- console.error('Hydration completed but contains mismatches.')
hasLoggedMismatchError = true
+
+ // Route through Vue's error handling pipeline so that
+ // onErrorCaptured and app.config.errorHandler can catch it.
+ if (__TEST__) {
+ // In test mode, only route through handleError if an error handler is
+ // actually configured (errorHandler or onErrorCaptured), to avoid adding
+ // unexpected "Unhandled error" warnings to every mismatch test.
+ if (
+ instance &&
+ (instance.appContext.config.errorHandler || hasErrorCaptured(instance))
+ ) {
+ handleError(
+ new Error('Hydration completed but contains mismatches.'),
+ instance,
+ ErrorCodes.HYDRATION_MISMATCH,
+ false,
+ )
+ }
+ } else {
+ handleError(
+ new Error('Hydration completed but contains mismatches.'),
+ instance,
+ ErrorCodes.HYDRATION_MISMATCH,
+ false,
+ )
+ }
+}
+
+function hasErrorCaptured(instance: ComponentInternalInstance): boolean {
+ let cur = instance.parent
+ while (cur) {
+ if (cur.ec && cur.ec.length) return true
+ cur = cur.parent
+ }
+ return false
+}
+
+/**
+ * @internal
+ */
+export function resetHydrationMismatchState(): void {
+ hasLoggedMismatchError = false
}
const isSVGContainer = (container: Element) =>
@@ -191,7 +234,7 @@ export function createHydrationFunctions(
)}` +
`\n - expected on client: ${JSON.stringify(vnode.children)}`,
)
- logMismatchError()
+ logMismatchError(parentComponent)
;(node as Text).data = vnode.children as string
}
nextNode = nextSibling(node)
@@ -441,7 +484,7 @@ export function createHydrationFunctions(
)
hasWarned = true
}
- logMismatchError()
+ logMismatchError(parentComponent)
}
// The SSRed DOM contains more nodes than it should. Remove them.
@@ -474,7 +517,7 @@ export function createHydrationFunctions(
`\n - rendered on server: ${textContent}` +
`\n - expected on client: ${clientText}`,
)
- logMismatchError()
+ logMismatchError(parentComponent)
}
el.textContent = vnode.children as string
}
@@ -499,7 +542,7 @@ export function createHydrationFunctions(
!(dirs && dirs.some(d => d.dir.created)) &&
propHasMismatch(el, key, props[key], vnode, parentComponent)
) {
- logMismatchError()
+ logMismatchError(parentComponent)
}
if (
(forcePatch &&
@@ -617,7 +660,7 @@ export function createHydrationFunctions(
)
hasWarned = true
}
- logMismatchError()
+ logMismatchError(parentComponent)
}
// the SSRed DOM didn't contain enough nodes. Mount the missing ones.
@@ -666,7 +709,7 @@ export function createHydrationFunctions(
} else {
// fragment didn't hydrate successfully, since we didn't get a end anchor
// back. This should have led to node/children mismatch warnings.
- logMismatchError()
+ logMismatchError(parentComponent)
// since the anchor is missing, we need to create one and insert it
insert((vnode.anchor = createComment(`]`)), container, next)
@@ -695,7 +738,7 @@ export function createHydrationFunctions(
`\n- expected on client:`,
vnode.type,
)
- logMismatchError()
+ logMismatchError(parentComponent)
}
vnode.el = null