Skip to content
Merged
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
17 changes: 16 additions & 1 deletion new-ui/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<StrictMode>
<App />
</StrictMode>,
Expand Down
72 changes: 72 additions & 0 deletions new-ui/src/shared/utils/logUiCrash.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
54 changes: 54 additions & 0 deletions new-ui/src/shared/utils/logUiCrash.ts
Original file line number Diff line number Diff line change
@@ -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);
});
};
Loading