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 000000000..5981432a7 --- /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 25ea41339..b46c8aabd 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 19e899121..6e9a5a8a3 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 29b2f19ba..ee3b15eb7 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 ba334ea96..42a86886b 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 44c1f1217..e084d4a1d 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 8915dc112..b30fa82ea 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 000000000..74315e3d6 --- /dev/null +++ b/tests/dev-console-error.test.ts @@ -0,0 +1,123 @@ +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 0ceccfee6..3c4f33600 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 000000000..d2df96041 --- /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 37aced618..1c112d127 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 000000000..7d7027763 --- /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 000000000..8a848ad48 --- /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 000000000..efc74b473 --- /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 9e2f76565..148d9bc7e 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)', );