Skip to content
Draft
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
184 changes: 184 additions & 0 deletions packages/vinext/src/client/dev-console-error.ts
Original file line number Diff line number Diff line change
@@ -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);
}
19 changes: 19 additions & 0 deletions packages/vinext/src/client/dev-error-overlay-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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;
Expand Down Expand Up @@ -66,6 +74,17 @@ export function getOverlaySnapshot(): OverlayState {
}

export function reportToOverlay(error: Omit<ReportedError, "id">): 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.
Expand Down
Loading
Loading