Skip to content

Commit 8ddad67

Browse files
handle api errors in error boundary (#17086)
* handle api errors in error boundary * handling resetting the error boundary * splitting out error boundary types
1 parent 45cf0d2 commit 8ddad67

File tree

7 files changed

+151
-112
lines changed

7 files changed

+151
-112
lines changed

components/dashboard/src/App.tsx

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,23 @@
44
* See License.AGPL.txt in the project root for license information.
55
*/
66

7-
import * as GitpodCookie from "@gitpod/gitpod-protocol/lib/util/gitpod-cookie";
87
import React, { FC, Suspense } from "react";
98
import { AppLoading } from "./app/AppLoading";
109
import { AppRoutes } from "./app/AppRoutes";
11-
import { GitpodErrorBoundary } from "./components/ErrorBoundary";
1210
import { useCurrentOrg } from "./data/organizations/orgs-query";
1311
import { useAnalyticsTracking } from "./hooks/use-analytics-tracking";
1412
import { useUserLoader } from "./hooks/use-user-loader";
1513
import { Login } from "./Login";
16-
import { isGitpodIo } from "./utils";
17-
18-
const Setup = React.lazy(() => import(/* webpackPrefetch: true */ "./Setup"));
1914

2015
// Wrap the App in an ErrorBoundary to catch User/Org loading errors
2116
// This will also catch any errors that happen to bubble all the way up to the top
2217
const AppWithErrorBoundary: FC = () => {
23-
return (
24-
<GitpodErrorBoundary>
25-
<App />
26-
</GitpodErrorBoundary>
27-
);
18+
return <App />;
2819
};
2920

3021
// Top level Dashboard App component
3122
const App: FC = () => {
32-
const { user, isSetupRequired, loading } = useUserLoader();
23+
const { user, loading } = useUserLoader();
3324
const currentOrgQuery = useCurrentOrg();
3425

3526
// Setup analytics/tracking
@@ -39,25 +30,7 @@ const App: FC = () => {
3930
return <AppLoading />;
4031
}
4132

42-
// This may be flagged true during user/teams loading
43-
if (isSetupRequired) {
44-
return (
45-
<Suspense fallback={<AppLoading />}>
46-
<Setup />
47-
</Suspense>
48-
);
49-
}
50-
51-
// Redirects to www site if it's the root, no user, and no gp cookie present (has logged in recently)
52-
// Should come after the <Setup/> check
53-
if (isGitpodIo() && window.location.pathname === "/" && window.location.hash === "" && !user) {
54-
// If there's no gp cookie, bounce to www site
55-
if (!GitpodCookie.isPresent(document.cookie)) {
56-
window.location.href = `https://www.gitpod.io`;
57-
return <div></div>;
58-
}
59-
}
60-
33+
// Technically this should get handled in the QueryErrorBoundary, but having it here doesn't hurt
6134
// At this point if there's no user, they should Login
6235
if (!user) {
6336
return <Login />;

components/dashboard/src/Login.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import { AuthProviderInfo } from "@gitpod/gitpod-protocol";
88
import * as GitpodCookie from "@gitpod/gitpod-protocol/lib/util/gitpod-cookie";
9-
import { useContext, useEffect, useState, useMemo, useCallback } from "react";
9+
import { useContext, useEffect, useState, useMemo, useCallback, FC } from "react";
1010
import { UserContext } from "./user-context";
1111
import { getGitpodService } from "./service/service";
1212
import { iconForAuthProvider, openAuthorizeWindow, simplifyProviderName, getSafeURLRedirect } from "./provider-utils";
@@ -47,7 +47,10 @@ export function hasVisitedMarketingWebsiteBefore() {
4747
return document.cookie.match("gitpod-marketing-website-visited=true");
4848
}
4949

50-
export function Login() {
50+
type LoginProps = {
51+
onLoggedIn?: () => void;
52+
};
53+
export const Login: FC<LoginProps> = ({ onLoggedIn }) => {
5154
const { setUser } = useContext(UserContext);
5255

5356
const urlHash = useMemo(() => getURLHash(), []);
@@ -96,14 +99,16 @@ export function Login() {
9699
async (payload?: string) => {
97100
updateUser().catch(console.error);
98101

102+
onLoggedIn && onLoggedIn();
103+
99104
// Check for a valid returnTo in payload
100105
const safeReturnTo = getSafeURLRedirect(payload);
101106
if (safeReturnTo) {
102107
// ... and if it is, redirect to it
103108
window.location.replace(safeReturnTo);
104109
}
105110
},
106-
[updateUser],
111+
[onLoggedIn, updateUser],
107112
);
108113

109114
const openLogin = useCallback(
@@ -261,4 +266,4 @@ export function Login() {
261266
</div>
262267
</div>
263268
);
264-
}
269+
};

components/dashboard/src/Setup.tsx

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@
44
* See License.AGPL.txt in the project root for license information.
55
*/
66

7-
import { useEffect, useState } from "react";
7+
import { useCallback, useEffect, useState } from "react";
8+
import { Button } from "./components/Button";
89
import Modal, { ModalBody, ModalFooter, ModalHeader } from "./components/Modal";
910
import { getGitpodService, gitpodHostUrl } from "./service/service";
1011
import { GitIntegrationModal } from "./user-settings/Integrations";
1112

12-
export default function Setup() {
13+
type Props = {
14+
onComplete?: () => void;
15+
};
16+
export default function Setup({ onComplete }: Props) {
1317
const [showModal, setShowModal] = useState<boolean>(false);
1418

1519
useEffect(() => {
@@ -22,16 +26,21 @@ export default function Setup() {
2226
})();
2327
}, []);
2428

25-
const acceptAndContinue = () => {
29+
const acceptAndContinue = useCallback(() => {
2630
setShowModal(true);
27-
};
31+
}, []);
2832

29-
const onAuthorize = (payload?: string) => {
30-
// run without await, so the integrated closing of new tab isn't blocked
31-
(async () => {
32-
window.location.href = gitpodHostUrl.asDashboard().toString();
33-
})();
34-
};
33+
const onAuthorize = useCallback(
34+
(payload?: string) => {
35+
onComplete && onComplete();
36+
37+
// run without await, so the integrated closing of new tab isn't blocked
38+
(async () => {
39+
window.location.href = gitpodHostUrl.asDashboard().toString();
40+
})();
41+
},
42+
[onComplete],
43+
);
3544

3645
const headerText = "Configure a Git integration with a GitLab, GitHub, or Bitbucket instance.";
3746

@@ -61,9 +70,9 @@ export default function Setup() {
6170
</div>
6271
</ModalBody>
6372
<ModalFooter>
64-
<button className={"ml-2"} onClick={() => acceptAndContinue()}>
73+
<Button className={"ml-2"} onClick={acceptAndContinue}>
6574
Continue
66-
</button>
75+
</Button>
6776
</ModalFooter>
6877
</Modal>
6978
)}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import * as GitpodCookie from "@gitpod/gitpod-protocol/lib/util/gitpod-cookie";
8+
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
9+
import { QueryErrorResetBoundary } from "@tanstack/react-query";
10+
import { FC, lazy, Suspense } from "react";
11+
import { ErrorBoundary, FallbackProps } from "react-error-boundary";
12+
import { AppLoading } from "../../app/AppLoading";
13+
import { Login } from "../../Login";
14+
import { isGitpodIo } from "../../utils";
15+
import { CaughtError } from "./ReloadPageErrorBoundary";
16+
17+
const Setup = lazy(() => import(/* webpackPrefetch: true */ "../../Setup"));
18+
19+
// Error boundary intended to catch and handle expected errors from api calls
20+
export const QueryErrorBoundary: FC = ({ children }) => {
21+
return (
22+
<QueryErrorResetBoundary>
23+
{({ reset }) => (
24+
// Passing reset to onReset so any queries know to retry after boundary is reset
25+
<ErrorBoundary FallbackComponent={ExpectedQueryErrorsFallback} onReset={reset}>
26+
{children}
27+
</ErrorBoundary>
28+
)}
29+
</QueryErrorResetBoundary>
30+
);
31+
};
32+
33+
// This handles any expected errors, i.e. errors w/ a code that an api call produced
34+
const ExpectedQueryErrorsFallback: FC<FallbackProps> = ({ error, resetErrorBoundary }) => {
35+
// adjust typing, as we may have caught an api error here w/ a code property
36+
const caughtError = error as CaughtError;
37+
38+
// User needs to Login
39+
if (caughtError.code === ErrorCodes.NOT_AUTHENTICATED) {
40+
// Before we show a Login screen, check to see if we need to redirect to www site
41+
// Redirects if it's the root, no user, and no gp cookie present (has logged in recently)
42+
if (isGitpodIo() && window.location.pathname === "/" && window.location.hash === "") {
43+
// If there's no gp cookie, bounce to www site
44+
if (!GitpodCookie.isPresent(document.cookie)) {
45+
window.location.href = `https://www.gitpod.io`;
46+
return <div></div>;
47+
}
48+
}
49+
50+
return <Login onLoggedIn={resetErrorBoundary} />;
51+
}
52+
53+
// Setup needed before proceeding
54+
if (caughtError.code === ErrorCodes.SETUP_REQUIRED) {
55+
return (
56+
<Suspense fallback={<AppLoading />}>
57+
<Setup onComplete={resetErrorBoundary} />
58+
</Suspense>
59+
);
60+
}
61+
62+
// Otherwise throw the error for default error boundary to catch and handle
63+
throw error;
64+
};

components/dashboard/src/components/ErrorBoundary.tsx renamed to components/dashboard/src/components/error-boundaries/ReloadPageErrorBoundary.tsx

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,31 @@
44
* See License.AGPL.txt in the project root for license information.
55
*/
66

7-
import { FC } from "react";
7+
import { FC, useCallback } from "react";
88
import { ErrorBoundary, FallbackProps, ErrorBoundaryProps } from "react-error-boundary";
9-
import gitpodIcon from "../icons/gitpod.svg";
10-
import { getGitpodService } from "../service/service";
11-
import { Heading1, Subheading } from "./typography/headings";
9+
import gitpodIcon from "../../icons/gitpod.svg";
10+
import { getGitpodService } from "../../service/service";
11+
import { Heading1, Subheading } from "../typography/headings";
1212

13-
export const GitpodErrorBoundary: FC = ({ children }) => {
13+
export type CaughtError = Error & { code?: number };
14+
15+
// Catches any unexpected errors w/ a UI to reload the page. Also reports errors to api
16+
export const ReloadPageErrorBoundary: FC = ({ children }) => {
1417
return (
15-
<ErrorBoundary FallbackComponent={DefaultErrorFallback} onReset={handleReset} onError={handleError}>
18+
<ErrorBoundary FallbackComponent={ReloadPageErrorFallback} onError={handleError}>
1619
{children}
1720
</ErrorBoundary>
1821
);
1922
};
2023

21-
type CaughtError = Error & { code?: number };
22-
23-
export const DefaultErrorFallback: FC<FallbackProps> = ({ error, resetErrorBoundary }) => {
24+
export const ReloadPageErrorFallback: FC<Pick<FallbackProps, "error">> = ({ error }) => {
2425
// adjust typing, as we may have caught an api error here w/ a code property
2526
const caughtError = error as CaughtError;
2627

28+
const handleReset = useCallback(() => {
29+
window.location.reload();
30+
}, []);
31+
2732
const emailSubject = encodeURIComponent("Gitpod Dashboard Error");
2833
let emailBodyStr = `\n\nError: ${caughtError.message}`;
2934
if (caughtError.code) {
@@ -43,9 +48,9 @@ export const DefaultErrorFallback: FC<FallbackProps> = ({ error, resetErrorBound
4348
.
4449
</Subheading>
4550
<div>
46-
<button onClick={resetErrorBoundary}>Reload</button>
51+
<button onClick={handleReset}>Reload</button>
4752
</div>
48-
<div>
53+
<div className="flex flex-col items-center space-y-2">
4954
{caughtError.code && (
5055
<span>
5156
<strong>Code:</strong> {caughtError.code}
@@ -57,10 +62,6 @@ export const DefaultErrorFallback: FC<FallbackProps> = ({ error, resetErrorBound
5762
);
5863
};
5964

60-
export const handleReset: ErrorBoundaryProps["onReset"] = () => {
61-
window.location.reload();
62-
};
63-
6465
export const handleError: ErrorBoundaryProps["onError"] = async (error, info) => {
6566
const url = window.location.toString();
6667
try {

components/dashboard/src/hooks/use-user-loader.ts

Lines changed: 10 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,55 +4,38 @@
44
* See License.AGPL.txt in the project root for license information.
55
*/
66

7-
import { useState, useContext } from "react";
8-
import { User } from "@gitpod/gitpod-protocol";
7+
import { useContext } from "react";
98
import { UserContext } from "../user-context";
109
import { getGitpodService } from "../service/service";
11-
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
1210
import { trackLocation } from "../Analytics";
1311
import { refreshSearchData } from "../components/RepositoryFinder";
1412
import { useQuery } from "@tanstack/react-query";
1513
import { noPersistence } from "../data/setup";
1614

1715
export const useUserLoader = () => {
1816
const { user, setUser } = useContext(UserContext);
19-
const [isSetupRequired, setSetupRequired] = useState(false);
2017

2118
// For now, we're using the user context to store the user, but letting react-query handle the loading
2219
// In the future, we should remove the user context and use react-query to access the user
2320
const { isLoading } = useQuery({
2421
queryKey: noPersistence(["current-user"]),
2522
queryFn: async () => {
26-
let user: User | undefined;
27-
try {
28-
user = await getGitpodService().server.getLoggedInUser();
29-
setUser(user);
30-
refreshSearchData();
31-
} catch (error) {
32-
if (error && "code" in error) {
33-
if (error.code === ErrorCodes.SETUP_REQUIRED) {
34-
setSetupRequired(true);
35-
return;
36-
}
37-
38-
// If it was a server error, throw it so we can catch it with an ErrorBoundary
39-
if (error.code >= 500) {
40-
throw error;
41-
}
42-
43-
// Other errors will treat user as needing to log in
44-
}
45-
} finally {
46-
trackLocation(!!user);
47-
}
23+
const user = await getGitpodService().server.getLoggedInUser();
4824

4925
return user || null;
5026
},
5127
// We'll let an ErrorBoundary catch the error
5228
useErrorBoundary: true,
5329
cacheTime: 1000 * 60 * 60 * 1, // 1 hour
5430
staleTime: 1000 * 60 * 60 * 1, // 1 hour
31+
onSuccess: (loadedUser) => {
32+
setUser(loadedUser);
33+
refreshSearchData();
34+
},
35+
onSettled: (loadedUser) => {
36+
trackLocation(!!loadedUser);
37+
},
5538
});
5639

57-
return { user, loading: isLoading, isSetupRequired };
40+
return { user, loading: isLoading };
5841
};

0 commit comments

Comments
 (0)