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
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,14 @@ The inverse trap is real too — a `.catch()` written to _reduce_ RUM noise must
which is what #1658 was. `console.debug` is not collected, and is the channel for a swallowed
failure you still want in devtools.

Error boundaries are the same trap from the other side. React's root `onCaughtError` already reports
every error any boundary catches, through
[`reportReactError`](src/integrations/datadog/reportReactError.ts), so a `componentDidCatch` that
`console.error`s files a second, console-sourced copy of each one. Log there with `console.warn`
(not collected either, but visible in devtools by default). That reporter also collapses repeats: a
TanStack route re-throws its stored error on every match update and every reload throws a fresh
identical one, so one failed load used to reach Error Tracking up to 14 times (#1695).

## A 5xx body never reaches an auth form

`describeError` renders whatever the server sent, and before #1676 that reached the sign-in and
Expand Down
84 changes: 75 additions & 9 deletions src/components/ErrorComponent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,29 @@
* @vitest-environment jsdom
*/
import { TestProvider } from '@/lib/test/TestProvider';
import { cleanup, render, screen } from '@testing-library/react';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { act } from 'react';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ErrorComponent } from './ErrorComponent';

afterEach(() => cleanup());
const { invalidate } = vi.hoisted(() => ({ invalidate: vi.fn(() => Promise.resolve()) }));
// Only ErrorComponent's own import is replaced; the provider's router and its links stay real.
vi.mock('@tanstack/react-router', async (importOriginal) => {
const router = { invalidate };
return { ...await importOriginal<typeof import('@tanstack/react-router')>(), useRouter: () => router };
});

afterEach(() => {
cleanup();
invalidate.mockClear();
});

const FALLBACK = 'An unexpected error occurred.';

async function renderError(error: unknown, props: { title?: string; description?: string } = {}) {
async function renderError(
error: unknown,
props: { title?: string; description?: string; reset?: () => void } = {},
) {
render(
<TestProvider>
<ErrorComponent error={error} showReturnToHome={false} {...props} />
Expand Down Expand Up @@ -46,7 +59,7 @@ describe(ErrorComponent, () => {
},
});
await renderError(hostile);
expect(screen.getByRole('heading', { name: 'Component Error' })).toBeTruthy();
expect(screen.getByRole('heading', { name: 'Something Went Wrong' })).toBeTruthy();
expect(screen.getByText(FALLBACK)).toBeTruthy();
});

