error management: showError API for async error handling

This commit is contained in:
Valere
2025-03-10 15:20:51 +01:00
parent 13a19ed751
commit c22412c045
7 changed files with 274 additions and 31 deletions

View File

@@ -8,10 +8,10 @@ Please see LICENSE in the repository root for full details.
import { describe, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import {
type FC,
type ReactElement,
type ReactNode,
useCallback,
useEffect,
useState,
} from "react";
import { BrowserRouter } from "react-router-dom";
@@ -27,6 +27,8 @@ import {
UnknownCallError,
} from "../utils/errors.ts";
import { mockConfig } from "../utils/test.ts";
import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts";
import { GroupCallErrorBoundaryContextProvider } from "./GroupCallErrorBoundaryContextProvider.tsx";
test.each([
{
@@ -210,3 +212,30 @@ describe("Rageshake button", () => {
).not.toBeInTheDocument();
});
});
test("should show async error with useElementCallErrorContext", async () => {
// const error = new MatrixRTCFocusMissingError("example.com");
const TestComponent = (): ReactNode => {
const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary();
useEffect(() => {
setTimeout(() => {
showGroupCallErrorBoundary(new ConnectionLostError());
});
}, [showGroupCallErrorBoundary]);
return <div>Hello</div>;
};
const onErrorMock = vi.fn();
render(
<BrowserRouter>
<GroupCallErrorBoundaryContextProvider>
<GroupCallErrorBoundary onError={onErrorMock}>
<TestComponent />
</GroupCallErrorBoundary>
</GroupCallErrorBoundaryContextProvider>
</BrowserRouter>,
);
await screen.findByText("Connection lost");
});

View File

@@ -105,6 +105,30 @@ interface BoundaryProps {
onError?: (error: unknown) => void;
}
/**
* An ErrorBoundary component that handles ElementCalls errors that can occur during a group call.
* It is based on the sentry ErrorBoundary component, that will log the error to sentry.
*
* The error fallback will show an error page with:
* - a description of the error
* - a button to go back the home screen
* - optional call-to-action buttons (ex: reconnect for connection lost)
* - A rageshake button for unknown errors
*
* For async errors the `useCallErrorBoundary` hook should be used to show the error page
* ```
* const { showGroupCallErrorBoundary } = useCallErrorBoundary();
* ... some async code
* catch(error) {
* showGroupCallErrorBoundary(error);
* }
* ...
* ```
* @param recoveryActionHandler
* @param onError
* @param children
* @constructor
*/
export const GroupCallErrorBoundary = ({
recoveryActionHandler,
onError,

View File

@@ -0,0 +1,18 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { createContext } from "react";
import { type ElementCallError } from "../utils/errors.ts";
export type GroupCallErrorBoundaryContextType = {
subscribe: (cb: (error: ElementCallError) => void) => () => void;
notifyHandled: (error: ElementCallError) => void;
};
export const GroupCallErrorBoundaryContext =
createContext<GroupCallErrorBoundaryContextType | null>(null);

View File

@@ -0,0 +1,54 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { it } from "vitest";
import { render, screen } from "@testing-library/react";
import { type ReactElement, useCallback } from "react";
import userEvent from "@testing-library/user-event";
import { BrowserRouter } from "react-router-dom";
import { GroupCallErrorBoundaryContextProvider } from "./GroupCallErrorBoundaryContextProvider.tsx";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts";
import { ConnectionLostError } from "../utils/errors.ts";
it("should show async error", async () => {
const user = userEvent.setup();
const TestComponent = (): ReactElement => {
const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary();
const onClick = useCallback((): void => {
showGroupCallErrorBoundary(new ConnectionLostError());
}, [showGroupCallErrorBoundary]);
return (
<div>
<h1>HELLO</h1>
<button onClick={onClick}>Click me</button>
</div>
);
};
render(
<BrowserRouter>
<GroupCallErrorBoundaryContextProvider>
<GroupCallErrorBoundary>
<TestComponent />
</GroupCallErrorBoundary>
</GroupCallErrorBoundaryContextProvider>
</BrowserRouter>,
);
await user.click(screen.getByRole("button", { name: "Click me" }));
await screen.findByText("Connection lost");
await user.click(screen.getByRole("button", { name: "Reconnect" }));
await screen.findByText("HELLO");
});

View File

@@ -0,0 +1,54 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type FC,
type PropsWithChildren,
useCallback,
useMemo,
useRef,
} from "react";
import type { ElementCallError } from "../utils/errors.ts";
import {
GroupCallErrorBoundaryContext,
type GroupCallErrorBoundaryContextType,
} from "./GroupCallErrorBoundaryContext.tsx";
export const GroupCallErrorBoundaryContextProvider: FC<PropsWithChildren> = ({
children,
}) => {
const subscribers = useRef<Set<(error: ElementCallError) => void>>(new Set());
// Register a component for updates
const subscribe = useCallback(
(cb: (error: ElementCallError) => void): (() => void) => {
subscribers.current.add(cb);
return (): boolean => subscribers.current.delete(cb); // Unsubscribe function
},
[],
);
// Notify all subscribers
const notify = useCallback((error: ElementCallError) => {
subscribers.current.forEach((callback) => callback(error));
}, []);
const context: GroupCallErrorBoundaryContextType = useMemo(
() => ({
notifyHandled: notify,
subscribe,
}),
[subscribe, notify],
);
return (
<GroupCallErrorBoundaryContext.Provider value={context}>
{children}
</GroupCallErrorBoundaryContext.Provider>
);
};

View File

@@ -57,6 +57,8 @@ import {
UnknownCallError,
} from "../utils/errors.ts";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
import { GroupCallErrorBoundaryContextProvider } from "./GroupCallErrorBoundaryContextProvider.tsx";
import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts";
declare global {
interface Window {
@@ -77,7 +79,15 @@ interface Props {
widget: WidgetHelpers | null;
}
export const GroupCallView: FC<Props> = ({
export const GroupCallView: FC<Props> = (props) => {
return (
<GroupCallErrorBoundaryContextProvider>
<GroupCallViewInner {...props} />
</GroupCallErrorBoundaryContextProvider>
);
};
export const GroupCallViewInner: FC<Props> = ({
client,
isPasswordlessUser,
confineToRoom,
@@ -156,25 +166,29 @@ export const GroupCallView: FC<Props> = ({
const latestDevices = useLatest(deviceContext);
const latestMuteStates = useLatest(muteStates);
const enterRTCSessionOrError = async (
rtcSession: MatrixRTCSession,
perParticipantE2EE: boolean,
): Promise<void> => {
try {
await enterRTCSession(rtcSession, perParticipantE2EE);
} catch (e) {
if (e instanceof ElementCallError) {
// e.code === ErrorCode.MISSING_LIVE_KIT_SERVICE_URL)
setEnterRTCError(e);
} else {
logger.error(`Unknown Error while entering RTC session`, e);
const error = new UnknownCallError(
e instanceof Error ? e : new Error("Unknown error", { cause: e }),
);
setEnterRTCError(error);
const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary();
const enterRTCSessionOrError = useCallback(
async (
rtcSession: MatrixRTCSession,
perParticipantE2EE: boolean,
): Promise<void> => {
try {
await enterRTCSession(rtcSession, perParticipantE2EE);
} catch (e) {
if (e instanceof ElementCallError) {
showGroupCallErrorBoundary(e);
} else {
logger.error(`Unknown Error while entering RTC session`, e);
const error = new UnknownCallError(
e instanceof Error ? e : new Error("Unknown error", { cause: e }),
);
showGroupCallErrorBoundary(error);
}
}
}
};
},
[showGroupCallErrorBoundary],
);
useEffect(() => {
const defaultDeviceSetup = async ({
@@ -255,12 +269,11 @@ export const GroupCallView: FC<Props> = ({
perParticipantE2EE,
latestDevices,
latestMuteStates,
enterRTCSessionOrError,
]);
const [left, setLeft] = useState(false);
const [enterRTCError, setEnterRTCError] = useState<ElementCallError | null>(
null,
);
const navigate = useNavigate();
const onLeave = useCallback(
@@ -378,14 +391,7 @@ export const GroupCallView: FC<Props> = ({
);
let body: ReactNode;
if (enterRTCError) {
// If an ElementCallError was recorded, then create a component that will fail to render and throw
// the error. This will then be handled by the ErrorBoundary component.
const ErrorComponent = (): ReactNode => {
throw enterRTCError;
};
body = <ErrorComponent />;
} else if (isJoined) {
if (isJoined) {
body = (
<>
{shareModal}

View File

@@ -0,0 +1,58 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { ElementCallError } from "../utils/errors.ts";
import { GroupCallErrorBoundaryContext } from "./GroupCallErrorBoundaryContext.tsx";
export type UseErrorBoundaryApi = {
showGroupCallErrorBoundary: (error: ElementCallError) => void;
};
export function useGroupCallErrorBoundary(): UseErrorBoundaryApi {
const context = useContext(GroupCallErrorBoundaryContext);
if (!context)
throw new Error(
"useGroupCallErrorBoundary must be used within an GoupCallErrorBoundary",
);
const [error, setError] = useState<ElementCallError | null>(null);
const resetErrorIfNeeded = useCallback(
(handled: ElementCallError): void => {
// There might be several useGroupCallErrorBoundary in the tree,
// so only clear our state if it's the one we're handling?
if (error && handled === error) {
// reset current state
setError(null);
}
},
[error],
);
useEffect(() => {
// return a function to unsubscribe
return context.subscribe((error: ElementCallError): void => {
resetErrorIfNeeded(error);
});
}, [resetErrorIfNeeded, context]);
const memoized: UseErrorBoundaryApi = useMemo(
() => ({
showGroupCallErrorBoundary: (error: ElementCallError) => setError(error),
}),
[],
);
if (error) {
throw error;
}
return memoized;
}