Skip to content

Commit 79e2947

Browse files
authored
Merge pull request #3058 from element-hq/valere/refactor_error_handling
refactor: Centralize group call errors in custom GroupCallErrorBoundary
2 parents 7aac56a + 612ace1 commit 79e2947

File tree

8 files changed

+1340
-157
lines changed

8 files changed

+1340
-157
lines changed

src/RichError.tsx

Lines changed: 3 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
55
Please see LICENSE in the repository root for full details.
66
*/
77

8-
import { Trans, useTranslation } from "react-i18next";
9-
import {
10-
ErrorIcon,
11-
HostIcon,
12-
PopOutIcon,
13-
} from "@vector-im/compound-design-tokens/assets/web/icons";
8+
import { useTranslation } from "react-i18next";
9+
import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
1410

15-
import type { ComponentType, FC, ReactNode, SVGAttributes } from "react";
11+
import type { FC, ReactNode } from "react";
1612
import { ErrorView } from "./ErrorView";
17-
import { type ElementCallError, ErrorCategory } from "./utils/errors.ts";
1813

1914
/**
2015
* An error consisting of a terse message to be logged to the console and a
@@ -51,62 +46,3 @@ export class OpenElsewhereError extends RichError {
5146
super("App opened in another tab", <OpenElsewhere />);
5247
}
5348
}
54-
55-
const InsufficientCapacity: FC = () => {
56-
const { t } = useTranslation();
57-
58-
return (
59-
<ErrorView Icon={HostIcon} title={t("error.insufficient_capacity")}>
60-
<p>{t("error.insufficient_capacity_description")}</p>
61-
</ErrorView>
62-
);
63-
};
64-
65-
export class InsufficientCapacityError extends RichError {
66-
public constructor() {
67-
super("Insufficient server capacity", <InsufficientCapacity />);
68-
}
69-
}
70-
71-
type ECErrorProps = {
72-
error: ElementCallError;
73-
};
74-
75-
const GenericECError: FC<{ error: ElementCallError }> = ({
76-
error,
77-
}: ECErrorProps) => {
78-
const { t } = useTranslation();
79-
80-
let title: string;
81-
let icon: ComponentType<SVGAttributes<SVGElement>>;
82-
switch (error.category) {
83-
case ErrorCategory.CONFIGURATION_ISSUE:
84-
title = t("error.call_is_not_supported");
85-
icon = HostIcon;
86-
break;
87-
default:
88-
title = t("error.generic");
89-
icon = ErrorIcon;
90-
}
91-
return (
92-
<ErrorView Icon={icon} title={title}>
93-
<p>
94-
{error.localisedMessage ?? (
95-
<Trans
96-
i18nKey="error.unexpected_ec_error"
97-
components={[<b />, <code />]}
98-
values={{ errorCode: error.code }}
99-
/>
100-
)}
101-
</p>
102-
</ErrorView>
103-
);
104-
};
105-
106-
export class ElementCallRichError extends RichError {
107-
public ecError: ElementCallError;
108-
public constructor(ecError: ElementCallError) {
109-
super(ecError.message, <GenericECError error={ecError} />);
110-
this.ecError = ecError;
111-
}
112-
}

src/livekit/useECConnectionState.test.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,19 @@ Please see LICENSE in the repository root for full details.
66
*/
77

88
import { type FC, useCallback, useState } from "react";
9-
import { test } from "vitest";
9+
import { test, vi } from "vitest";
1010
import {
1111
ConnectionError,
1212
ConnectionErrorReason,
1313
type Room,
1414
} from "livekit-client";
1515
import userEvent from "@testing-library/user-event";
1616
import { render, screen } from "@testing-library/react";
17-
import { ErrorBoundary } from "@sentry/react";
1817
import { MemoryRouter } from "react-router-dom";
1918

20-
import { ErrorPage } from "../FullScreenView";
2119
import { useECConnectionState } from "./useECConnectionState";
2220
import { type SFUConfig } from "./openIDSFU";
21+
import { GroupCallErrorBoundary } from "../room/GroupCallErrorBoundary.tsx";
2322

