/** * 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. * * 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. */ export interface ConsoleErrorEntry { ts: number; message: string; } const MAX_ENTRIES = 20; const MAX_MESSAGE = 500; const MARKER = '__whpConsoleErrorBufferPatched'; const TRUE_ORIGINAL_KEY = '__whpConsoleErrorBufferTrueOriginal'; type MarkedConsoleError = typeof console.error & { [MARKER]?: true }; type ConsoleWithStash = typeof console & { [TRUE_ORIGINAL_KEY]?: typeof console.error }; function isPatched(fn: typeof console.error): fn is MarkedConsoleError { return typeof fn === 'function' && (fn as MarkedConsoleError)[MARKER] === true; } 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. 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 { // We're already the live, active patch -- nothing to do. if (isPatched(console.error)) return; // 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); const patched: MarkedConsoleError = (...args: unknown[]): void => { try { record(args.map(stringifyArg).join(' ')); } catch { // Recording must never break logging. } trueOriginal.call(console, ...args); }; patched[MARKER] = true; console.error = patched; 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. } }; 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 = []; const trueOriginal = getTrueOriginal(); if (trueOriginal) { console.error = trueOriginal; } clearTrueOriginal(); if (typeof window !== 'undefined') { if (errorListener) window.removeEventListener('error', errorListener); if (rejectionListener) window.removeEventListener('unhandledrejection', rejectionListener); } errorListener = null; rejectionListener = null; }