error management: showError API for async error handling
This commit is contained in:
@@ -8,10 +8,10 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import {
|
import {
|
||||||
type FC,
|
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
UnknownCallError,
|
UnknownCallError,
|
||||||
} from "../utils/errors.ts";
|
} from "../utils/errors.ts";
|
||||||
import { mockConfig } from "../utils/test.ts";
|
import { mockConfig } from "../utils/test.ts";
|
||||||
|
import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts";
|
||||||
|
import { GroupCallErrorBoundaryContextProvider } from "./GroupCallErrorBoundaryContextProvider.tsx";
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
{
|
{
|
||||||
@@ -210,3 +212,30 @@ describe("Rageshake button", () => {
|
|||||||
).not.toBeInTheDocument();
|
).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");
|
||||||
|
});
|
||||||
|
|||||||
@@ -105,6 +105,30 @@ interface BoundaryProps {
|
|||||||
onError?: (error: unknown) => void;
|
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 = ({
|
export const GroupCallErrorBoundary = ({
|
||||||
recoveryActionHandler,
|
recoveryActionHandler,
|
||||||
onError,
|
onError,
|
||||||
|
|||||||
18
src/room/GroupCallErrorBoundaryContext.tsx
Normal file
18
src/room/GroupCallErrorBoundaryContext.tsx
Normal 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);
|
||||||
54
src/room/GroupCallErrorBoundaryContextProvider.test.tsx
Normal file
54
src/room/GroupCallErrorBoundaryContextProvider.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
54
src/room/GroupCallErrorBoundaryContextProvider.tsx
Normal file
54
src/room/GroupCallErrorBoundaryContextProvider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -57,6 +57,8 @@ import {
|
|||||||
UnknownCallError,
|
UnknownCallError,
|
||||||
} from "../utils/errors.ts";
|
} from "../utils/errors.ts";
|
||||||
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
|
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
|
||||||
|
import { GroupCallErrorBoundaryContextProvider } from "./GroupCallErrorBoundaryContextProvider.tsx";
|
||||||
|
import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -77,7 +79,15 @@ interface Props {
|
|||||||
widget: WidgetHelpers | null;
|
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,
|
client,
|
||||||
isPasswordlessUser,
|
isPasswordlessUser,
|
||||||
confineToRoom,
|
confineToRoom,
|
||||||
@@ -156,25 +166,29 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
const latestDevices = useLatest(deviceContext);
|
const latestDevices = useLatest(deviceContext);
|
||||||
const latestMuteStates = useLatest(muteStates);
|
const latestMuteStates = useLatest(muteStates);
|
||||||
|
|
||||||
const enterRTCSessionOrError = async (
|
const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary();
|
||||||
rtcSession: MatrixRTCSession,
|
|
||||||
perParticipantE2EE: boolean,
|
const enterRTCSessionOrError = useCallback(
|
||||||
): Promise<void> => {
|
async (
|
||||||
try {
|
rtcSession: MatrixRTCSession,
|
||||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
perParticipantE2EE: boolean,
|
||||||
} catch (e) {
|
): Promise<void> => {
|
||||||
if (e instanceof ElementCallError) {
|
try {
|
||||||
// e.code === ErrorCode.MISSING_LIVE_KIT_SERVICE_URL)
|
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||||
setEnterRTCError(e);
|
} catch (e) {
|
||||||
} else {
|
if (e instanceof ElementCallError) {
|
||||||
logger.error(`Unknown Error while entering RTC session`, e);
|
showGroupCallErrorBoundary(e);
|
||||||
const error = new UnknownCallError(
|
} else {
|
||||||
e instanceof Error ? e : new Error("Unknown error", { cause: e }),
|
logger.error(`Unknown Error while entering RTC session`, e);
|
||||||
);
|
const error = new UnknownCallError(
|
||||||
setEnterRTCError(error);
|
e instanceof Error ? e : new Error("Unknown error", { cause: e }),
|
||||||
|
);
|
||||||
|
showGroupCallErrorBoundary(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
};
|
[showGroupCallErrorBoundary],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const defaultDeviceSetup = async ({
|
const defaultDeviceSetup = async ({
|
||||||
@@ -255,12 +269,11 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
perParticipantE2EE,
|
perParticipantE2EE,
|
||||||
latestDevices,
|
latestDevices,
|
||||||
latestMuteStates,
|
latestMuteStates,
|
||||||
|
enterRTCSessionOrError,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [left, setLeft] = useState(false);
|
const [left, setLeft] = useState(false);
|
||||||
const [enterRTCError, setEnterRTCError] = useState<ElementCallError | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const onLeave = useCallback(
|
const onLeave = useCallback(
|
||||||
@@ -378,14 +391,7 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
let body: ReactNode;
|
let body: ReactNode;
|
||||||
if (enterRTCError) {
|
if (isJoined) {
|
||||||
// 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) {
|
|
||||||
body = (
|
body = (
|
||||||
<>
|
<>
|
||||||
{shareModal}
|
{shareModal}
|
||||||
|
|||||||
58
src/room/useCallErrorBoundary.ts
Normal file
58
src/room/useCallErrorBoundary.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user