2423
test.each<[string, ConnectionError]>([
2524
[
@@ -61,9 +60,9 @@ test.each<[string, ConnectionError]>([
6160
const user = userEvent.setup();
6261
render(
6362
<MemoryRouter>
64-
<ErrorBoundary fallback={ErrorPage}>
63+
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()}>
6564
<TestComponent />
66-
</ErrorBoundary>
65+
</GroupCallErrorBoundary>
6766
</MemoryRouter>,
6867
);
6968
await user.click(screen.getByRole("button", { name: "Connect" }));

src/livekit/useECConnectionState.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ import * as Sentry from "@sentry/react";
2020

2121
import { type SFUConfig, sfuConfigEquals } from "./openIDSFU";
2222
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
23-
import { InsufficientCapacityError, RichError } from "../RichError";
23+
import {
24+
ElementCallError,
25+
InsufficientCapacityError,
26+
UnknownCallError,
27+
} from "../utils/errors.ts";
2428

2529
declare global {
2630
interface Window {
@@ -188,7 +192,7 @@ export function useECConnectionState(
188192

189193
const [isSwitchingFocus, setSwitchingFocus] = useState(false);
190194
const [isInDoConnect, setIsInDoConnect] = useState(false);
191-
const [error, setError] = useState<RichError | null>(null);
195+
const [error, setError] = useState<ElementCallError | null>(null);
192196
if (error !== null) throw error;
193197

194198
const onConnStateChanged = useCallback((state: ConnectionState) => {
@@ -271,9 +275,11 @@ export function useECConnectionState(
271275
initialAudioOptions,
272276
)
273277
.catch((e) => {
274-
if (e instanceof RichError)
278+
if (e instanceof ElementCallError) {
275279
setError(e); // Bubble up any error screens to React
276-
else logger.error("Failed to connect to SFU", e);
280+
} else if (e instanceof Error) {
281+
setError(new UnknownCallError(e));
282+
} else logger.error("Failed to connect to SFU", e);
277283
})
278284
.finally(() => setIsInDoConnect(false));
279285
}
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE in the repository root for full details.
6+
*/
7+
8+
import { describe, expect, test, vi } from "vitest";
9+
import { render, screen } from "@testing-library/react";
10+
import {
11+
type FC,
12+
type ReactElement,
13+
type ReactNode,
14+
useCallback,
15+
useState,
16+
} from "react";
17+
import { BrowserRouter } from "react-router-dom";
18+
import userEvent from "@testing-library/user-event";
19+
20+
import {
21+
type CallErrorRecoveryAction,
22+
GroupCallErrorBoundary,
23+
} from "./GroupCallErrorBoundary.tsx";
24+
import {
25+
ConnectionLostError,
26+
E2EENotSupportedError,
27+
type ElementCallError,
28+
InsufficientCapacityError,
29+
MatrixRTCFocusMissingError,
30+
UnknownCallError,
31+
} from "../utils/errors.ts";
32+
import { mockConfig } from "../utils/test.ts";
33+
34+
test.each([
35+
{
36+
error: new MatrixRTCFocusMissingError("example.com"),
37+
expectedTitle: "Call is not supported",
38+
},
39+
{
40+
error: new ConnectionLostError(),
41+
expectedTitle: "Connection lost",
42+
expectedDescription: "You were disconnected from the call.",
43+
},
44+
{
45+
error: new E2EENotSupportedError(),
46+
expectedTitle: "Incompatible browser",
47+
expectedDescription:
48+
"Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.",
49+
},
50+
{
51+
error: new InsufficientCapacityError(),
52+
expectedTitle: "Insufficient capacity",
53+
expectedDescription:
54+
"The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
55+
},
56+
])(
57+
"should report correct error for $expectedTitle",
58+
async ({ error, expectedTitle, expectedDescription }) => {
59+
const TestComponent = (): ReactNode => {
60+
throw error;
61+
};
62+
63+
const onErrorMock = vi.fn();
64+
const { asFragment } = render(
65+
<BrowserRouter>
66+
<GroupCallErrorBoundary
67+
onError={onErrorMock}
68+
recoveryActionHandler={vi.fn()}
69+
>
70+
<TestComponent />
71+
</GroupCallErrorBoundary>
72+
</BrowserRouter>,
73+
);
74+
75+
await screen.findByText(expectedTitle);
76+
if (expectedDescription) {
77+
expect(screen.queryByText(expectedDescription)).toBeInTheDocument();
78+
}
79+
expect(onErrorMock).toHaveBeenCalledWith(error);
80+
81+
expect(asFragment()).toMatchSnapshot();
82+
},
83+
);
84+
85+
test("should render the error page with link back to home", async () => {
86+
const error = new MatrixRTCFocusMissingError("example.com");
87+
const TestComponent = (): ReactNode => {
88+
throw error;
89+
};
90+
91+
const onErrorMock = vi.fn();
92+
const { asFragment } = render(
93+
<BrowserRouter>
94+
<GroupCallErrorBoundary
95+
onError={onErrorMock}
96+
recoveryActionHandler={vi.fn()}
97+
>
98+
<TestComponent />
99+
</GroupCallErrorBoundary>
100+
</BrowserRouter>,
101+
);
102+
103+
await screen.findByText("Call is not supported");
104+
expect(screen.getByText(/Domain: example\.com/i)).toBeInTheDocument();
105+
expect(
106+
screen.getByText(/Error Code: MISSING_MATRIX_RTC_FOCUS/i),
107+
).toBeInTheDocument();
108+
109+
await screen.findByRole("button", { name: "Return to home screen" });
110+
111+
expect(onErrorMock).toHaveBeenCalledOnce();
112+
expect(onErrorMock).toHaveBeenCalledWith(error);
113+
114+
expect(asFragment()).toMatchSnapshot();
115+
});
116+
117+
test("ConnectionLostError: Action handling should reset error state", async () => {
118+
const user = userEvent.setup();
119+
120+
const TestComponent: FC<{ fail: boolean }> = ({ fail }): ReactNode => {
121+
if (fail) {
122+
throw new ConnectionLostError();
123+
}
124+
return <div>HELLO</div>;
125+
};
126+
127+
const reconnectCallbackSpy = vi.fn();
128+
129+
const WrapComponent = (): ReactNode => {
130+
const [failState, setFailState] = useState(true);
131+
const reconnectCallback = useCallback(
132+
(action: CallErrorRecoveryAction) => {
133+
reconnectCallbackSpy(action);
134+
setFailState(false);
135+
},
136+
[setFailState],
137+
);
138+
139+
return (
140+
<BrowserRouter>
141+
<GroupCallErrorBoundary recoveryActionHandler={reconnectCallback}>
142+
<TestComponent fail={failState} />
143+
</GroupCallErrorBoundary>
144+
</BrowserRouter>
145+
);
146+
};
147+
148+
const { asFragment } = render(<WrapComponent />);
149+
150+
// Should fail first
151+
await screen.findByText("Connection lost");
152+
await screen.findByRole("button", { name: "Reconnect" });
153+
await screen.findByRole("button", { name: "Return to home screen" });
154+
155+
expect(asFragment()).toMatchSnapshot();
156+
157+
await user.click(screen.getByRole("button", { name: "Reconnect" }));
158+
159+
// reconnect should have reset the error, thus rendering should be ok
160+
await screen.findByText("HELLO");
161+
162+
expect(reconnectCallbackSpy).toHaveBeenCalledOnce();
163+
expect(reconnectCallbackSpy).toHaveBeenCalledWith("reconnect");
164+
});
165+
166+
describe("Rageshake button", () => {
167+
function setupTest(testError: ElementCallError): void {
168+
mockConfig({
169+
rageshake: {
170+
submit_url: "https://rageshake.example.com.localhost",
171+
},
172+
});
173+
174+
const TestComponent = (): ReactElement => {
175+
throw testError;
176+
};
177+
178+
render(
179+
<BrowserRouter>
180+
<GroupCallErrorBoundary
181+
onError={vi.fn()}
182+
recoveryActionHandler={vi.fn()}
183+
>
184+
<TestComponent />
185+
</GroupCallErrorBoundary>
186+
</BrowserRouter>,
187+
);
188+
}
189+
190+
test("should show send rageshake button for unknown errors", () => {
191+
setupTest(new UnknownCallError(new Error("FOO")));
192+
193+
expect(
194+
screen.queryByRole("button", { name: "Send debug logs" }),
195+
).toBeInTheDocument();
196+
});
197+
198+
test("should not show send rageshake button for call errors", () => {
199+
setupTest(new E2EENotSupportedError());
200+
201+
expect(
202+
screen.queryByRole("button", { name: "Send debug logs" }),
203+
).not.toBeInTheDocument();
204+
});
205+
});

0 commit comments

Comments
 (0)