From 1371d415cd1a7eb1e653272c0e79137d71f138ba Mon Sep 17 00:00:00 2001 From: Pierluigi Lenoci Date: Fri, 24 Apr 2026 18:02:16 +0200 Subject: [PATCH] fix(hydration): route mismatches via handleError when consumer set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../runtime-core/__tests__/hydration.spec.ts | 87 +++++++++++++++++++ packages/runtime-core/src/errorHandling.ts | 2 + packages/runtime-core/src/hydration.ts | 54 +++++++++--- 3 files changed, 132 insertions(+), 11 deletions(-) diff --git a/packages/runtime-core/__tests__/hydration.spec.ts b/packages/runtime-core/__tests__/hydration.spec.ts index 2fe64910403..6553c0b4864 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__ @@ -2674,4 +2676,89 @@ 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() + }) + + test('no Unhandled error warning when no errorHandler/onErrorCaptured', () => { + const container = document.createElement('div') + container.innerHTML = `
server
` + const App = defineComponent({ + render() { + return h('div', [h('span', 'client')]) + }, + }) + const app = createSSRApp(App) + app.mount(container) + expect(`Hydration text content mismatch`).toHaveBeenWarned() + expect( + `Unhandled error during execution of hydration`, + ).not.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 464db7519b6..f6bb1d05100 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,44 @@ 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 + + // Only route through handleError when there is an explicit consumer: + // without one, it would surface an "Unhandled error" warning on top of + // the per-mismatch warn() calls already produced at the call sites. + if ( + instance && + (instance.appContext.config.errorHandler || hasErrorCaptured(instance)) + ) { + 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 +223,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) @@ -434,7 +466,7 @@ export function createHydrationFunctions( el, `\nServer rendered element contains more child nodes than client vdom.`, ) - logMismatchError() + logMismatchError(parentComponent) } while (next) { // The SSRed DOM contains more nodes than it should. Remove them. @@ -467,7 +499,7 @@ export function createHydrationFunctions( `\n - rendered on server: ${textContent}` + `\n - expected on client: ${clientText}`, ) - logMismatchError() + logMismatchError(parentComponent) } el.textContent = vnode.children as string } @@ -492,7 +524,7 @@ export function createHydrationFunctions( !(dirs && dirs.some(d => d.dir.created)) && propHasMismatch(el, key, props[key], vnode, parentComponent) ) { - logMismatchError() + logMismatchError(parentComponent) } if ( (forcePatch && @@ -607,7 +639,7 @@ export function createHydrationFunctions( container, `\nServer rendered element contains fewer child nodes than client vdom.`, ) - logMismatchError() + logMismatchError(parentComponent) } } @@ -657,7 +689,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) @@ -686,7 +718,7 @@ export function createHydrationFunctions( `\n- expected on client:`, vnode.type, ) - logMismatchError() + logMismatchError(parentComponent) } vnode.el = null