Merge pull request #3118 from element-hq/robin/errors-outside-boundary

Fix join errors mistakenly showing a generic error screen
This commit is contained in:
Robin
2025-03-21 17:20:52 -04:00
committed by GitHub
6 changed files with 104 additions and 61 deletions

View File

@@ -12,9 +12,9 @@ import { useEffect, useState } from "react";
import { type LivekitFocus } from "matrix-js-sdk/src/matrixrtc/LivekitFocus"; import { type LivekitFocus } from "matrix-js-sdk/src/matrixrtc/LivekitFocus";
import { useActiveLivekitFocus } from "../room/useActiveFocus"; import { useActiveLivekitFocus } from "../room/useActiveFocus";
import { useGroupCallErrorBoundary } from "../room/useCallErrorBoundary.ts"; import { useErrorBoundary } from "../useErrorBoundary";
import { FailToGetOpenIdToken } from "../utils/errors.ts"; import { FailToGetOpenIdToken } from "../utils/errors";
import { doNetworkOperationWithRetry } from "../utils/matrix.ts"; import { doNetworkOperationWithRetry } from "../utils/matrix";
export interface SFUConfig { export interface SFUConfig {
url: string; url: string;
@@ -41,7 +41,7 @@ export function useOpenIDSFU(
const [sfuConfig, setSFUConfig] = useState<SFUConfig | undefined>(undefined); const [sfuConfig, setSFUConfig] = useState<SFUConfig | undefined>(undefined);
const activeFocus = useActiveLivekitFocus(rtcSession); const activeFocus = useActiveLivekitFocus(rtcSession);
const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary(); const { showErrorBoundary } = useErrorBoundary();
useEffect(() => { useEffect(() => {
if (activeFocus) { if (activeFocus) {
@@ -50,14 +50,14 @@ export function useOpenIDSFU(
setSFUConfig(sfuConfig); setSFUConfig(sfuConfig);
}, },
(e) => { (e) => {
showGroupCallErrorBoundary(new FailToGetOpenIdToken(e)); showErrorBoundary(new FailToGetOpenIdToken(e));
logger.error("Failed to get SFU config", e); logger.error("Failed to get SFU config", e);
}, },
); );
} else { } else {
setSFUConfig(undefined); setSFUConfig(undefined);
} }
}, [client, activeFocus, showGroupCallErrorBoundary]); }, [client, activeFocus, showErrorBoundary]);
return sfuConfig; return sfuConfig;
} }

View File

