From ce2395f94be0b7dab8092c22cc07ea0f5ca66df4 Mon Sep 17 00:00:00 2001 From: Kerry Date: Thu, 14 Sep 2023 12:26:32 +1200 Subject: [PATCH] Error boundary (#1743) * reinstate link to browser session detail * add util hook for unwrapping current browser session id * fix bug in compatsessiondetail createdAt -> finishedAt * browser session detail page * tweak naming * add ErrorBoundary * useCurrentBrowserSessionId throw when error * add ErrorBoundary to pages * throw errors instead of rendering error * add unwrap util --- .../src/components/BrowserSessionList.tsx | 12 +--- frontend/src/components/CompatSessionList.tsx | 6 +- frontend/src/components/ErrorBoundary.tsx | 72 +++++++++++++++++++ frontend/src/components/OAuth2SessionList.tsx | 6 +- .../SessionDetail/BrowserSessionDetail.tsx | 7 +- frontend/src/pages/BrowserSession.tsx | 7 +- frontend/src/pages/BrowserSessionList.tsx | 7 +- frontend/src/pages/CompatSessionList.tsx | 7 +- frontend/src/pages/OAuth2Client.tsx | 7 +- frontend/src/pages/OAuth2SessionList.tsx | 7 +- frontend/src/pages/Profile.tsx | 7 +- frontend/src/pages/SessionDetail.tsx | 7 +- frontend/src/pages/SessionsOverview.tsx | 7 +- frontend/src/pages/VerifyEmail.tsx | 7 +- frontend/src/result.ts | 11 +++ .../session/useCurrentBrowserSessionId.ts | 25 +++---- 16 files changed, 154 insertions(+), 48 deletions(-) create mode 100644 frontend/src/components/ErrorBoundary.tsx diff --git a/frontend/src/components/BrowserSessionList.tsx b/frontend/src/components/BrowserSessionList.tsx index 7a030111..5750dc8e 100644 --- a/frontend/src/components/BrowserSessionList.tsx +++ b/frontend/src/components/BrowserSessionList.tsx @@ -26,12 +26,11 @@ import { FIRST_PAGE, Pagination, } from "../pagination"; -import { isErr, isOk, unwrapErr, unwrapOk } from "../result"; +import { isOk, unwrap, unwrapOk } from "../result"; import { useCurrentBrowserSessionId } from "../utils/session/useCurrentBrowserSessionId"; import BlockList from "./BlockList"; import BrowserSession from "./BrowserSession"; -import GraphQLError from "./GraphQLError"; import PaginationControls from "./PaginationControls"; import { Title } from "./Typography"; @@ -113,19 +112,14 @@ const paginationFamily = atomFamily((userId: string) => { }); const BrowserSessionList: React.FC<{ userId: string }> = ({ userId }) => { - const { currentBrowserSessionId, currentBrowserSessionIdError } = - useCurrentBrowserSessionId(); + const currentBrowserSessionId = useCurrentBrowserSessionId(); const [pending, startTransition] = useTransition(); const result = useAtomValue(browserSessionListFamily(userId)); const setPagination = useSetAtom(currentPaginationAtom); const [prevPage, nextPage] = useAtomValue(paginationFamily(userId)); const [filter, setFilter] = useAtom(filterAtom); - if (currentBrowserSessionIdError) - return ; - if (isErr(result)) return ; - - const browserSessions = unwrapOk(result); + const browserSessions = unwrap(result); if (browserSessions === null) return <>Failed to load browser sessions; const paginate = (pagination: Pagination): void => { diff --git a/frontend/src/components/CompatSessionList.tsx b/frontend/src/components/CompatSessionList.tsx index 28666903..ebbd2c60 100644 --- a/frontend/src/components/CompatSessionList.tsx +++ b/frontend/src/components/CompatSessionList.tsx @@ -26,11 +26,10 @@ import { FIRST_PAGE, Pagination, } from "../pagination"; -import { isErr, isOk, unwrapErr, unwrapOk } from "../result"; +import { isOk, unwrap, unwrapOk } from "../result"; import BlockList from "./BlockList"; import CompatSession from "./CompatSession"; -import GraphQLError from "./GraphQLError"; import PaginationControls from "./PaginationControls"; import { Title } from "./Typography"; @@ -116,8 +115,7 @@ const CompatSessionList: React.FC<{ userId: string }> = ({ userId }) => { const [prevPage, nextPage] = useAtomValue(paginationFamily(userId)); const [filter, setFilter] = useAtom(filterAtom); - if (isErr(result)) return ; - const compatSessionList = unwrapOk(result); + const compatSessionList = unwrap(result); if (compatSessionList === null) return <>Failed to load sessions.; const paginate = (pagination: Pagination): void => { diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..8db0f5ec --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,72 @@ +// Copyright 2023 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CombinedError } from "@urql/core"; +import { Alert } from "@vector-im/compound-web"; +import { ErrorInfo, ReactNode, PureComponent } from "react"; + +import GraphQLError from "./GraphQLError"; + +interface Props { + children: ReactNode; +} + +interface IState { + error?: Error; +} + +const isGqlError = (error: Error): error is CombinedError => + error.name === "CombinedError"; + +/** + * This error boundary component can be used to wrap large content areas and + * catch exceptions during rendering in the component tree below them. + */ +export default class ErrorBoundary extends PureComponent { + public constructor(props: Props) { + super(props); + + this.state = {}; + } + + public static getDerivedStateFromError(error: Error): Partial { + // Side effects are not permitted here, so we only update the state so + // that the next render shows an error message. + return { error }; + } + + public componentDidCatch(error: Error, { componentStack }: ErrorInfo): void { + console.error(error); + console.error( + "The above error occurred while React was rendering the following components:", + componentStack, + ); + } + + public render(): ReactNode { + if (this.state.error) { + if (isGqlError(this.state.error)) { + return ; + } + + return ( + + {this.state.error.message} + + ); + } + + return this.props.children; + } +} diff --git a/frontend/src/components/OAuth2SessionList.tsx b/frontend/src/components/OAuth2SessionList.tsx index 0426c6fb..b1b8d93e 100644 --- a/frontend/src/components/OAuth2SessionList.tsx +++ b/frontend/src/components/OAuth2SessionList.tsx @@ -26,10 +26,9 @@ import { FIRST_PAGE, Pagination, } from "../pagination"; -import { isErr, isOk, unwrapErr, unwrapOk } from "../result"; +import { isOk, unwrap, unwrapOk } from "../result"; import BlockList from "./BlockList"; -import GraphQLError from "./GraphQLError"; import OAuth2Session from "./OAuth2Session"; import PaginationControls from "./PaginationControls"; import { Title } from "./Typography"; @@ -121,8 +120,7 @@ const OAuth2SessionList: React.FC = ({ userId }) => { const [prevPage, nextPage] = useAtomValue(paginationFamily(userId)); const [filter, setFilter] = useAtom(filterAtom); - if (isErr(result)) return ; - const oauth2Sessions = unwrapOk(result); + const oauth2Sessions = unwrap(result); if (oauth2Sessions === null) return <>Failed to load sessions.; const paginate = (pagination: Pagination): void => { diff --git a/frontend/src/components/SessionDetail/BrowserSessionDetail.tsx b/frontend/src/components/SessionDetail/BrowserSessionDetail.tsx index ab9719c7..a99d0772 100644 --- a/frontend/src/components/SessionDetail/BrowserSessionDetail.tsx +++ b/frontend/src/components/SessionDetail/BrowserSessionDetail.tsx @@ -24,7 +24,6 @@ import { useCurrentBrowserSessionId } from "../../utils/session/useCurrentBrowse import BlockList from "../BlockList/BlockList"; import { useEndBrowserSession } from "../BrowserSession"; import DateTime from "../DateTime"; -import GraphQLError from "../GraphQLError"; import EndSessionButton from "../Session/EndSessionButton"; import styles from "./BrowserSessionDetail.module.css"; @@ -36,15 +35,11 @@ type Props = { const BrowserSessionDetail: React.FC = ({ session }) => { const data = useFragment(BROWSER_SESSION_DETAIL_FRAGMENT, session); - const { currentBrowserSessionId, currentBrowserSessionIdError } = - useCurrentBrowserSessionId(); + const currentBrowserSessionId = useCurrentBrowserSessionId(); const isCurrent = currentBrowserSessionId === data.id; const onSessionEnd = useEndBrowserSession(data.id, isCurrent); - if (currentBrowserSessionIdError) - return ; - const deviceInformation = parseUserAgent(data.userAgent || undefined); const sessionName = sessionNameFromDeviceInformation(deviceInformation) || "Browser session"; diff --git a/frontend/src/pages/BrowserSession.tsx b/frontend/src/pages/BrowserSession.tsx index 1d7e94c1..893eea2f 100644 --- a/frontend/src/pages/BrowserSession.tsx +++ b/frontend/src/pages/BrowserSession.tsx @@ -17,6 +17,7 @@ import { atomFamily } from "jotai/utils"; import { atomWithQuery } from "jotai-urql"; import { mapQueryAtom } from "../atoms"; +import ErrorBoundary from "../components/ErrorBoundary"; import GraphQLError from "../components/GraphQLError"; import NotFound from "../components/NotFound"; import BrowserSessionDetail from "../components/SessionDetail/BrowserSessionDetail"; @@ -70,7 +71,11 @@ const BrowserSession: React.FC<{ id: string }> = ({ id }) => { const browserSession = unwrapOk(result); if (!browserSession) return ; - return ; + return ( + + + + ); }; export default BrowserSession; diff --git a/frontend/src/pages/BrowserSessionList.tsx b/frontend/src/pages/BrowserSessionList.tsx index aaefeef9..f778ce2e 100644 --- a/frontend/src/pages/BrowserSessionList.tsx +++ b/frontend/src/pages/BrowserSessionList.tsx @@ -16,6 +16,7 @@ import { useAtomValue } from "jotai"; import { currentUserIdAtom } from "../atoms"; import List from "../components/BrowserSessionList"; +import ErrorBoundary from "../components/ErrorBoundary"; import GraphQLError from "../components/GraphQLError"; import NotLoggedIn from "../components/NotLoggedIn"; import { isErr, unwrapErr, unwrapOk } from "../result"; @@ -27,7 +28,11 @@ const BrowserSessionList: React.FC = () => { const userId = unwrapOk(result); if (userId === null) return ; - return ; + return ( + + + + ); }; export default BrowserSessionList; diff --git a/frontend/src/pages/CompatSessionList.tsx b/frontend/src/pages/CompatSessionList.tsx index 902bceae..3b4ab370 100644 --- a/frontend/src/pages/CompatSessionList.tsx +++ b/frontend/src/pages/CompatSessionList.tsx @@ -16,6 +16,7 @@ import { useAtomValue } from "jotai"; import { currentUserIdAtom } from "../atoms"; import List from "../components/CompatSessionList"; +import ErrorBoundary from "../components/ErrorBoundary"; import GraphQLError from "../components/GraphQLError"; import NotLoggedIn from "../components/NotLoggedIn"; import { isErr, unwrapErr, unwrapOk } from "../result"; @@ -27,7 +28,11 @@ const CompatSessionList: React.FC = () => { const userId = unwrapOk(result); if (userId === null) return ; - return ; + return ( + + + + ); }; export default CompatSessionList; diff --git a/frontend/src/pages/OAuth2Client.tsx b/frontend/src/pages/OAuth2Client.tsx index f0921cf3..9840f326 100644 --- a/frontend/src/pages/OAuth2Client.tsx +++ b/frontend/src/pages/OAuth2Client.tsx @@ -18,6 +18,7 @@ import { atomWithQuery } from "jotai-urql"; import { mapQueryAtom } from "../atoms"; import OAuth2ClientDetail from "../components/Client/OAuth2ClientDetail"; +import ErrorBoundary from "../components/ErrorBoundary"; import GraphQLError from "../components/GraphQLError"; import NotFound from "../components/NotFound"; import { graphql } from "../gql"; @@ -52,7 +53,11 @@ const OAuth2Client: React.FC<{ id: string }> = ({ id }) => { const oauth2Client = unwrapOk(result); if (!oauth2Client) return ; - return ; + return ( + + + + ); }; export default OAuth2Client; diff --git a/frontend/src/pages/OAuth2SessionList.tsx b/frontend/src/pages/OAuth2SessionList.tsx index 9663bc5b..c6cc2b9c 100644 --- a/frontend/src/pages/OAuth2SessionList.tsx +++ b/frontend/src/pages/OAuth2SessionList.tsx @@ -15,6 +15,7 @@ import { useAtomValue } from "jotai"; import { currentUserIdAtom } from "../atoms"; +import ErrorBoundary from "../components/ErrorBoundary"; import GraphQLError from "../components/GraphQLError"; import NotLoggedIn from "../components/NotLoggedIn"; import List from "../components/OAuth2SessionList"; @@ -27,7 +28,11 @@ const OAuth2SessionList: React.FC = () => { const userId = unwrapOk(result); if (userId === null) return ; - return ; + return ( + + + + ); }; export default OAuth2SessionList; diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 88c2acae..38473236 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -15,6 +15,7 @@ import { useAtomValue } from "jotai"; import { currentUserIdAtom } from "../atoms"; +import ErrorBoundary from "../components/ErrorBoundary"; import GraphQLError from "../components/GraphQLError"; import NotLoggedIn from "../components/NotLoggedIn"; import UserProfile from "../components/UserProfile"; @@ -27,7 +28,11 @@ const Profile: React.FC = () => { const userId = unwrapOk(result); if (userId === null) return ; - return ; + return ( + + + + ); }; export default Profile; diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 30e6baf5..f4de2f0d 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -15,6 +15,7 @@ import { useAtomValue } from "jotai"; import { currentUserIdAtom } from "../atoms"; +import ErrorBoundary from "../components/ErrorBoundary"; import GraphQLError from "../components/GraphQLError"; import NotLoggedIn from "../components/NotLoggedIn"; import UserSessionDetail from "../components/SessionDetail"; @@ -27,7 +28,11 @@ const SessionDetail: React.FC<{ deviceId: string }> = ({ deviceId }) => { const userId = unwrapOk(result); if (userId === null) return ; - return ; + return ( + + + + ); }; export default SessionDetail; diff --git a/frontend/src/pages/SessionsOverview.tsx b/frontend/src/pages/SessionsOverview.tsx index 155848f2..3e7b5cd1 100644 --- a/frontend/src/pages/SessionsOverview.tsx +++ b/frontend/src/pages/SessionsOverview.tsx @@ -16,6 +16,7 @@ import { useAtomValue } from "jotai"; import { atomWithQuery } from "jotai-urql"; import { mapQueryAtom } from "../atoms"; +import ErrorBoundary from "../components/ErrorBoundary"; import GraphQLError from "../components/GraphQLError"; import NotLoggedIn from "../components/NotLoggedIn"; import UserSessionsOverview from "../components/UserSessionsOverview"; @@ -54,7 +55,11 @@ const SessionsOverview: React.FC = () => { const data = unwrapOk(result); if (data === null) return ; - return ; + return ( + + + + ); }; export default SessionsOverview; diff --git a/frontend/src/pages/VerifyEmail.tsx b/frontend/src/pages/VerifyEmail.tsx index a7acea07..4a162a61 100644 --- a/frontend/src/pages/VerifyEmail.tsx +++ b/frontend/src/pages/VerifyEmail.tsx @@ -17,6 +17,7 @@ import { atomFamily } from "jotai/utils"; import { atomWithQuery } from "jotai-urql"; import { mapQueryAtom } from "../atoms"; +import ErrorBoundary from "../components/ErrorBoundary"; import GraphQLError from "../components/GraphQLError"; import VerifyEmailComponent from "../components/VerifyEmail"; import { graphql } from "../gql"; @@ -51,7 +52,11 @@ const VerifyEmail: React.FC<{ id: string }> = ({ id }) => { const email = unwrapOk(result); if (email == null) return <>Unknown email; - return ; + return ( + + + + ); }; export default VerifyEmail; diff --git a/frontend/src/result.ts b/frontend/src/result.ts index 7b9dcd21..b7a1d288 100644 --- a/frontend/src/result.ts +++ b/frontend/src/result.ts @@ -59,3 +59,14 @@ export const unwrapOk = (result: Ok): T => result[OK]; // Extract the error from an `Err` export const unwrapErr = (result: Err): E => result[ERR]; + +/** + * Check result for error and throw unwrapped error + * Otherwise return unwrapped Ok result + */ +export const unwrap = (result: Result): T => { + if (isErr(result)) { + throw unwrapErr(result); + } + return unwrapOk(result); +}; diff --git a/frontend/src/utils/session/useCurrentBrowserSessionId.ts b/frontend/src/utils/session/useCurrentBrowserSessionId.ts index 75d16daa..019fad37 100644 --- a/frontend/src/utils/session/useCurrentBrowserSessionId.ts +++ b/frontend/src/utils/session/useCurrentBrowserSessionId.ts @@ -21,26 +21,19 @@ import { isOk, unwrapOk, unwrapErr, isErr } from "../../result"; /** * Query the current browser session id * and unwrap the result + * throws error when error result */ -export const useCurrentBrowserSessionId = (): { - currentBrowserSessionId?: string | null; - currentBrowserSessionIdError?: CombinedError; -} => { +export const useCurrentBrowserSessionId = (): string | null => { const currentSessionIdResult = useAtomValue(currentBrowserSessionIdAtom); - if (isOk(currentSessionIdResult)) { - return { - currentBrowserSessionId: unwrapOk(currentSessionIdResult), - }; - } - if (isErr(currentSessionIdResult)) { - return { - currentBrowserSessionIdError: unwrapErr( - currentSessionIdResult, - ) as CombinedError, - }; + // eslint-disable-next-line no-throw-literal + throw unwrapErr(currentSessionIdResult) as Error; } - return {}; + if (isOk(currentSessionIdResult)) { + return unwrapOk(currentSessionIdResult); + } + + return null; };