From 6907858b17136b5975bf12d9fab96cf266f6a581 Mon Sep 17 00:00:00 2001 From: MaxtuneLee Date: Sat, 25 Jul 2026 17:48:55 +0800 Subject: [PATCH 1/2] fix(dev-overlay): surface attribute-only hydration mismatches as Console Error React 19 reports an attribute-only hydration mismatch through console.error only -- it never calls onRecoverableError and never patches the mismatched attribute. vinext's dev overlay listened to onCaughtError/onUncaughtError, window error/unhandledrejection events, and Vite HMR errors, but not console.error, so this case surfaced nothing in the overlay. - Wire onRecoverableError (devOnRecoverableError) for the first time, so recoverable hydration mismatches (text/element-tree) surface as "Recoverable Error" instead of falling through to the window error listener mislabeled as "Unhandled Script Error". - Patch console.error, narrowly gated: only messages matching React's attribute-only hydration regex open the overlay (as "Console Error"); every other console.error call is left exactly as before. General console.error interception (matching Next.js's broader Console Error behavior for any console.error call) is intentionally out of scope here and continues on a follow-up branch. --- .../vinext/src/client/dev-console-error.ts | 184 ++++++++++++++++++ .../src/client/dev-error-overlay-store.ts | 19 ++ .../vinext/src/client/dev-error-overlay.tsx | 147 ++++++++++++-- .../vinext/src/entries/pages-client-entry.ts | 1 + .../vinext/src/server/app-browser-entry.ts | 12 ++ .../vinext/src/server/pages-dev-hydration.ts | 1 + tests/app-browser-entry.test.ts | 118 +++++++++++ tests/dev-console-error.test.ts | 119 +++++++++++ .../e2e/app-router/dev-error-overlay.spec.ts | 46 +++++ .../dev-overlay-hydration.spec.ts | 45 +++++ tests/entry-templates.test.ts | 1 + .../hydration-attribute-mismatch.tsx | 20 ++ .../app/dev-overlay-hydration/page.tsx | 11 ++ .../pages/dev-overlay-hydration.tsx | 22 +++ tests/pages-router.test.ts | 3 + 15 files changed, 738 insertions(+), 11 deletions(-) create mode 100644 packages/vinext/src/client/dev-console-error.ts create mode 100644 tests/dev-console-error.test.ts create mode 100644 tests/e2e/pages-router/dev-overlay-hydration.spec.ts create mode 100644 tests/fixtures/app-basic/app/dev-overlay-hydration/hydration-attribute-mismatch.tsx create mode 100644 tests/fixtures/app-basic/app/dev-overlay-hydration/page.tsx create mode 100644 tests/fixtures/pages-basic/pages/dev-overlay-hydration.tsx diff --git a/packages/vinext/src/client/dev-console-error.ts b/packages/vinext/src/client/dev-console-error.ts new file mode 100644 index 0000000000..5981432a7e --- /dev/null +++ b/packages/vinext/src/client/dev-console-error.ts @@ -0,0 +1,184 @@ +// Pure helpers for the dev overlay's console.error interception. Kept free of +// DOM/store access so they can be unit-tested in a plain Node environment. +// +// Ported from Next.js's console interception + hydration recognition: +// packages/next/src/client/lib/console.ts (formatConsoleArgs) +// packages/next/src/next-devtools/shared/react-19-hydration-error.ts +// https://github.com/vercel/next.js/blob/canary/packages/next/src/client/lib/console.ts +// https://github.com/vercel/next.js/blob/canary/packages/next/src/next-devtools/shared/react-19-hydration-error.ts + +function isError(value: unknown): value is Error { + return value instanceof Error; +} + +// Ported from Next.js's formatObject (client/lib/console.ts): a depth-limited +// structural dump, matching so embedded Error objects (e.g. the one React +// inlines via %o in a replayed server-log badge) keep their name — not just +// their message — the same as real Next.js's overlay shows. Depth-limiting +// (rather than JSON.stringify) also means a circular reference or a BigInt +// property can't make this throw; deeper levels just collapse to "...". +// +// Diverges from upstream in two spots: upstream's object branch reads +// `Object.getOwnPropertyDescriptor(arg, 'key')` (the literal string "key" +// instead of the loop variable), which makes every plain-object property +// silently vanish and renders as bare "{}" — that's a bug, not a format to +// match, so it's fixed here with comma-separated keys. Function/bigint/symbol +// keep vinext's clearer renderings instead of upstream's raw String(arg), +// which for a function would otherwise dump its entire source text. +function formatObject(value: unknown, depth: number): string { + if (value === null) return "null"; + if (Array.isArray(value)) { + let result = "["; + if (depth < 1) { + for (let i = 0; i < value.length; i++) { + if (result !== "[") result += ","; + if (Object.prototype.hasOwnProperty.call(value, i)) { + result += formatObject(value[i], depth + 1); + } + } + } else if (value.length > 0) { + result += "..."; + } + return result + "]"; + } + // Render an Error the same way Next.js does (arg + ''): its name + message, + // e.g. "TypeError: ...". The stack still goes to the overlay's dedicated + // Call Stack section via the embedded-error path, not inline in the message. + if (isError(value)) return String(value); + if (typeof value === "bigint") return `${value.toString()}n`; + if (typeof value === "function") return value.name ? `[Function: ${value.name}]` : "[Function]"; + if (typeof value === "symbol") return value.toString(); + if (value === undefined) return "undefined"; + if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "object") { + const keys = Object.keys(value); + let result = "{"; + if (depth < 1) { + let appended = 0; + for (const key of keys) { + const desc = Object.getOwnPropertyDescriptor(value, key); + if (!desc || desc.get || desc.set) continue; + if (appended > 0) result += ", "; + const jsonKey = JSON.stringify(key); + result += jsonKey === `"${key}"` ? `${key}: ` : `${jsonKey}: `; + result += formatObject(desc.value, depth + 1); + appended++; + } + } else if (keys.length > 0) { + result += "..."; + } + return result + "}"; + } + return String(value); +} + +// util.format-style substitution for the message the overlay displays. Handles +// %s/%d/%i/%f/%o/%O/%c/%% the same way the browser console does, then appends +// any trailing arguments. Mirrors Next.js formatConsoleArgs so React's variadic +// warnings (hydration diffs, component stacks) render intact. +export function formatConsoleArgs(args: readonly unknown[]): string { + let template: string; + let idx: number; + if (typeof args[0] === "string") { + template = args[0]; + idx = 1; + } else { + template = ""; + idx = 0; + } + + let result = ""; + let startQuote = false; + for (let i = 0; i < template.length; ++i) { + const char = template[i]; + if (char !== "%" || i === template.length - 1 || idx >= args.length) { + result += char; + continue; + } + + const code = template[++i]; + switch (code) { + case "c": { + // %c carries CSS styling; the console renders it, we can't. Wrap the + // styled run in brackets so a replayed "[Server]" badge stays legible. + result = startQuote ? `${result}]` : `[${result}`; + startQuote = !startQuote; + idx++; + break; + } + case "O": + case "o": { + result += formatObject(args[idx++], 0); + break; + } + case "d": + case "i": { + result += String(Number.parseInt(args[idx++] as string, 10)); + break; + } + case "f": { + result += String(Number.parseFloat(args[idx++] as string)); + break; + } + case "s": { + result += String(args[idx++]); + break; + } + default: + result += "%" + code; + } + } + + for (; idx < args.length; idx++) { + result += (result.length > 0 ? " " : "") + formatObject(args[idx], 0); + } + + return result; +} + +// React calls user code starting from a special stack frame. StrictMode's +// double-invocation of the same throw produces two stacks that are identical +// below that frame and differ only above it (the extra invocation). Stripping +// everything from that frame down isolates the part unaffected by StrictMode, +// so two StrictMode-doubled reports of the same failure still compare equal. +// Ported verbatim from Next.js's REACT_ERROR_STACK_BOTTOM_FRAME_REGEX / +// getStackIgnoringStrictMode (next-devtools/dev-overlay/shared.ts). +const REACT_ERROR_STACK_BOTTOM_FRAME_REGEX = + /\s+(at Object\.react_stack_bottom_frame.*)|(react_stack_bottom_frame@.*)|(at react-stack-bottom-frame.*)|(react-stack-bottom-frame@.*)/; + +export function getStackIgnoringStrictMode(stack: string | undefined): string | undefined { + return stack?.split(REACT_ERROR_STACK_BOTTOM_FRAME_REGEX)[0]; +} + +// Two reports count as the same failure only when message, stack, and owner +// stack all match — mirrors Next.js's pushErrorFilterDuplicates. Comparing +// message text alone conflates two different call sites that happen to log +// identical text; comparing stack alone would resurface the same failure as +// "new" on every StrictMode double-invocation. No time window: a duplicate +// stays a duplicate no matter how long ago the first occurrence was reported, +// same as upstream (state.errors only clears on an explicit refresh). +export function isSameReportedError( + a: { message: string; stack: string | undefined; ownerStack: string | null | undefined }, + b: { message: string; stack: string | undefined; ownerStack: string | null | undefined }, +): boolean { + if (a.message !== b.message) return false; + if ( + a.stack !== b.stack && + getStackIgnoringStrictMode(a.stack) !== getStackIgnoringStrictMode(b.stack) + ) { + return false; + } + return a.ownerStack === b.ownerStack; +} + +// The attribute-only hydration mismatch, recognized by the same regex Next.js +// uses (react-19-hydration-error.ts's isHydrationError). React logs it to +// console.error and does NOT call onRecoverableError (it leaves the server +// attribute in place) — this is the one hydration warning the overlay can +// only learn about through the console.error interceptor. +const HYDRATION_ATTRIBUTE_MISMATCH = + /A tree hydrated but some attributes of the server rendered HTML didn't match the client properties\./; + +export function isAttributeOnlyHydrationWarningMessage(message: string): boolean { + return HYDRATION_ATTRIBUTE_MISMATCH.test(message); +} diff --git a/packages/vinext/src/client/dev-error-overlay-store.ts b/packages/vinext/src/client/dev-error-overlay-store.ts index 25ea413392..b46c8aabd0 100644 --- a/packages/vinext/src/client/dev-error-overlay-store.ts +++ b/packages/vinext/src/client/dev-error-overlay-store.ts @@ -2,11 +2,15 @@ // overlay React component (dev-error-overlay.tsx) can subscribe via // useSyncExternalStore without circular imports. +import { isSameReportedError } from "./dev-console-error.js"; + export type Source = | "server" | "vite" | "uncaught" | "caught" + | "recoverable" + | "console-error" | "window-error" | "unhandledrejection"; @@ -15,6 +19,10 @@ export type ReportedError = { source: Source; message: string; stack: string | undefined; + // React 19's captureOwnerStack(), captured synchronously at report time + // (see dev-error-overlay.tsx). Distinguishes two call sites that happen to + // log identical text/stack, and is part of the duplicate check below. + ownerStack: string | null | undefined; ignoredStackFrames: boolean[] | undefined; projectRoot: string | undefined; codeFrame: OverlayCodeFrame | undefined; @@ -66,6 +74,17 @@ export function getOverlaySnapshot(): OverlayState { } export function reportToOverlay(error: Omit): number { + // Structural dedup, matching Next.js's pushErrorFilterDuplicates: a report + // is only "new" if it differs from every error already retained on + // message, stack, or owner stack. No time window — an identical failure + // reported minutes later is still the same duplicate, and state.errors + // only clears on dismissOverlay (an explicit refresh), not on a timer. + // A duplicate leaves state untouched (no re-open, no re-expand) and + // resolves to the existing entry's id so an in-flight source-map lookup + // for the new occurrence still lands on it. + const duplicate = snapshot.errors.find((existing) => isSameReportedError(existing, error)); + if (duplicate) return duplicate.id; + // Any new error pops the dialog open, regardless of source or current // state. The developer can minimize once they've taken stock; subsequent // failures will re-expand. diff --git a/packages/vinext/src/client/dev-error-overlay.tsx b/packages/vinext/src/client/dev-error-overlay.tsx index 19e8991216..6e9a5a8a37 100644 --- a/packages/vinext/src/client/dev-error-overlay.tsx +++ b/packages/vinext/src/client/dev-error-overlay.tsx @@ -1,20 +1,33 @@ -// Dev-only runtime error overlay. Surfaces four error sources that -// otherwise only reach the console: +// Dev-only runtime error overlay. Surfaces error sources that otherwise only +// reach the console: // 1. React render errors caught by an error.tsx boundary (onCaughtError) // 2. React render errors with no boundary above them (onUncaughtError) -// 3. Plain script errors / unhandled promise rejections (window listeners) -// 4. Vite build/transform errors reported over HMR (vite:error) +// 3. React recoverable errors, incl. text/tree hydration mismatches +// (onRecoverableError) +// 4. The one hydration warning React reports ONLY via console.error and +// never via onRecoverableError: an attribute-only mismatch (patched +// console.error, narrowly gated — see handleInterceptedConsoleError) +// 5. Plain script errors / unhandled promise rejections (window listeners) +// 6. Vite build/transform errors reported over HMR (vite:error) // // Rendered via a separate React root mounted on a detached
appended to // the body. That isolation means the overlay survives an unmount of the main // hydrateRoot(document, ...) tree — necessary because most of the errors we // want to surface are exactly the ones that take that tree down. -import { Fragment, useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { + captureOwnerStack, + Fragment, + useEffect, + useMemo, + useState, + useSyncExternalStore, +} from "react"; import { createRoot, type Root } from "react-dom/client"; import { VINEXT_DEV_ERROR_RECOVERY_EVENT } from "../utils/dev-error-recovery-event.js"; import { isNavigationSignalError } from "../utils/navigation-signal.js"; +import { formatConsoleArgs, isAttributeOnlyHydrationWarningMessage } from "./dev-console-error.js"; import { type OverlayState, type OverlayCodeFrame, @@ -53,7 +66,9 @@ type ReactRefreshWindow = Window & // Errors React already routed through onCaughtError/onUncaughtError shouldn't // also surface from the window listeners — otherwise the same throw appears // twice in the dialog ("Runtime Error" + "Unhandled Script Error"). We track -// instances we've reported and skip them in the global handlers. +// instances we've reported and skip them in the global handlers. This is +// identity-based, so it only dedups Error instances (not string console args — +// those use message-based dedup below). const reportedErrors = new WeakSet(); function rememberReported(error: unknown): void { @@ -64,6 +79,46 @@ function alreadyReported(error: unknown): boolean { return !!error && typeof error === "object" && reportedErrors.has(error); } +// React 19's captureOwnerStack() reports the JSX call-site chain of whoever +// is currently rendering, but only when called synchronously within React's +// own call stack — e.g. from console.error while React is mid-render, or +// from a callback React invokes directly (onCaughtError/onUncaughtError/ +// onRecoverableError). Capturing it at each report entry point, rather than +// later, is what lets the store's dedup (dev-error-overlay-store.ts) tell +// apart two call sites that happen to log an identical message/stack — +// matching Next.js's setOwnerStackIfAvailable. +function captureCurrentOwnerStack(): string | null | undefined { + return typeof captureOwnerStack === "function" ? captureOwnerStack() : undefined; +} + +// Patch console.error so the one hydration warning React reports ONLY this +// way — an attribute-only mismatch, never followed by onRecoverableError — +// still reaches the overlay. Deliberately narrow: unlike Next.js's +// patchConsoleError (which opens a Console Error entry for any console.error +// call), this only reports messages matching the attribute-mismatch pattern; +// every other console.error call is left exactly as before. Must run before +// hydration so it is in place when React emits its first warning. +function patchConsoleErrorForOverlay(): void { + const globalConsole = window.console; + const forward = globalConsole.error.bind(globalConsole); + globalConsole.error = (...args: unknown[]): void => { + handleInterceptedConsoleError(args, forward); + }; +} + +function handleInterceptedConsoleError( + args: unknown[], + forward: (...args: unknown[]) => void, +): void { + // Preserve default console behavior for everything. + forward(...args); + + const message = formatConsoleArgs(args); + if (!isAttributeOnlyHydrationWarningMessage(message)) return; + + reportConsoleError(message, undefined, captureCurrentOwnerStack()); +} + export function installDevErrorOverlay(): void { if (typeof window === "undefined") return; @@ -72,6 +127,8 @@ export function installDevErrorOverlay(): void { if (installed) return; installed = true; + patchConsoleErrorForOverlay(); + window.addEventListener("error", (event: ErrorEvent) => { const err = event.error; if (isNavigationSignalError(err)) return; @@ -253,20 +310,59 @@ function reportDevError( : safeStringify(error); const stack = error instanceof Error ? error.stack : undefined; - ensureMounted(); - const id = reportToOverlay({ + submitOverlayError({ source: options.source, message, stack, + componentStack: options.componentStack, + ownerStack: captureCurrentOwnerStack(), + }); +} + +// Report a formatted console.error message. Unlike reportDevError, the display +// message comes from the formatted variadic args (so the hydration diff is +// preserved) while the stack, when present, comes from an embedded Error so the +// call site can still be source-mapped. ownerStack must be captured by the +// caller — it's only valid when read synchronously while still inside the +// patched console.error call, before any of this function's own logic runs. +function reportConsoleError( + message: string, + embeddedError: Error | undefined, + ownerStack: string | null | undefined, +): void { + if (typeof window === "undefined") return; + if (embeddedError) rememberReported(embeddedError); + submitOverlayError({ + source: "console-error", + message, + stack: embeddedError?.stack, + componentStack: undefined, + ownerStack, + }); +} + +function submitOverlayError(input: { + source: Source; + message: string; + stack: string | undefined; + componentStack: string | undefined; + ownerStack: string | null | undefined; +}): void { + ensureMounted(); + const id = reportToOverlay({ + source: input.source, + message: input.message, + stack: input.stack, ignoredStackFrames: undefined, projectRoot: undefined, codeFrame: undefined, - componentStack: options.componentStack, + componentStack: input.componentStack, + ownerStack: input.ownerStack, }); - void resolveBrowserStackTrace(stack).then((mappedStackTrace) => { + void resolveBrowserStackTrace(input.stack).then((mappedStackTrace) => { if ( - mappedStackTrace.stack !== stack || + mappedStackTrace.stack !== input.stack || mappedStackTrace.ignoredFrames !== undefined || mappedStackTrace.codeFrame || mappedStackTrace.projectRoot @@ -317,6 +413,27 @@ export function devOnUncaughtError( reportDevError(error, { source: "uncaught", componentStack: errorInfo?.componentStack }); } +// Dev variant of onRecoverableError. React reports recoverable errors — +// including text and element-tree hydration mismatches — through this +// callback. React also logs these via console.error, but with a different +// message pattern than the attribute-only mismatch (see +// handleInterceptedConsoleError's narrow regex gate), so that echo is simply +// never picked up by the interceptor — no explicit suppression needed here. +// Wiring this in dev also stops React from falling back to its default +// handler (reportError → a global "error" event), which would otherwise +// mislabel the mismatch as "Unhandled Script Error". +export function devOnRecoverableError( + error: unknown, + errorInfo?: { componentStack?: string }, +): void { + if (isNavigationSignalError(error)) return; + // React wraps the original error in error.cause for recoverable reports; + // unwrap it so the overlay shows the real message and stack. + const actual = error instanceof Error && error.cause !== undefined ? error.cause : error; + if (alreadyReported(actual)) return; + reportDevError(actual, { source: "recoverable", componentStack: errorInfo?.componentStack }); +} + function safeStringify(value: unknown): string { try { return JSON.stringify(value); @@ -394,11 +511,19 @@ function configureDevErrorOverlayHost(host: HTMLElement): void { // React component tree // --------------------------------------------------------------------------- +// Intentionally diverges from current Next.js canary, which derives labels +// dynamically (`Runtime ${error.name}`, `Console ${error.name}`, +// `Recoverable ${error.name}`) with no caught/uncaught split. We keep fixed, +// finer-grained labels (separating caught/uncaught/window-error/promise +// rejection) since that distinction is more informative, and two e2e specs +// assert on these exact strings. const SOURCE_LABEL: Record = { server: "Server Error", vite: "Build Error", uncaught: "Unhandled Runtime Error", caught: "Runtime Error", + recoverable: "Recoverable Error", + "console-error": "Console Error", "window-error": "Unhandled Script Error", unhandledrejection: "Unhandled Promise Rejection", }; diff --git a/packages/vinext/src/entries/pages-client-entry.ts b/packages/vinext/src/entries/pages-client-entry.ts index 29b2f19bad..ee3b15eb7c 100644 --- a/packages/vinext/src/entries/pages-client-entry.ts +++ b/packages/vinext/src/entries/pages-client-entry.ts @@ -231,6 +231,7 @@ async function hydrate() { overlay.reportInitialDevServerErrors(); hydrateRootOptions = { onCaughtError: overlay.devOnCaughtError, + onRecoverableError: overlay.devOnRecoverableError, onUncaughtError: overlay.devOnUncaughtError, }; } diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index ba334ea967..42a86886bc 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -1572,6 +1572,17 @@ function bootstrapHydration( hydrationCachePublication.fail(); prodOnRecoverableError(...args); }; + // In dev, route recoverable errors (incl. text/tree hydration mismatches) to + // the overlay as "Recoverable Error" instead of React's default reportError + // path, which would surface them as an "Unhandled Script Error" global event. + // Aliased to a const so the closure keeps the non-null narrowing. + const devOverlay = devErrorOverlay; + const devRecoverableError = devOverlay + ? (error: unknown, errorInfo?: { componentStack?: string }) => { + hydrationCachePublication.fail(); + devOverlay.devOnRecoverableError(error, errorInfo); + } + : undefined; const invalidateOnCaughtError = void>(handler: T): T => ((...args: Parameters) => { hydrationCachePublication.fail(); @@ -1585,6 +1596,7 @@ function bootstrapHydration( onCaughtError: invalidateOnCaughtError( createDevOnCaughtError(devErrorOverlay.devOnCaughtError, onUncaughtError), ), + onRecoverableError: devRecoverableError, onUncaughtError, }) : createVinextHydrateRootOptions({ diff --git a/packages/vinext/src/server/pages-dev-hydration.ts b/packages/vinext/src/server/pages-dev-hydration.ts index 44c1f1217a..e084d4a1dd 100644 --- a/packages/vinext/src/server/pages-dev-hydration.ts +++ b/packages/vinext/src/server/pages-dev-hydration.ts @@ -78,6 +78,7 @@ async function hydrate() { overlay.reportInitialDevServerErrors(); hydrateRootOptions = { onCaughtError: overlay.devOnCaughtError, + onRecoverableError: overlay.devOnRecoverableError, onUncaughtError: overlay.devOnUncaughtError, }; } diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index 8915dc1126..b30fa82ea5 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -51,6 +51,7 @@ import { createDevErrorOverlayMountNode, createViteOpenInEditorUrl, devOnCaughtError, + devOnRecoverableError, devOnUncaughtError, formatErrorInfoForClipboard, formatOverlayDisplayFile, @@ -60,6 +61,7 @@ import { } from "../packages/vinext/src/client/dev-error-overlay.js"; import { dismissOverlay, + getOverlaySnapshot, reportToOverlay, subscribeOverlay, } from "../packages/vinext/src/client/dev-error-overlay-store.js"; @@ -8120,6 +8122,38 @@ describe("devOnUncaughtError (hydrateRoot dev handler)", () => { }); }); +describe("devOnRecoverableError (hydrateRoot dev handler)", () => { + // These run in a Node environment with no `window`, so reportDevError's + // window guard makes the overlay-mounting side effects a no-op; the + // behavior under test here is that the handler never throws and correctly + // filters/unwraps its input before that guard. Full "opens the overlay as + // Recoverable Error, exactly once, deduped against the console echo" + // coverage lives in the App/Pages Router dev-overlay E2E specs. + it("ignores redirect sentinels handled by global redirect recovery", () => { + expect(() => + devOnRecoverableError( + Object.assign(new Error("NEXT_REDIRECT:/?auth=required"), { + digest: "NEXT_REDIRECT;;%2F%3Fauth%3Drequired", + }), + { componentStack: "\n at ProtectedPage" }, + ), + ).not.toThrow(); + }); + + it("is not a no-op (regression guard against `() => {}`)", () => { + // React reports recoverable hydration mismatches (text/tree) through this + // callback. Guard against silently swallowing them the way `caught` and + // `uncaught` once did. + expect(() => devOnRecoverableError(new Error("hydration mismatch"))).not.toThrow(); + }); + + it("unwraps error.cause (React wraps recoverable errors) without throwing", () => { + const inner = new Error("Hydration failed because the server rendered text"); + const wrapper = new Error("recoverable", { cause: inner }); + expect(() => devOnRecoverableError(wrapper, { componentStack: "\n at Page" })).not.toThrow(); + }); +}); + describe("dev overlay Shadow DOM mount", () => { class FakeShadowRoot { children: FakeElement[] = []; @@ -8541,6 +8575,7 @@ describe("dev overlay store", () => { source: "caught", message: "boom", stack: undefined, + ownerStack: undefined, ignoredStackFrames: undefined, projectRoot: undefined, codeFrame: undefined, @@ -8558,6 +8593,89 @@ describe("dev overlay store", () => { dismissOverlay(); } }); + + // Aligns with Next.js's pushErrorFilterDuplicates: a report is only "new" + // if it differs from every retained error on message, stack, or owner + // stack — no time window, and text alone isn't enough to call it the same. + it("collapses a report identical on message/stack/ownerStack into the existing entry", () => { + try { + const base = { + source: "console-error" as const, + message: "Warning: Each child in a list should have a unique key prop.", + stack: undefined, + ownerStack: "at List (App.tsx:10)", + ignoredStackFrames: undefined, + projectRoot: undefined, + codeFrame: undefined, + componentStack: undefined, + }; + + const firstId = reportToOverlay(base); + expect(getOverlaySnapshot().errors).toHaveLength(1); + + const secondId = reportToOverlay({ ...base }); + expect(secondId).toBe(firstId); + expect(getOverlaySnapshot().errors).toHaveLength(1); + } finally { + dismissOverlay(); + } + }); + + it("keeps a repeat of the same message as a distinct entry when it comes from a different owner stack", () => { + try { + const message = "Warning: Each child in a list should have a unique key prop."; + + reportToOverlay({ + source: "console-error", + message, + stack: undefined, + ownerStack: "at ListA (App.tsx:10)", + ignoredStackFrames: undefined, + projectRoot: undefined, + codeFrame: undefined, + componentStack: undefined, + }); + reportToOverlay({ + source: "console-error", + message, + stack: undefined, + ownerStack: "at ListB (App.tsx:42)", + ignoredStackFrames: undefined, + projectRoot: undefined, + codeFrame: undefined, + componentStack: undefined, + }); + + expect(getOverlaySnapshot().errors).toHaveLength(2); + } finally { + dismissOverlay(); + } + }); + + it("still treats an identical repeat as a duplicate however long it's been (no dedup time window)", () => { + vi.useFakeTimers(); + try { + const base = { + source: "console-error" as const, + message: "Warning: something went wrong", + stack: undefined, + ownerStack: null, + ignoredStackFrames: undefined, + projectRoot: undefined, + codeFrame: undefined, + componentStack: undefined, + }; + + reportToOverlay(base); + vi.advanceTimersByTime(10 * 60 * 1000); + reportToOverlay({ ...base }); + + expect(getOverlaySnapshot().errors).toHaveLength(1); + } finally { + vi.useRealTimers(); + dismissOverlay(); + } + }); }); describe("createOnUncaughtError (hydrateRoot uncaught handler)", () => { diff --git a/tests/dev-console-error.test.ts b/tests/dev-console-error.test.ts new file mode 100644 index 0000000000..14071c58d7 --- /dev/null +++ b/tests/dev-console-error.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + formatConsoleArgs, + getStackIgnoringStrictMode, + isAttributeOnlyHydrationWarningMessage, + isSameReportedError, +} from "../packages/vinext/src/client/dev-console-error.js"; + +// The React 19 attribute-only hydration warning (the case from the repro). +const ATTRIBUTE_MISMATCH_MESSAGE = + "A tree hydrated but some attributes of the server rendered HTML didn't match the client " + + "properties. This won't be patched up. This can happen if a SSR-ed Client Component used:"; + +describe("formatConsoleArgs", () => { + it("substitutes %s / %d / %i / %f placeholders", () => { + expect(formatConsoleArgs(["hello %s", "world"])).toBe("hello world"); + expect(formatConsoleArgs(["count %d", 42])).toBe("count 42"); + expect(formatConsoleArgs(["idx %i", "7px"])).toBe("idx 7"); + expect(formatConsoleArgs(["ratio %f", "1.5x"])).toBe("ratio 1.5"); + }); + + it("renders %o / %O objects Next.js-style (unquoted keys, depth-limited)", () => { + expect(formatConsoleArgs(["value %o", { a: 1 }])).toBe("value {a: 1}"); + expect(formatConsoleArgs(["value %o", { a: { b: 1 } }])).toBe("value {a: {...}}"); + }); + + it("appends trailing args separated by spaces", () => { + // The first arg is the template; trailing string args are JSON.stringify'd + // (quoted), matching Next.js — only %s-substituted strings stay bare. + expect(formatConsoleArgs(["a", "b", "c"])).toBe('a "b" "c"'); + expect(formatConsoleArgs([{ x: 1 }])).toBe("{x: 1}"); + }); + + it("renders an Error with its name, matching Next.js (arg + '')", () => { + expect(formatConsoleArgs([new Error("boom")])).toBe("Error: boom"); + expect(formatConsoleArgs(["failed:", new Error("boom")])).toBe("failed: Error: boom"); + }); + + it("preserves the React hydration diff passed via %s", () => { + const diff = '\n+ aria-label="light"\n- aria-label="auto"'; + const message = formatConsoleArgs([`${ATTRIBUTE_MISMATCH_MESSAGE}%s`, diff]); + expect(message).toContain("didn't match the client"); + expect(message).toContain('aria-label="light"'); + expect(message).toContain('aria-label="auto"'); + }); +}); + +describe("getStackIgnoringStrictMode", () => { + it("strips everything from React's react_stack_bottom_frame marker down (v8 shape)", () => { + const stack = + "Error: boom\n at Component (App.tsx:5:1)\n at Object.react_stack_bottom_frame (react-dom.development.js:100:1)\n at renderWithHooks (react-dom.development.js:200:1)"; + expect(getStackIgnoringStrictMode(stack)).toBe("Error: boom\n at Component (App.tsx:5:1)"); + }); + + it("returns the stack unchanged when there is no bottom-frame marker", () => { + const stack = "Error: boom\n at Component (App.tsx:5:1)"; + expect(getStackIgnoringStrictMode(stack)).toBe(stack); + }); + + it("passes through undefined", () => { + expect(getStackIgnoringStrictMode(undefined)).toBeUndefined(); + }); +}); + +describe("isSameReportedError", () => { + const base = { message: "boom", stack: "Error: boom\n at f (a.tsx:1:1)", ownerStack: "at Owner" }; + + it("matches when message, stack, and owner stack are all identical", () => { + expect(isSameReportedError(base, { ...base })).toBe(true); + }); + + it("does not match when the message text differs", () => { + expect(isSameReportedError(base, { ...base, message: "different" })).toBe(false); + }); + + it("does not match when the owner stack differs, even with identical message and stack", () => { + // The false-positive case: same warning text and no per-call stack, but + // logged from two different call sites — owner stack is what tells them + // apart. + expect(isSameReportedError(base, { ...base, ownerStack: "at OtherOwner" })).toBe(false); + }); + + it("matches StrictMode's double-invocation despite differing raw stacks", () => { + const first = { + ...base, + stack: + "Error: boom\n at Component (App.tsx:5:1)\n at Object.react_stack_bottom_frame (react-dom.development.js:100:1)\n at renderWithHooksAgain (react-dom.development.js:201:1)", + }; + const second = { + ...base, + stack: + "Error: boom\n at Component (App.tsx:5:1)\n at Object.react_stack_bottom_frame (react-dom.development.js:100:1)\n at renderWithHooks (react-dom.development.js:200:1)", + }; + expect(isSameReportedError(first, second)).toBe(true); + }); +}); + +describe("isAttributeOnlyHydrationWarningMessage", () => { + it("recognizes the attribute-only mismatch (no onRecoverableError fires)", () => { + expect(isAttributeOnlyHydrationWarningMessage(ATTRIBUTE_MISMATCH_MESSAGE)).toBe(true); + }); + + it("does not flag recoverable text/tree mismatches", () => { + const textMismatch = + "Hydration failed because the server rendered text didn't match the client."; + const nesting = "In HTML,
cannot be a child of

.\nThis will cause a hydration error."; + for (const message of [textMismatch, nesting]) { + expect(isAttributeOnlyHydrationWarningMessage(message)).toBe(false); + } + }); + + it("does not flag unrelated console errors as hydration warnings", () => { + expect( + isAttributeOnlyHydrationWarningMessage("Warning: Each child in a list needs a key"), + ).toBe(false); + expect(isAttributeOnlyHydrationWarningMessage("Something else went wrong")).toBe(false); + }); +}); diff --git a/tests/e2e/app-router/dev-error-overlay.spec.ts b/tests/e2e/app-router/dev-error-overlay.spec.ts index 0ceccfee6f..3c4f336005 100644 --- a/tests/e2e/app-router/dev-error-overlay.spec.ts +++ b/tests/e2e/app-router/dev-error-overlay.spec.ts @@ -304,6 +304,52 @@ test.describe("Dev error overlay", () => { expect(canaryAfterNav).toBe(true); }); + // React 19 reports an attribute-only hydration mismatch through + // console.error ("...won't be patched up") and does NOT call + // onRecoverableError, so the overlay only sees it once console.error is + // patched. Ported from Next.js's hydration acceptance suite: + // https://github.com/vercel/next.js/blob/canary/test/development/acceptance-app/hydration-error.test.ts + test("attribute-only hydration mismatch surfaces as a Console Error", async ({ page }) => { + const consoleErrors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + + await page.goto(`${BASE}/dev-overlay-hydration`); + await expect(page.getByTestId("dev-overlay-hydration-content")).toBeVisible(); + + // A console error opens the overlay automatically (it may briefly render as + // the minimized indicator first). + const dialog = page.getByTestId("vinext-dev-error-overlay"); + const indicator = page.getByTestId("vinext-dev-error-indicator"); + await expect(indicator.or(dialog).first()).toBeVisible({ timeout: 10_000 }); + if ((await indicator.count()) > 0 && (await dialog.count()) === 0) { + await indicator.click(); + } + await expect(dialog).toBeVisible({ timeout: 2_000 }); + + // Labeled "Console Error" (not "Recoverable Error" — no onRecoverableError + // fires for the attribute-only case). + await expect(page.getByTestId("vinext-dev-error-title")).toHaveText("Console Error"); + await expect(page.getByTestId("vinext-dev-error-message")).toContainText("patched up"); + // The React attribute diff is preserved. + await expect(page.getByTestId("vinext-dev-error-message")).toContainText("aria-label"); + + // The DOM keeps the server-rendered attribute — React does not patch it, + // proving this is the attribute path rather than a recoverable tree + // regeneration. + await expect(page.getByTestId("hydration-attribute-target")).toHaveAttribute( + "aria-label", + "auto", + ); + + // Exactly one overlay entry: no pagination control. + await expect(page.getByTestId("vinext-dev-error-pagination")).toBeHidden(); + + // The browser console still received the original warning. + expect(consoleErrors.some((text) => text.includes("patched up"))).toBe(true); + }); + test("clicking the backdrop minimizes the dialog to a corner indicator", async ({ page }) => { await page.goto(`${BASE}/dev-overlay-test`); await clickUntilOverlay(page, "trigger-window-error"); diff --git a/tests/e2e/pages-router/dev-overlay-hydration.spec.ts b/tests/e2e/pages-router/dev-overlay-hydration.spec.ts new file mode 100644 index 0000000000..d2df960416 --- /dev/null +++ b/tests/e2e/pages-router/dev-overlay-hydration.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from "../fixtures"; + +// The Pages Router dev overlay uses the same installer as the App Router, so an +// attribute-only hydration mismatch should surface identically as a "Console +// Error" once console.error is patched. This test intentionally triggers a +// console error, so it deliberately does NOT use the `consoleErrors` fixture. +// Ported from Next.js's hydration acceptance coverage: +// https://github.com/vercel/next.js/blob/canary/test/development/acceptance-app/hydration-error.test.ts +const BASE = "http://localhost:4173"; + +test.describe("Pages Router dev overlay", () => { + test("attribute-only hydration mismatch surfaces as a Console Error", async ({ page }) => { + const consoleErrors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + + await page.goto(`${BASE}/dev-overlay-hydration`); + await expect(page.getByTestId("dev-overlay-hydration-content")).toBeVisible(); + + const dialog = page.getByTestId("vinext-dev-error-overlay"); + const indicator = page.getByTestId("vinext-dev-error-indicator"); + await expect(indicator.or(dialog).first()).toBeVisible({ timeout: 10_000 }); + if ((await indicator.count()) > 0 && (await dialog.count()) === 0) { + await indicator.click(); + } + await expect(dialog).toBeVisible({ timeout: 2_000 }); + + await expect(page.getByTestId("vinext-dev-error-title")).toHaveText("Console Error"); + await expect(page.getByTestId("vinext-dev-error-message")).toContainText("patched up"); + + // React does not patch the mismatched attribute — the DOM keeps the server + // value, proving the attribute (not recoverable) path. + await expect(page.getByTestId("hydration-attribute-target")).toHaveAttribute( + "aria-label", + "auto", + ); + + // Exactly one overlay entry. + await expect(page.getByTestId("vinext-dev-error-pagination")).toBeHidden(); + + // The browser console still received the original warning. + expect(consoleErrors.some((text) => text.includes("patched up"))).toBe(true); + }); +}); diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 37aced6185..1c112d1279 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -1791,6 +1791,7 @@ describe("Pages Router entry template", () => { expect(code).toContain("overlay.installViteHmrErrorHandler(import.meta.hot)"); expect(code).toContain("overlay.reportInitialDevServerErrors()"); expect(code).toContain("onCaughtError: overlay.devOnCaughtError"); + expect(code).toContain("onRecoverableError: overlay.devOnRecoverableError"); expect(code).toContain("onUncaughtError: overlay.devOnUncaughtError"); expect(overlayImportIndex).toBeLessThan(pageLoadIndex); } finally { diff --git a/tests/fixtures/app-basic/app/dev-overlay-hydration/hydration-attribute-mismatch.tsx b/tests/fixtures/app-basic/app/dev-overlay-hydration/hydration-attribute-mismatch.tsx new file mode 100644 index 0000000000..7d70277633 --- /dev/null +++ b/tests/fixtures/app-basic/app/dev-overlay-hydration/hydration-attribute-mismatch.tsx @@ -0,0 +1,20 @@ +"use client"; + +// Reproduces a React 19 attribute-only hydration mismatch. The module is +// evaluated in both the SSR and browser environments; `isClient` is therefore +// false during server render and true during hydration, so aria-label differs. +// React logs this via console.error ("...won't be patched up") WITHOUT calling +// onRecoverableError, and leaves the server attribute in the DOM. +const isClient = typeof window !== "undefined"; + +export function HydrationAttributeMismatch() { + return ( + + ); +} diff --git a/tests/fixtures/app-basic/app/dev-overlay-hydration/page.tsx b/tests/fixtures/app-basic/app/dev-overlay-hydration/page.tsx new file mode 100644 index 0000000000..8a848ad484 --- /dev/null +++ b/tests/fixtures/app-basic/app/dev-overlay-hydration/page.tsx @@ -0,0 +1,11 @@ +import { HydrationAttributeMismatch } from "./hydration-attribute-mismatch"; + +export default function DevOverlayHydrationPage() { + return ( +

+

Dev Overlay Hydration

+

attribute-only hydration mismatch

+ +
+ ); +} diff --git a/tests/fixtures/pages-basic/pages/dev-overlay-hydration.tsx b/tests/fixtures/pages-basic/pages/dev-overlay-hydration.tsx new file mode 100644 index 0000000000..efc74b4737 --- /dev/null +++ b/tests/fixtures/pages-basic/pages/dev-overlay-hydration.tsx @@ -0,0 +1,22 @@ +// Reproduces a React 19 attribute-only hydration mismatch in the Pages Router. +// The module is evaluated on the server (isClient false → aria-label "auto") +// and again in the browser during hydration (isClient true → aria-label +// "light"). React logs this via console.error ("...won't be patched up") +// without calling onRecoverableError and leaves the server attribute in place. +const isClient = typeof window !== "undefined"; + +export default function DevOverlayHydrationPage() { + return ( +
+