@@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { beforeEach, expect, type MockedFunction, test, vitest } from "vitest"; import {
beforeEach,
expect,
type MockedFunction,
onTestFinished,
test,
vi,
} from "vitest";
import { render, waitFor, screen } from "@testing-library/react"; import { render, waitFor, screen } from "@testing-library/react";
import { type MatrixClient } from "matrix-js-sdk/src/client"; import { type MatrixClient } from "matrix-js-sdk/src/client";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc"; import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
@@ -15,6 +22,7 @@ import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { type RelationsContainer } from "matrix-js-sdk/src/models/relations-container"; import { type RelationsContainer } from "matrix-js-sdk/src/models/relations-container";
import { useState } from "react"; import { useState } from "react";
import { TooltipProvider } from "@vector-im/compound-web";
import { type MuteStates } from "./MuteStates"; import { type MuteStates } from "./MuteStates";
import { prefetchSounds } from "../soundUtils"; import { prefetchSounds } from "../soundUtils";
@@ -28,20 +36,33 @@ import {
MockRTCSession, MockRTCSession,
} from "../utils/test"; } from "../utils/test";
import { GroupCallView } from "./GroupCallView"; import { GroupCallView } from "./GroupCallView";
import { leaveRTCSession } from "../rtcSessionHelpers";
import { type WidgetHelpers } from "../widget"; import { type WidgetHelpers } from "../widget";
import { LazyEventEmitter } from "../LazyEventEmitter"; import { LazyEventEmitter } from "../LazyEventEmitter";
import { MatrixRTCFocusMissingError } from "../utils/errors";
vitest.mock("../soundUtils"); vi.mock("../soundUtils");
vitest.mock("../useAudioContext"); vi.mock("../useAudioContext");
vitest.mock("./InCallView"); vi.mock("./InCallView");
vi.mock("react-use-measure", () => ({
default: (): [() => void, object] => [(): void => {}, {}],
}));
vitest.mock("../rtcSessionHelpers", async (importOriginal) => { const enterRTCSession = vi.hoisted(() => vi.fn(async () => Promise.resolve()));
const leaveRTCSession = vi.hoisted(() =>
vi.fn(
async (
rtcSession: unknown,
cause: unknown,
promiseBeforeHangup = Promise.resolve(),
) => await promiseBeforeHangup,
),
);
vi.mock("../rtcSessionHelpers", async (importOriginal) => {
// TODO: perhaps there is a more elegant way to manage the type import here? // TODO: perhaps there is a more elegant way to manage the type import here?
// eslint-disable-next-line @typescript-eslint/consistent-type-imports // eslint-disable-next-line @typescript-eslint/consistent-type-imports
const orig = await importOriginal<typeof import("../rtcSessionHelpers")>(); const orig = await importOriginal<typeof import("../rtcSessionHelpers")>();
vitest.spyOn(orig, "leaveRTCSession"); return { ...orig, enterRTCSession, leaveRTCSession };
return orig;
}); });
let playSound: MockedFunction< let playSound: MockedFunction<
@@ -55,11 +76,11 @@ const roomMembers = new Map([carol].map((p) => [p.userId, p]));
const roomId = "!foo:bar"; const roomId = "!foo:bar";
beforeEach(() => { beforeEach(() => {
vitest.clearAllMocks(); vi.clearAllMocks();
(prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({ (prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({
sound: new ArrayBuffer(0), sound: new ArrayBuffer(0),
}); });
playSound = vitest.fn(); playSound = vi.fn();
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({ (useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound, playSound,
}); });
@@ -75,7 +96,10 @@ beforeEach(() => {
); );
}); });
function createGroupCallView(widget: WidgetHelpers | null): { function createGroupCallView(
widget: WidgetHelpers | null,
joined = true,
): {
rtcSession: MockRTCSession; rtcSession: MockRTCSession;
getByText: ReturnType<typeof render>["getByText"]; getByText: ReturnType<typeof render>["getByText"];
} { } {
@@ -88,7 +112,7 @@ function createGroupCallView(widget: WidgetHelpers | null): {
const room = mockMatrixRoom({ const room = mockMatrixRoom({
relations: { relations: {
getChildEventsForEvent: () => getChildEventsForEvent: () =>
vitest.mocked({ vi.mocked({
getRelations: () => [], getRelations: () => [],
}), }),
} as unknown as RelationsContainer, } as unknown as RelationsContainer,
@@ -106,24 +130,27 @@ function createGroupCallView(widget: WidgetHelpers | null): {
localRtcMember, localRtcMember,
[], [],
).withMemberships(of([])); ).withMemberships(of([]));
rtcSession.joined = joined;
const muteState = { const muteState = {
audio: { enabled: false }, audio: { enabled: false },
video: { enabled: false }, video: { enabled: false },
} as MuteStates; } as MuteStates;
const { getByText } = render( const { getByText } = render(
<BrowserRouter> <BrowserRouter>
<GroupCallView <TooltipProvider>
client={client} <GroupCallView
isPasswordlessUser={false} client={client}
confineToRoom={false} isPasswordlessUser={false}
preload={false} confineToRoom={false}
skipLobby={false} preload={false}
hideHeader={true} skipLobby={false}
rtcSession={rtcSession as unknown as MatrixRTCSession} hideHeader={true}
isJoined rtcSession={rtcSession as unknown as MatrixRTCSession}
muteStates={muteState} isJoined={joined}
widget={widget} muteStates={muteState}
/> widget={widget}
/>
</TooltipProvider>
</BrowserRouter>, </BrowserRouter>,
); );
return { return {
@@ -132,7 +159,7 @@ function createGroupCallView(widget: WidgetHelpers | null): {
}; };
} }
test("will play a leave sound asynchronously in SPA mode", async () => { test("GroupCallView plays a leave sound asynchronously in SPA mode", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { getByText, rtcSession } = createGroupCallView(null); const { getByText, rtcSession } = createGroupCallView(null);
const leaveButton = getByText("Leave"); const leaveButton = getByText("Leave");
@@ -143,13 +170,13 @@ test("will play a leave sound asynchronously in SPA mode", async () => {
"user", "user",
expect.any(Promise), expect.any(Promise),
); );
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce(); expect(leaveRTCSession).toHaveBeenCalledOnce();
// Ensure that the playSound promise resolves within this test to avoid // Ensure that the playSound promise resolves within this test to avoid
// impacting the results of other tests // impacting the results of other tests
await waitFor(() => expect(leaveRTCSession).toHaveResolved()); await waitFor(() => expect(leaveRTCSession).toHaveResolved());
}); });
test("will play a leave sound synchronously in widget mode", async () => { test("GroupCallView plays a leave sound synchronously in widget mode", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const widget = { const widget = {
api: { api: {
@@ -158,7 +185,7 @@ test("will play a leave sound synchronously in widget mode", async () => {
lazyActions: new LazyEventEmitter(), lazyActions: new LazyEventEmitter(),
}; };
let resolvePlaySound: () => void; let resolvePlaySound: () => void;
playSound = vitest playSound = vi
.fn() .fn()
.mockReturnValue( .mockReturnValue(
new Promise<void>((resolve) => (resolvePlaySound = resolve)), new Promise<void>((resolve) => (resolvePlaySound = resolve)),
@@ -183,7 +210,7 @@ test("will play a leave sound synchronously in widget mode", async () => {
"user", "user",
expect.any(Promise), expect.any(Promise),
); );
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce(); expect(leaveRTCSession).toHaveBeenCalledOnce();
}); });
test("GroupCallView leaves the session when an error occurs", async () => { test("GroupCallView leaves the session when an error occurs", async () => {
@@ -205,8 +232,15 @@ test("GroupCallView leaves the session when an error occurs", async () => {
"error", "error",
expect.any(Promise), expect.any(Promise),
); );
expect(rtcSession.leaveRoomSession).toHaveBeenCalledOnce(); });
// Ensure that the playSound promise resolves within this test to avoid
// impacting the results of other tests test("GroupCallView shows errors that occur during joining", async () => {
await waitFor(() => expect(leaveRTCSession).toHaveResolved()); const user = userEvent.setup();
enterRTCSession.mockRejectedValue(new MatrixRTCFocusMissingError(""));
onTestFinished(() => {
enterRTCSession.mockReset();
});
createGroupCallView(null, false);
await user.click(screen.getByRole("button", { name: "Join call" }));
screen.getByText("Call is not supported");
}); });

View File

@@ -67,7 +67,6 @@ import {
useSetting, useSetting,
} from "../settings/settings"; } from "../settings/settings";
import { useTypedEventEmitter } from "../useEvents"; import { useTypedEventEmitter } from "../useEvents";
import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts";
declare global { declare global {
interface Window { interface Window {
@@ -100,6 +99,11 @@ export const GroupCallView: FC<Props> = ({
muteStates, muteStates,
widget, widget,
}) => { }) => {
// Used to thread through any errors that occur outside the error boundary
const [externalError, setExternalError] = useState<ElementCallError | null>(
null,
);
const memberships = useMatrixRTCSessionMemberships(rtcSession); const memberships = useMatrixRTCSessionMemberships(rtcSession);
const leaveSoundContext = useLatest( const leaveSoundContext = useLatest(
useAudioContext({ useAudioContext({
@@ -121,13 +125,11 @@ export const GroupCallView: FC<Props> = ({
}; };
}, [rtcSession]); }, [rtcSession]);
const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary();
useTypedEventEmitter( useTypedEventEmitter(
rtcSession, rtcSession,
MatrixRTCSessionEvent.MembershipManagerError, MatrixRTCSessionEvent.MembershipManagerError,
(error) => { (error) => {
showGroupCallErrorBoundary( setExternalError(
new RTCSessionError( new RTCSessionError(
ErrorCode.MEMBERSHIP_MANAGER_UNRECOVERABLE, ErrorCode.MEMBERSHIP_MANAGER_UNRECOVERABLE,
error.message ?? error, error.message ?? error,
@@ -190,17 +192,17 @@ export const GroupCallView: FC<Props> = ({
); );
} catch (e) { } catch (e) {
if (e instanceof ElementCallError) { if (e instanceof ElementCallError) {
showGroupCallErrorBoundary(e); setExternalError(e);
} else { } else {
logger.error(`Unknown Error while entering RTC session`, e); logger.error(`Unknown Error while entering RTC session`, e);
const error = new UnknownCallError( const error = new UnknownCallError(
e instanceof Error ? e : new Error("Unknown error", { cause: e }), e instanceof Error ? e : new Error("Unknown error", { cause: e }),
); );
showGroupCallErrorBoundary(error); setExternalError(error);
} }
} }
}, },
[showGroupCallErrorBoundary], [setExternalError],
); );
useEffect(() => { useEffect(() => {
@@ -422,7 +424,15 @@ export const GroupCallView: FC<Props> = ({
); );
let body: ReactNode; let body: ReactNode;
if (isJoined) { if (externalError) {
// If an error was recorded within this component but outside
// GroupCallErrorBoundary, create a component that rethrows the error from
// within the error boundary, so it can be handled uniformly
const ErrorComponent = (): ReactNode => {
throw externalError;
};
body = <ErrorComponent />;
} else if (isJoined) {
body = ( body = (
<> <>
{shareModal} {shareModal}

View File

@@ -11,19 +11,19 @@ import { type ReactElement, useCallback } from "react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx"; import { GroupCallErrorBoundary } from "./room/GroupCallErrorBoundary";
import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts"; import { useErrorBoundary } from "./useErrorBoundary";
import { ConnectionLostError } from "../utils/errors.ts"; import { ConnectionLostError } from "./utils/errors";
it("should show async error", async () => { it("should show async error", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const TestComponent = (): ReactElement => { const TestComponent = (): ReactElement => {
const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary(); const { showErrorBoundary } = useErrorBoundary();
const onClick = useCallback((): void => { const onClick = useCallback((): void => {
showGroupCallErrorBoundary(new ConnectionLostError()); showErrorBoundary(new ConnectionLostError());
}, [showGroupCallErrorBoundary]); }, [showErrorBoundary]);
return ( return (
<div> <div>

View File

@@ -7,18 +7,16 @@ Please see LICENSE in the repository root for full details.
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { ElementCallError } from "../utils/errors.ts";
export type UseErrorBoundaryApi = { export type UseErrorBoundaryApi = {
showGroupCallErrorBoundary: (error: ElementCallError) => void; showErrorBoundary: (error: Error) => void;
}; };
export function useGroupCallErrorBoundary(): UseErrorBoundaryApi { export function useErrorBoundary(): UseErrorBoundaryApi {
const [error, setError] = useState<ElementCallError | null>(null); const [error, setError] = useState<Error | null>(null);
const memoized: UseErrorBoundaryApi = useMemo( const memoized: UseErrorBoundaryApi = useMemo(
() => ({ () => ({
showGroupCallErrorBoundary: (error: ElementCallError) => setError(error), showErrorBoundary: (error: Error) => setError(error),
}), }),
[], [],
); );

View File

@@ -286,8 +286,9 @@ export class MockRTCSession extends TypedEventEmitter<
super(); super();
} }
public isJoined(): true { public joined = true;
return true; public isJoined(): boolean {
return this.joined;
} }
public withMemberships( public withMemberships(