Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions packages/runtime-core/__tests__/hydration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
defineComponent,
h,
nextTick,
onErrorCaptured,
onMounted,
onServerPrefetch,
openBlock,
Expand All @@ -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__
Expand Down Expand Up @@ -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 = `<div><span>server</span></div>`
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 = `<div><span>server</span></div>`
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 = `<div><span>a</span><span>b</span></div>`
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()
})
})
})
2 changes: 2 additions & 0 deletions packages/runtime-core/src/errorHandling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export enum ErrorCodes {
SCHEDULER,
COMPONENT_UPDATE,
APP_UNMOUNT_CLEANUP,
HYDRATION_MISMATCH,
}

export const ErrorTypeStrings: Record<ErrorTypes, string> = {
Expand Down Expand Up @@ -63,6 +64,7 @@ export const ErrorTypeStrings: Record<ErrorTypes, string> = {
[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
Expand Down
65 changes: 54 additions & 11 deletions packages/runtime-core/src/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
}
Comment on lines +68 to +92
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Preserve the old fallback when no error handler is present.

handleError(..., false) still goes through logError(), which adds a new dev warning (Unhandled error during execution of hydration) before console.error(err). That means unhandled hydration mismatches now emit extra generic noise in dev, even though the old behavior was just the specific mismatch warning plus the one-off console error.

Please gate the handleError() path on an actual consumer (app.config.errorHandler / ancestor errorCaptured) and keep the old direct console.error(new Error(...)) fallback otherwise.

Proposed fix
 const logMismatchError = (
   instance: ComponentInternalInstance | null = null,
 ) => {
   if (hasLoggedMismatchError) {
     return
   }
   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,
-    )
-  }
+  const err = new Error('Hydration completed but contains mismatches.')
+  const hasConsumer =
+    !!instance &&
+    (instance.appContext.config.errorHandler || hasErrorCaptured(instance))
+
+  if (hasConsumer) {
+    handleError(err, instance, ErrorCodes.HYDRATION_MISMATCH, false)
+  } else if (!__TEST__) {
+    console.error(err)
+  }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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,
)
}
const logMismatchError = (
instance: ComponentInternalInstance | null = null,
) => {
if (hasLoggedMismatchError) {
return
}
hasLoggedMismatchError = true
const err = new Error('Hydration completed but contains mismatches.')
const hasConsumer =
!!instance &&
(instance.appContext.config.errorHandler || hasErrorCaptured(instance))
if (hasConsumer) {
handleError(err, instance, ErrorCodes.HYDRATION_MISMATCH, false)
} else if (!__TEST__) {
console.error(err)
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/runtime-core/src/hydration.ts` around lines 68 - 92, The current
call to handleError(...) always routes through logError() and emits extra dev
warnings; change the logic so handleError is only invoked when there is an
actual consumer (check instance.appContext.config.errorHandler or
hasErrorCaptured(instance)), and when no consumer exists fall back to directly
calling console.error(new Error('Hydration completed but contains mismatches.'))
instead of handleError; update both the __TEST__ branch and the default branch
to use this gating around handleError and preserve the existing
ErrorCodes.HYDRATION_MISMATCH constant when routing to handleError.

}

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) =>
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -441,7 +484,7 @@ export function createHydrationFunctions(
)
hasWarned = true
}
logMismatchError()
logMismatchError(parentComponent)
}

// The SSRed DOM contains more nodes than it should. Remove them.
Expand Down Expand Up @@ -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
}
Expand All @@ -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 &&
Expand Down Expand Up @@ -617,7 +660,7 @@ export function createHydrationFunctions(
)
hasWarned = true
}
logMismatchError()
logMismatchError(parentComponent)
}

// the SSRed DOM didn't contain enough nodes. Mount the missing ones.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -695,7 +738,7 @@ export function createHydrationFunctions(
`\n- expected on client:`,
vnode.type,
)
logMismatchError()
logMismatchError(parentComponent)
}

vnode.el = null
Expand Down