Pages Dev Overlay Hydration

+

attribute-only hydration mismatch

+ +
+ ); +} diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index aad1c5939b..3d62803732 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -1467,6 +1467,9 @@ export default function Page({ marker }: { marker: string }) { expect(hydrationProxy).toContain("overlay.installDevErrorOverlay()"); expect(hydrationProxy).toContain("overlay.installViteHmrErrorHandler(import.meta.hot)"); expect(hydrationProxy).toContain("overlay.reportInitialDevServerErrors()"); + // Dev wires onRecoverableError so recoverable hydration mismatches surface + // as "Recoverable Error" instead of React's default reportError path. + expect(hydrationProxy).toContain("onRecoverableError: overlay.devOnRecoverableError"); expect(hydrationProxy).toContain( 'hydrateRoot(document.getElementById("__next"), element, hydrateRootOptions)', ); From 434d4e5fde5f403c9a03d89be0668f6e22d88d1d Mon Sep 17 00:00:00 2001 From: MaxtuneLee Date: Sat, 25 Jul 2026 22:01:26 +0800 Subject: [PATCH 2/2] style(tests): format dev-console-error test --- tests/dev-console-error.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/dev-console-error.test.ts b/tests/dev-console-error.test.ts index 14071c58d7..74315e3d6f 100644 --- a/tests/dev-console-error.test.ts +++ b/tests/dev-console-error.test.ts @@ -64,7 +64,11 @@ describe("getStackIgnoringStrictMode", () => { }); describe("isSameReportedError", () => { - const base = { message: "boom", stack: "Error: boom\n at f (a.tsx:1:1)", ownerStack: "at Owner" }; + const base = { + message: "boom", + stack: "Error: boom\n at f (a.tsx:1:1)", + ownerStack: "at Owner", + }; it("matches when message, stack, and owner stack are all identical", () => { expect(isSameReportedError(base, { ...base })).toBe(true);