From 721016dde46a3e4e467a14802db3b9d5777ae3dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Tue, 3 Nov 2026 12:25:39 +0100 Subject: [PATCH] log ui related crashes,errors --- new-ui/src/main.tsx | 17 ++++- new-ui/src/shared/utils/logUiCrash.test.ts | 72 ++++++++++++++++++++++ new-ui/src/shared/utils/logUiCrash.ts | 54 ++++++++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 new-ui/src/shared/utils/logUiCrash.test.ts create mode 100644 new-ui/src/shared/utils/logUiCrash.ts diff --git a/new-ui/src/main.tsx b/new-ui/src/main.tsx index 8d5ec030d..96f72865e 100644 --- a/new-ui/src/main.tsx +++ b/new-ui/src/main.tsx @@ -3,9 +3,24 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import App from './app/App.tsx'; import './shared/scss/index.scss'; +import { logUiCrash, UiCrashSource } from './shared/utils/logUiCrash.ts'; + +window.addEventListener('error', (event) => { + logUiCrash(UiCrashSource.Window, event.error ?? event.message); +}); // biome-ignore lint/style/noNonNullAssertion: this element is static in index.html -createRoot(document.getElementById('root')!).render( +createRoot(document.getElementById('root')!, { + // replacing the defaults drops React's own console report, so keep it + onCaughtError: (error, { componentStack }) => { + console.error(error); + logUiCrash(UiCrashSource.Caught, error, componentStack); + }, + onUncaughtError: (error, { componentStack }) => { + console.error(error); + logUiCrash(UiCrashSource.Uncaught, error, componentStack); + }, +}).render( , diff --git a/new-ui/src/shared/utils/logUiCrash.test.ts b/new-ui/src/shared/utils/logUiCrash.test.ts new file mode 100644 index 000000000..d8d18a2ca --- /dev/null +++ b/new-ui/src/shared/utils/logUiCrash.test.ts @@ -0,0 +1,72 @@ +import { notFound, redirect } from '@tanstack/react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { logUiCrash, UiCrashSource } from './logUiCrash'; + +const mocks = vi.hoisted(() => ({ error: vi.fn() })); + +vi.mock('@tauri-apps/plugin-log', () => ({ error: mocks.error })); + +const loggedMessage = (): string => mocks.error.mock.calls[0][0]; + +describe('logUiCrash', () => { + beforeEach(() => { + mocks.error.mockReset(); + mocks.error.mockResolvedValue(undefined); + }); + + it('logs the summary first, then the stack and component stack', () => { + const err = new TypeError('boom'); + err.stack = 'TypeError: boom\n at Overview (overview.tsx:1:1)'; + + logUiCrash(UiCrashSource.Caught, err, '\n at Overview\n at App'); + + expect(loggedMessage()).toBe( + [ + 'UI crash (caught): TypeError: boom', + 'at Overview (overview.tsx:1:1)', + 'Component stack:', + 'at Overview\n at App', + ].join('\n'), + ); + }); + + it('keeps a stack that does not repeat the summary', () => { + const err = new Error('boom'); + err.stack = 'Overview@overview.tsx:1:1'; + + logUiCrash(UiCrashSource.Window, err); + + expect(loggedMessage()).toBe( + 'UI crash (window): Error: boom\nOverview@overview.tsx:1:1', + ); + }); + + it('describes non-Error values', () => { + logUiCrash(UiCrashSource.Caught, 'plain string'); + logUiCrash(UiCrashSource.Caught, { code: 42 }); + logUiCrash(UiCrashSource.Caught, undefined); + + expect(mocks.error.mock.calls.map(([message]) => message)).toEqual([ + 'UI crash (caught): plain string', + 'UI crash (caught): {"code":42}', + 'UI crash (caught): undefined', + ]); + }); + + it('ignores router redirects and not-found errors', () => { + logUiCrash(UiCrashSource.Caught, redirect({ to: '/' })); + logUiCrash(UiCrashSource.Caught, notFound()); + + expect(mocks.error).not.toHaveBeenCalled(); + }); + + it('does not throw when writing the log fails', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + mocks.error.mockRejectedValue(new Error('ipc down')); + + expect(() => logUiCrash(UiCrashSource.Uncaught, new Error('boom'))).not.toThrow(); + await vi.waitFor(() => expect(consoleError).toHaveBeenCalled()); + + consoleError.mockRestore(); + }); +}); diff --git a/new-ui/src/shared/utils/logUiCrash.ts b/new-ui/src/shared/utils/logUiCrash.ts new file mode 100644 index 000000000..8c8dbf00b --- /dev/null +++ b/new-ui/src/shared/utils/logUiCrash.ts @@ -0,0 +1,54 @@ +import { isNotFound, isRedirect } from '@tanstack/react-router'; +import { error as logError } from '@tauri-apps/plugin-log'; + +export const UiCrashSource = { + Caught: 'caught', + Uncaught: 'uncaught', + Window: 'window', +} as const; + +export type UiCrashSourceValue = (typeof UiCrashSource)[keyof typeof UiCrashSource]; + +const summaryOf = (thrown: unknown): string => { + if (thrown instanceof Error) return `${thrown.name}: ${thrown.message}`; + if (typeof thrown === 'string') return thrown; + try { + return JSON.stringify(thrown) ?? String(thrown); + } catch { + return String(thrown); + } +}; + +const stackOf = (thrown: unknown): string | undefined => { + if (!(thrown instanceof Error) || !thrown.stack) return undefined; + // V8 starts the stack with the summary line, WebKit does not + const summary = summaryOf(thrown); + const stack = thrown.stack.startsWith(summary) + ? thrown.stack.slice(summary.length) + : thrown.stack; + return stack.trim() || undefined; +}; + +export const logUiCrash = ( + source: UiCrashSourceValue, + thrown: unknown, + componentStack?: string, +): void => { + // router control flow travels through React error boundaries + if (isRedirect(thrown) || isNotFound(thrown)) return; + + const stack = stackOf(thrown); + const components = componentStack?.trim(); + // only the first line matches the Logs page parser, the rest stays in the log file + const message = [ + `UI crash (${source}): ${summaryOf(thrown)}`, + stack, + components ? `Component stack:\n${components}` : undefined, + ] + .filter((part) => part !== undefined) + .join('\n'); + + logError(message).catch((err: unknown) => { + console.error('Failed to write UI crash to log', err); + }); +};