Expand All @@ -62,10 +75,63 @@ describe(ErrorComponent, () => {
expect(screen.queryByText(/doesn't have permission/)).toBeNull();
});

it('should keep the raw message for a status it has no copy for', async () => {
await renderError(httpError(500));
expect(screen.getByRole('heading', { name: 'Component Error' })).toBeTruthy();
expect(screen.getByText('Request failed with status code 500')).toBeTruthy();
it('should keep the raw message under a generic title for a status it has no copy for', async () => {
await renderError(httpError(409));
expect(screen.getByRole('heading', { name: 'Something Went Wrong' })).toBeTruthy();
expect(screen.getByText('Request failed with status code 409')).toBeTruthy();
});

const networkError = (code: string) => Object.assign(new Error('Network Error'), { code });
const reset = () => {};

it.each([
['a 500', httpError(500), 'Something Went Wrong', /couldn't load this \(error 500\)/],
['a 503', httpError(503), 'Something Went Wrong', /couldn't load this \(error 503\)/],
['ERR_NETWORK', networkError('ERR_NETWORK'), "Couldn't Reach the Server", /didn't get an answer/],
['ECONNABORTED', networkError('ECONNABORTED'), "Couldn't Reach the Server", /didn't get an answer/],
['ETIMEDOUT', networkError('ETIMEDOUT'), "Couldn't Reach the Server", /didn't get an answer/],
])('should offer Try Again on a route for %s', async (_label, error, title, description) => {
await renderError(error, { reset });
expect(screen.getByRole('heading', { name: title })).toBeTruthy();
expect(screen.getByText(description)).toBeTruthy();
expect(screen.getByRole('button', { name: /Try Again/ })).toBeTruthy();
});

it.each([
['a 400', httpError(400)],
['a 403', httpError(403)],
['a 404', httpError(404)],
['a cancellation', networkError('ERR_CANCELED')],
['a thrown TypeError', new TypeError('x is undefined')],
['a thrown string', 'boom'],
])('should not offer Try Again for %s, which a reload cannot fix', async (_label, error) => {
await renderError(error, { reset });
expect(screen.queryByRole('button', { name: /Try Again/ })).toBeNull();
});

it('should not offer Try Again outside a route error boundary', async () => {
await renderError(httpError(503));
expect(screen.getByText(/couldn't load this \(error 503\)/)).toBeTruthy();
expect(screen.queryByRole('button', { name: /Try Again/ })).toBeNull();
});

it('should reload the route once per Try Again, and not again while that reload is pending', async () => {
let finishReload!: () => void;
invalidate.mockImplementationOnce(() => new Promise<void>((resolve) => (finishReload = resolve)));
await renderError(httpError(503), { reset });
const button = screen.getByRole('button', { name: /Try Again/ });

fireEvent.click(button);
fireEvent.click(button);
expect(invalidate).toHaveBeenCalledTimes(1);
const pending = screen.getByRole<HTMLButtonElement>('button', { name: /Trying Again/ });
expect(pending.disabled).toBe(true);
expect(pending.querySelector('svg')?.classList.contains('animate-spin')).toBe(true);

await act(async () => finishReload());
const settled = screen.getByRole<HTMLButtonElement>('button', { name: /Try Again/ });
expect(settled.disabled).toBe(false);
expect(settled.querySelector('svg')?.classList.contains('animate-spin')).toBe(false);
});

it('should render the message of a thrown Error', async () => {
Expand Down
128 changes: 93 additions & 35 deletions src/components/ErrorComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { useOverallAuth } from '@/hooks/useAuth';
import { cn } from '@/lib/cn';
import { errorStatus } from '@/lib/errorStatus';
import { getDefaultSignedInCloudRouteForUser } from '@/lib/urls/getDefaultSignedInCloudRouteForUser';
import { Link } from '@tanstack/react-router';
import { ArrowLeft } from 'lucide-react';
import { isValidElement, ReactNode } from 'react';
import { Link, useRouter } from '@tanstack/react-router';
import { AxiosError } from 'axios';
import { ArrowLeft, RotateCw } from 'lucide-react';
import { isValidElement, ReactNode, useState } from 'react';

interface ErrorProps {
className?: string | undefined;
Expand All @@ -20,38 +21,87 @@ interface ErrorProps {
title?: string;
description?: ReactNode;
showReturnToHome?: boolean;
// TanStack Router passes this only to a route error component, so its presence is what says
// reloading the route can retry the failure. Retrying calls `router.invalidate()`, not `reset`:
// a reset alone re-renders the match, which re-throws the error it still holds.
reset?: () => void;
children?: ReactNode;
}

const FALLBACK_ERROR_MESSAGE = 'An unexpected error occurred.';
const GENERIC_TITLE = 'Something Went Wrong';

interface StatusCopy {
interface FailureCopy {
title: string;
description: string;
retryable?: boolean;
}

const FORBIDDEN_COPY: StatusCopy = {
const FORBIDDEN_COPY: FailureCopy = {
title: 'Access Denied',
description:
"Your account doesn't have permission to view this. If you think it should, ask an administrator for access.",
};

const NOT_FOUND_COPY: StatusCopy = {
const NOT_FOUND_COPY: FailureCopy = {
title: 'Not Found',
description: "We couldn't find what you were looking for. It may have been deleted, or the link may be wrong.",
};

function describeStatus(error: unknown): StatusCopy | undefined {
switch (errorStatus(error)) {
case 403:
return FORBIDDEN_COPY;
case 404:
return NOT_FOUND_COPY;
default:
return undefined;
const UNREACHABLE_COPY: FailureCopy = {
title: "Couldn't Reach the Server",
description: "The request didn't get an answer. Check your connection, then try again.",
retryable: true,
};

const NO_RESPONSE_CODES: ReadonlySet<unknown> = new Set([
AxiosError.ERR_NETWORK,
AxiosError.ECONNABORTED,
AxiosError.ETIMEDOUT,
]);

function errorCode(error: unknown): unknown {
try {
return (error as { code?: unknown } | null | undefined)?.code;
} catch {
return undefined;
}
}

function describeFailure(error: unknown): FailureCopy | undefined {
const status = errorStatus(error);
if (status === 403) { return FORBIDDEN_COPY; }
if (status === 404) { return NOT_FOUND_COPY; }
if (typeof status === 'number') {
return status >= 500 && status < 600
? {
title: GENERIC_TITLE,
description: `The server couldn't load this (error ${status}). Try again in a moment.`,
retryable: true,
}
: undefined;
}
return NO_RESPONSE_CODES.has(errorCode(error)) ? UNREACHABLE_COPY : undefined;
}

function RetryButton() {
const router = useRouter();
const [retrying, setRetrying] = useState(false);
return (
<Button
type="button"
variant="outline"
disabled={retrying}
onClick={() => {
setRetrying(true);
void router.invalidate().finally(() => setRetrying(false));
}}
>
<RotateCw className={retrying ? 'animate-spin' : undefined} /> {retrying ? 'Trying Again…' : 'Try Again'}
</Button>
);
}
Comment thread
dawsontoth marked this conversation as resolved.
Resolved

/**
* Whether React can render this value as visible text.
*
Expand Down Expand Up @@ -93,10 +143,13 @@ function getErrorMessage(error: unknown): ReactNode {
return FALLBACK_ERROR_MESSAGE;
}

export function ErrorComponent({ className, error, title, description, showReturnToHome, children }: ErrorProps) {
export function ErrorComponent(
{ className, error, title, description, showReturnToHome, reset, children }: ErrorProps,
) {
const { user, isLoading: isUserLoading } = useOverallAuth();
const defaultCloudRoute = getDefaultSignedInCloudRouteForUser(user);
const statusCopy = describeStatus(error);
const failureCopy = describeFailure(error);
const canRetry = reset !== undefined && failureCopy?.retryable === true;

return (
<Card
Expand All @@ -105,29 +158,34 @@ export function ErrorComponent({ className, error, title, description, showRetur
>
<CardHeader>
<CardTitle className="text-2xl">
<h2>{title ?? statusCopy?.title ?? 'Component Error'}</h2>
<h2>{title ?? failureCopy?.title ?? GENERIC_TITLE}</h2>
</CardTitle>
<CardDescription>{description ?? statusCopy?.description ?? getErrorMessage(error)}</CardDescription>
<CardDescription>{description ?? failureCopy?.description ?? getErrorMessage(error)}</CardDescription>
</CardHeader>
<CardContent>
{children}
{showReturnToHome !== false && (user && !isUserLoading
? (
<Link to={isLocalStudio ? defaultInstanceRoute : defaultCloudRoute}>
<Button>
{' '}
<ArrowLeft /> Return to Home
</Button>
</Link>
)
: (
<Link to="/sign-in">
<Button>
{' '}
<ArrowLeft /> Go to Sign In Page
</Button>
</Link>
))}
{(canRetry || showReturnToHome !== false) && (
<div className="flex flex-wrap gap-2">
{canRetry && <RetryButton />}
{showReturnToHome !== false && (user && !isUserLoading
? (
<Link to={isLocalStudio ? defaultInstanceRoute : defaultCloudRoute}>
<Button>
{' '}
<ArrowLeft /> Return to Home
</Button>
</Link>
)
: (
<Link to="/sign-in">
<Button>
{' '}
<ArrowLeft /> Go to Sign In Page
</Button>
</Link>
))}
</div>
)}
</CardContent>
</Card>
);
Expand Down
12 changes: 11 additions & 1 deletion src/features/cluster/components/ClusterRouteError.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ vi.mock('@tanstack/react-router', () => ({
Link: ({ children, to }: { children: ReactNode; to: string }) => <a href={to}>{children}</a>,
}));
vi.mock('@/components/ErrorComponent', () => ({
ErrorComponent: ({ error }: { error: Error }) => <p>generic error: {error.message}</p>,
ErrorComponent: ({ error, reset }: { error: Error; reset?: () => void }) => (
<p>
generic error: {error.message}
{reset && ' (retryable)'}
</p>
),
}));

import { ClusterRouteError } from './ClusterRouteError';
Expand All @@ -33,4 +38,9 @@ describe('ClusterRouteError', () => {
expect(screen.getByText('generic error: Request failed with status code 500')).toBeTruthy();
expect(screen.queryByText('Pending Owner Setup')).toBeNull();
});

it("hands the router's reset to the generic error page so it can offer a retry", () => {
render(<ClusterRouteError error={httpError(503)} reset={() => {}} />);
expect(screen.getByText('generic error: Request failed with status code 503 (retryable)')).toBeTruthy();
});
});
4 changes: 2 additions & 2 deletions src/features/cluster/components/ClusterRouteError.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import { ArrowLeft } from 'lucide-react';
* The cluster route loads the cluster before any page under it renders. Central manager answers 403 to members who
* aren't org admins until a new cluster's setup is finished; its other per-cluster refusals read as 404.
*/
export function ClusterRouteError({ error }: { error: unknown }) {
export function ClusterRouteError({ error, reset }: { error: unknown; reset?: () => void }) {
const { organizationId }: { organizationId?: string } = useParams({ strict: false });
if (!isForbiddenError(error)) {
return <ErrorComponent error={error} />;
return <ErrorComponent error={error} reset={reset} />;
}
return (
<div className="flex flex-col items-center mt-24">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ class MetricErrorBoundary extends Component<MetricErrorBoundaryProps, { failed:
return { failed: true };
}
componentDidCatch(error: Error) {
console.error('[MetricRenderer] render failed; falling back:', error);
// Not `console.error`: the root `onCaughtError` already reports this to RUM, which would count that as a second error.
console.warn('[MetricRenderer] render failed; falling back:', error);
}
render() {
return this.state.failed ? this.props.fallback : this.props.children;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/** @vitest-environment jsdom */
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PanelErrorBoundary } from './PanelErrorBoundary';

afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

function Broken(): never {
throw new Error('bad records');
}

describe('PanelErrorBoundary', () => {
it('logs a panel crash as a warning, leaving the RUM report to the root error handler', () => {
const consoleError = vi.spyOn(console, 'error');
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
render(
<PanelErrorBoundary metric="cpu">
<Broken />
</PanelErrorBoundary>,
{ onCaughtError: () => {} },
);

expect(screen.getByText('Panel "cpu" is unavailable')).toBeTruthy();
expect(consoleWarn).toHaveBeenCalledWith('[panel:cpu] render failed', expect.any(Error));
expect(consoleError.mock.calls.filter(([first]) => String(first).includes('render failed'))).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ export class PanelErrorBoundary extends Component<Props, State> {
}

componentDidCatch(error: Error) {
console.error(`[panel:${this.props.metric}] render failed`, error);
// Not `console.error`: the root `onCaughtError` already reports this to RUM, which would count that as a second error.
console.warn(`[panel:${this.props.metric}] render failed`, error);
}

render() {
Expand Down
Loading
Loading