2026-08-09 10:37:32 -07:00
|
|
|
/**
|
|
|
|
|
* A tiny ring buffer of the most recent errors, attached to the issue
|
|
|
|
|
* reports users file from inside the builder. Without it a report says
|
|
|
|
|
* "it broke" and nothing else.
|
|
|
|
|
*
|
|
|
|
|
* Message text only -- no stack traces. Production stacks are minified into
|
|
|
|
|
* uselessness and leak bundle paths for no diagnostic gain.
|
|
|
|
|
*
|
|
|
|
|
* The console.error patch ALWAYS chains to the original. A monitor that
|
|
|
|
|
* swallows diagnostics is worse than no monitor.
|
|
|
|
|
*
|
2026-08-09 10:51:08 -07:00
|
|
|
* Idempotency and the "true original":
|
|
|
|
|
*
|
|
|
|
|
* `installConsoleErrorBuffer()` treats console.error's *live* MARKER stamp
|
|
|
|
|
* (not module-scoped state) as the source of truth for "am I already the
|
|
|
|
|
* active patch". That alone survives React 18 StrictMode / hot module
|
|
|
|
|
* reload re-running this module's top level while the previous instance's
|
|
|
|
|
* patch is still installed on the global -- a plain `let installed = false`
|
|
|
|
|
* would reset on re-execution and treat that already-patched function as
|
|
|
|
|
* virgin, wrapping it again and building a chain that grows on every
|
|
|
|
|
* reload.
|
|
|
|
|
*
|
|
|
|
|
* The marker alone isn't sufficient once something *other* than us has
|
|
|
|
|
* touched console.error since our last install, though. If external code
|
|
|
|
|
* wraps our patch (`console.error = L` where L internally calls our patched
|
|
|
|
|
* function), console.error is unmarked again from our point of view, so a
|
|
|
|
|
* naive "unmarked == virgin" re-install would capture L -- an intermediate
|
|
|
|
|
* wrapper, not the true original -- as "the original" to chain to. That
|
|
|
|
|
* would (a) leave our OLD patch still reachable inside L's closure, so one
|
|
|
|
|
* console.error() call records twice (once via the new patch, once via the
|
|
|
|
|
* old one still buried inside L), and (b) make __resetConsoleErrorBuffer()
|
|
|
|
|
* restore console.error to L instead of the real original, permanently
|
|
|
|
|
* losing the reference to it.
|
|
|
|
|
*
|
|
|
|
|
* The fix is to never re-derive "the original" from whatever the live
|
|
|
|
|
* console.error happens to be at install time. Instead, the true original
|
|
|
|
|
* is captured exactly once and stashed as a hidden property directly on the
|
|
|
|
|
* `console` object (not in module scope, so it also survives module
|
|
|
|
|
* re-execution) the first time we ever patch. Every subsequent install,
|
|
|
|
|
* whether triggered by our own idempotent re-install, HMR, or a reinstall
|
|
|
|
|
* after external code has wrapped or replaced console.error, reuses that
|
|
|
|
|
* stashed reference and re-wraps it directly -- guaranteeing exactly one
|
|
|
|
|
* patch layer chains straight to the real original, and that reset can
|
|
|
|
|
* always find it.
|
|
|
|
|
*
|
|
|
|
|
* Trade-off this implies: if install() is called again after some external
|
|
|
|
|
* code has wrapped console.error, that external wrapper is discarded (we
|
|
|
|
|
* re-wrap the true original directly, not the external wrapper) rather than
|
|
|
|
|
* preserved. We accept that over the alternative of chaining through an
|
|
|
|
|
* unknown wrapper, which cannot be done safely -- there is no way to detect
|
|
|
|
|
* whether that wrapper still calls through to our old patch (risking double
|
|
|
|
|
* recording if we also wrap it) or has fully replaced it (risking losing
|
|
|
|
|
* capture entirely if we don't). An external wrapper installed *after* us
|
|
|
|
|
* and left alone (i.e. install() is not called again) is completely
|
|
|
|
|
* unaffected -- it just sits on top of our patch and both continue to work
|
|
|
|
|
* as normal JS monkey-patch layering.
|
|
|
|
|
*
|
|
|
|
|
* Known residual gap (not fixed, documented instead): if this module is hot
|
|
|
|
|
* reloaded while console.error stays patched from the previous instance,
|
|
|
|
|
* the live patch's closure still points at the *previous* module
|
|
|
|
|
* instance's `buffer` array. The marker check correctly stops the new
|
|
|
|
|
* instance from re-wrapping, but that also means the new instance's own
|
|
|
|
|
* `getRecentConsoleErrors()` reads its own (empty) buffer forever while
|
|
|
|
|
* capture silently continues into the orphaned previous instance's buffer.
|
|
|
|
|
* This only matters across an actual HMR reload during development --
|
|
|
|
|
* production has exactly one module instance for the lifetime of the page.
|
2026-08-09 10:37:32 -07:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
export interface ConsoleErrorEntry {
|
|
|
|
|
ts: number;
|
|
|
|
|
message: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const MAX_ENTRIES = 20;
|
|
|
|
|
const MAX_MESSAGE = 500;
|
|
|
|
|
const MARKER = '__whpConsoleErrorBufferPatched';
|
2026-08-09 10:51:08 -07:00
|
|
|
const TRUE_ORIGINAL_KEY = '__whpConsoleErrorBufferTrueOriginal';
|
2026-08-09 10:37:32 -07:00
|
|
|
|
2026-08-09 10:51:08 -07:00
|
|
|
type MarkedConsoleError = typeof console.error & { [MARKER]?: true };
|
|
|
|
|
type ConsoleWithStash = typeof console & { [TRUE_ORIGINAL_KEY]?: typeof console.error };
|
2026-08-09 10:37:32 -07:00
|
|
|
|
|
|
|
|
function isPatched(fn: typeof console.error): fn is MarkedConsoleError {
|
|
|
|
|
return typeof fn === 'function' && (fn as MarkedConsoleError)[MARKER] === true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 10:51:08 -07:00
|
|
|
function getTrueOriginal(): typeof console.error | undefined {
|
|
|
|
|
return (console as ConsoleWithStash)[TRUE_ORIGINAL_KEY];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setTrueOriginal(fn: typeof console.error): void {
|
|
|
|
|
(console as ConsoleWithStash)[TRUE_ORIGINAL_KEY] = fn;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearTrueOriginal(): void {
|
|
|
|
|
delete (console as ConsoleWithStash)[TRUE_ORIGINAL_KEY];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Module-scoped: not consulted for correctness (see doc comment above), only
|
|
|
|
|
// used to avoid re-adding window listeners within a single module instance.
|
2026-08-09 10:37:32 -07:00
|
|
|
let buffer: ConsoleErrorEntry[] = [];
|
|
|
|
|
let errorListener: ((e: ErrorEvent) => void) | null = null;
|
|
|
|
|
let rejectionListener: ((e: PromiseRejectionEvent) => void) | null = null;
|
|
|
|
|
|
|
|
|
|
function record(message: string): void {
|
|
|
|
|
const text = message.length > MAX_MESSAGE ? message.slice(0, MAX_MESSAGE) : message;
|
|
|
|
|
buffer.push({ ts: Date.now(), message: text });
|
|
|
|
|
if (buffer.length > MAX_ENTRIES) buffer = buffer.slice(buffer.length - MAX_ENTRIES);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stringifyArg(arg: unknown): string {
|
|
|
|
|
if (typeof arg === 'string') return arg;
|
|
|
|
|
if (arg instanceof Error) return `${arg.name}: ${arg.message}`;
|
|
|
|
|
try {
|
|
|
|
|
return JSON.stringify(arg);
|
|
|
|
|
} catch {
|
|
|
|
|
return String(arg);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Idempotent. Patches console.error and adds window error listeners. */
|
|
|
|
|
export function installConsoleErrorBuffer(): void {
|
2026-08-09 10:51:08 -07:00
|
|
|
// We're already the live, active patch -- nothing to do.
|
2026-08-09 10:37:32 -07:00
|
|
|
if (isPatched(console.error)) return;
|
|
|
|
|
|
2026-08-09 10:51:08 -07:00
|
|
|
// Reuse the stashed true original if we've ever patched before (covers
|
|
|
|
|
// HMR re-execution and reinstall-after-external-wrap); otherwise this is
|
|
|
|
|
// a genuinely virgin install and the current console.error IS the true
|
|
|
|
|
// original.
|
|
|
|
|
const trueOriginal = getTrueOriginal() ?? console.error;
|
|
|
|
|
setTrueOriginal(trueOriginal);
|
|
|
|
|
|
2026-08-09 10:37:32 -07:00
|
|
|
const patched: MarkedConsoleError = (...args: unknown[]): void => {
|
|
|
|
|
try {
|
|
|
|
|
record(args.map(stringifyArg).join(' '));
|
|
|
|
|
} catch {
|
|
|
|
|
// Recording must never break logging.
|
|
|
|
|
}
|
2026-08-09 10:51:08 -07:00
|
|
|
trueOriginal.call(console, ...args);
|
2026-08-09 10:37:32 -07:00
|
|
|
};
|
|
|
|
|
patched[MARKER] = true;
|
|
|
|
|
console.error = patched;
|
|
|
|
|
|
2026-08-09 10:51:08 -07:00
|
|
|
if (typeof window !== 'undefined' && !errorListener) {
|
|
|
|
|
errorListener = (e: ErrorEvent) => {
|
|
|
|
|
try {
|
|
|
|
|
record(`window.onerror: ${e.message}`);
|
|
|
|
|
} catch {
|
|
|
|
|
// Recording must never break the page's own error handling.
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
rejectionListener = (e: PromiseRejectionEvent) => {
|
|
|
|
|
try {
|
|
|
|
|
record(`unhandledrejection: ${stringifyArg(e.reason)}`);
|
|
|
|
|
} catch {
|
|
|
|
|
// Recording must never break the page's own rejection handling.
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-08-09 10:37:32 -07:00
|
|
|
window.addEventListener('error', errorListener);
|
|
|
|
|
window.addEventListener('unhandledrejection', rejectionListener);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Oldest-first copy of the retained entries (at most 20). */
|
|
|
|
|
export function getRecentConsoleErrors(): ConsoleErrorEntry[] {
|
|
|
|
|
return buffer.slice();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Test-only: clear the buffer and un-patch. */
|
|
|
|
|
export function __resetConsoleErrorBuffer(): void {
|
|
|
|
|
buffer = [];
|
2026-08-09 10:51:08 -07:00
|
|
|
const trueOriginal = getTrueOriginal();
|
|
|
|
|
if (trueOriginal) {
|
|
|
|
|
console.error = trueOriginal;
|
2026-08-09 10:37:32 -07:00
|
|
|
}
|
2026-08-09 10:51:08 -07:00
|
|
|
clearTrueOriginal();
|
2026-08-09 10:37:32 -07:00
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
if (errorListener) window.removeEventListener('error', errorListener);
|
|
|
|
|
if (rejectionListener) window.removeEventListener('unhandledrejection', rejectionListener);
|
|
|
|
|
}
|
|
|
|
|
errorListener = null;
|
|
|
|
|
rejectionListener = null;
|
|
|
|
|
}
|