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