Skip to content

Instantly share code, notes, and snippets.

@RobinDaugherty
Created September 10, 2020 19:44
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save RobinDaugherty/f681682bb8f85236b5700dfe250b9159 to your computer and use it in GitHub Desktop.
Save RobinDaugherty/f681682bb8f85236b5700dfe250b9159 to your computer and use it in GitHub Desktop.
React app with router and error boundary
import React from 'react';
import { IntlProvider } from "react-intl"
import { AuthenticationProvider, AuthenticationRequired } from "lib/Authentication"
import ApolloClientProvider from "lib/ApolloClient";
import ErrorBoundary from "boundaries/ErrorBoundary";
import PageNotFoundBoundary from "boundaries/PageNotFoundBoundary";
import { Router, Routes } from "./Routes";
import PageLayout from "components/PageLayout";
const App: React.FC = () => {
return (
<IntlProvider locale={navigator.language}>
<ErrorBoundary>
<AuthenticationProvider>
<ApolloClientProvider>
<Router>
<PageLayout>
<PageNotFoundBoundary>
<AuthenticationRequired>
<Routes />
</AuthenticationRequired>
</PageNotFoundBoundary>
</PageLayout>
</Router>
</ApolloClientProvider>
</AuthenticationProvider>
</ErrorBoundary>
</IntlProvider>
);
};
export default App;
import React, { ErrorInfo, ReactNode } from "react";
import * as Sentry from "@sentry/browser";
import { showErrorReportDialog } from "lib/ErrorReporting";
type State = {
error: Error | null;
errorEventId: string | null;
};
class ErrorBoundary extends React.Component {
readonly state: State = {
error: null,
errorEventId: null,
};
componentDidMount(): void {
window.addEventListener(
"unhandledrejection",
this.handleUnhandledPromiseRejection
);
}
componentWillUnmount(): void {
window.removeEventListener(
"unhandledrejection",
this.handleUnhandledPromiseRejection
);
}
handleUnhandledPromiseRejection = (
promiseRejectionEvent: PromiseRejectionEvent
): void => {
this.setState({ error: promiseRejectionEvent });
};
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
this.setState({ error });
Sentry.withScope((scope) => {
scope.setExtras(errorInfo);
const errorEventId = Sentry.captureException(error);
this.setState({ errorEventId });
});
}
handleShowReportDialog = (): void => {
const { errorEventId } = this.state;
if (errorEventId) {
showErrorReportDialog();
}
};
handleReloadPage = (): void => {
window.location.reload();
};
handleGoBack = (): void => {
this.setState({ error: null });
window.history.back();
};
render(): ReactNode {
const { error } = this.state;
if (error) {
return (
<div id="error-page">
<div className="messaging">
<h1>Something Went Wrong</h1>
<p>Something seems to have broken on this page.</p>
<p>
Our engineers have been notified and will be looking into this. If
you want to give them more information about how you arrived at
this error, please report feedback. We appreciate your help!
</p>
{Sentry.lastEventId && (<p>
<button onClick={this.handleShowReportDialog}>
Report feedback
</button>
</p>)}
<p>
<button onClick={this.handleReloadPage}>
Reload the page to try again
</button>
</p>
<p>
<button onClick={this.handleGoBack}>
Go back to the previous page
</button>
</p>
</div>
</div>
);
} else {
return this.props.children;
}
}
}
export default ErrorBoundary;
import React, { ReactElement, useState, useEffect } from "react";
import NotFound from "contexts/NotFound";
import NotFoundPage from "pages/NotFoundPage";
type Props = {
children?: ReactElement;
};
const PageNotFoundBoundary: React.FC<Props> = (props) => {
const { children } = props;
const [hasNotFoundError, setHasNotFoundError] = useState(false);
const setNotFound = (notFound = true): void => {
setHasNotFoundError(notFound);
};
useEffect(() => {
// Always reset the "Page Not Found" error after page transition in case we have moved to a
// valid page.
const handlePopState = (): void => {
setNotFound(false);
};
window.addEventListener("popstate", handlePopState);
return (): void => {
window.removeEventListener("popstate", handlePopState);
};
}, []);
return (
<NotFound.Provider value={setNotFound}>
{hasNotFoundError ? <NotFoundPage /> : children}
</NotFound.Provider>
);
};
export default PageNotFoundBoundary;
import React from "react";
import { setPageTitle } from "contexts/PageTitle";
export interface Props {
children?: React.ReactNode;
className?: string;
title: string;
}
const Page: React.FC<Props> = (props) => {
const {
children,
className,
title,
} = props;
setPageTitle(title);
return (
<div className={`page ${className || ""}`}>
{children}
</div>
);
};
export default Page;
import React from "react";
const NotFound = React.createContext((notFound?: boolean) => {});
export function useShowNotFound(): (notFound?: boolean) => void {
const context = React.useContext(NotFound);
if (context === undefined) {
throw new Error("useShowNotFound must be used within PageNotFoundBoundary");
}
return context;
}
export default NotFound;
export const setPageTitle = (title: string | null) => {
const hasTitle = (title || "") !== "";
document.title = `${hasTitle ? title : ''}${hasTitle ? ' - ' : ''}Lassie`;
};
import React from 'react';
import ActionCable from 'actioncable';
import { ActionCableLink } from 'graphql-ruby-client';
import { ApolloProvider } from "@apollo/react-hooks";
import { ApolloClient } from "apollo-client";
import { ApolloLink, Operation } from "apollo-link";
import { DefinitionNode, OperationDefinitionNode } from 'graphql';
import { RetryLink } from "apollo-link-retry";
import { createHttpLink } from "apollo-link-http";
import { InMemoryCache } from "apollo-cache-inmemory";
import { onError } from "apollo-link-error";
import { setContext } from "apollo-link-context";
import { useAuthenticationState } from "lib/Authentication";
const getFinchApiUrl = (): string => {
// Allow an alternate URL to be set so that configuration is bypassed.
const localUrl = window.localStorage["FinchGraphqlUrl"];
if (localUrl) {
return localUrl;
}
const envUrl = process.env.REACT_APP__FINCH_GRAPHQL_URL;
if (!envUrl) {
throw new Error("Missing REACT_APP__FINCH_GRAPHQL_URL");
}
return envUrl;
};
const getFinchActionCableUrl = (): string => {
// Allow an alternate URL to be set so that configuration is bypassed.
const localUrl = window.localStorage["FinchCableUrl"];
if (localUrl) {
return localUrl;
}
const envUrl = process.env.REACT_APP__FINCH_ACTION_CABLE_URL;
if (!envUrl) {
throw new Error("Missing REACT_APP__FINCH_ACTION_CABLE_URL");
}
return envUrl;
};
const errorLink = onError((error) => {
console.log(error);
});
// Creates a "split-transport" Apollo link, which uses both HTTP and Websocket connections as needed.
// Authenticates the HTTP requests using a bearer token in a header, while the socket
// is authenticated using a token in the URL.
const createApolloClient = (authTokenForApollo: string | null) => {
const cache = new InMemoryCache({});
const retryLink = new RetryLink();
const authLink = setContext((_, { headers }) => {
let authHeaders: {[key: string]: string} = {};
if (authTokenForApollo) {
authHeaders["Authorization"] = `Bearer ${authTokenForApollo}`;
}
return {
headers: {
...headers,
...authHeaders,
},
};
});
const httpLink = createHttpLink({
uri: getFinchApiUrl(),
});
const authenticatedHttpLink = ApolloLink.from([authLink, httpLink]);
let link: ApolloLink;
if (authTokenForApollo) {
const actionCableConsumer = ActionCable.createConsumer(
`${getFinchActionCableUrl()}?token=${encodeURIComponent(authTokenForApollo)}`);
const actionCableLink = new ActionCableLink({ cable: actionCableConsumer });
const hasSubscriptionOperation = (operation: Operation) => {
const { query: { definitions } } = operation;
return definitions.some(
(value: DefinitionNode) => {
const { kind, operation } = value as OperationDefinitionNode;
return kind === 'OperationDefinition' && operation === 'subscription';
}
)
};
const splitTransportLink = ApolloLink.split(
hasSubscriptionOperation,
actionCableLink,
authenticatedHttpLink,
);
link = ApolloLink.from([authLink, retryLink, errorLink, splitTransportLink]);
} else {
link = ApolloLink.from([authLink, retryLink, errorLink, authenticatedHttpLink]);
}
const apolloClientOptions = {
cache: cache,
link: link,
name: "lassie-web-client",
version: "1.0.0",
queryDeduplication: false,
};
return new ApolloClient(apolloClientOptions);
}
const ApolloClientProvider: React.FC = ({ children }) => {
const authToken = useAuthenticationState()?.authToken || null;
const apolloClient = React.useMemo(() => {
return createApolloClient(authToken);
}, [authToken]);
return (
<ApolloProvider client={apolloClient}>
{children}
</ApolloProvider>
);
};
export default ApolloClientProvider;
import React, { useContext, useState } from "react";
import { useMutation } from "@apollo/react-hooks";
import AppleLogin from 'react-apple-login';
import { useHistory } from "react-router-dom";
import { GridContainer, Grid, Cell } from "react-foundation";
import decodeJwt from "jwt-decode";
import authenticateAppleIdentityMutation from "api/finch/AuthenticateAppleIdentityMutation";
import {
AuthenticateAppleIdentity,
AuthenticateAppleIdentityVariables,
} from "api/finch/types/AuthenticateAppleIdentity";
import LoadingIndicator from "components/LoadingIndicator";
import { setCurrentUser } from "lib/ErrorReporting";
const LOCAL_STORAGE_PATH_BEFORE_APPLE_KEY = "AuthenticationPathBeforeAuthRedirect";
const LOCAL_STORAGE_AUTH_STATE_KEY = "AuthenticationAuthState";
const APPLE_CLIENT_ID = "dog.harper.lassie";
const appleRedirectUri = `https://${document.location.host}/authenticate/apple`;
interface AuthenticatedUser {
id: string;
name: string | null;
email: string | null;
isAdmin: boolean;
};
interface AuthenticationState {
authToken: string;
user: AuthenticatedUser;
};
type AppleTokenContents = {
sub: string;
email?: string;
exp: number;
};
const storedAuthStateJson = window.localStorage.getItem(LOCAL_STORAGE_AUTH_STATE_KEY);
var storedAuthState: AuthenticationState | null = null;
if (storedAuthStateJson) {
storedAuthState = JSON.parse(storedAuthStateJson);
}
const AuthenticationContext = React.createContext<AuthenticationState | null>(storedAuthState);
var setAuthenticationState: ((newState: AuthenticationState | null) => void) | null = null;
export const useAuthenticationState: () => (AuthenticationState | null) = () =>
useContext(AuthenticationContext) || null;
// export const AuthenticationContextConsumer = AuthenticationContext.Consumer;
export const signOut = () => {
setAuthenticationState && setAuthenticationState(null);
}
export const AuthenticationProvider: React.FC = (props) => {
const { children } = props;
const [authState, setAuthState] = useState<AuthenticationState | null>(storedAuthState);
setAuthenticationState = (newState: AuthenticationState | null) => {
setAuthState(newState);
const newSentryUser = (newState && { id: newState.user.id, email: newState.user.email || undefined}) || null;
setCurrentUser(newSentryUser);
window.localStorage.setItem(LOCAL_STORAGE_AUTH_STATE_KEY, JSON.stringify(newState))
};
return (
<AuthenticationContext.Provider value={authState}>
{children}
</AuthenticationContext.Provider>
);
};
const AuthenticationPrompt: React.FC = (props) => {
return (
<GridContainer full>
<Grid alignX="center" alignY="middle" style={{ height: "60vh" }}>
<Cell small={10} className='text-center'>
<AppleLogin
designProp={{ scale: 2 }}
clientId={APPLE_CLIENT_ID}
redirectURI={appleRedirectUri}
responseType={"code id_token"}
responseMode={"fragment"}
/>
</Cell>
</Grid>
</GridContainer>
);
};
/**
* Renders children only if the user is authenticated. If not authenticated, renders the "Sign in" button
* or once we return from Apple's auth service, a loading indicator while the credentials are being saved.
* When returning from Apple's auth service, their auth credentials are in the URL fragment, and are parsed here
* and sent in a mutation to Finch to get the user's auth token.
*/
export const AuthenticationRequired: React.FC = (props) => {
const { children } = props;
const authenticationState = useAuthenticationState();
const hashParams: URLSearchParams | null =
(document.location.hash.length > 0 && new URLSearchParams(document.location.hash?.substring(1))) || null;
const appleIdToken: string | null = (hashParams && hashParams.get('id_token')) || null;
const appleCode: string | null = (hashParams && hashParams.get('code')) || null;
const [isExchangingAuthToken, setIsExchangingAuthToken] =
useState<Boolean>(appleIdToken != null && appleCode != null); // If these are present, we'll be doing this exchange.
const history = useHistory();
const [saveAuthTokenMutation] =
useMutation<AuthenticateAppleIdentity, AuthenticateAppleIdentityVariables>(authenticateAppleIdentityMutation);
React.useEffect(() => {
(async function exchangeCodeForFinchToken() {
if (appleIdToken && appleCode) {
setIsExchangingAuthToken(true);
const tokenContents = decodeJwt<AppleTokenContents>(appleIdToken);
const { data, errors } = await saveAuthTokenMutation({
variables: {
appleId: tokenContents.sub,
email: tokenContents.email,
authCode: appleCode,
redirectUri: appleRedirectUri,
},
});
if ((errors?.length || 0) > 0 || (data?.authenticateAppleIdentity?.userErrors?.length || 0) > 0) {
console.error("Failed to get apple identity");
setIsExchangingAuthToken(false);
}
const newIdentity = data?.authenticateAppleIdentity;
if (newIdentity && newIdentity.authToken && newIdentity.human) {
if (!setAuthenticationState) {
throw new Error("AuthenticationRequired must be used within an AuthenticationProvider");
}
setAuthenticationState({
authToken: newIdentity.authToken,
user: {
id: newIdentity.human.id,
name: newIdentity.human.name,
email: newIdentity.human.forSelf?.email || null,
isAdmin: newIdentity.human.forAdmin?.isAdmin || false,
},
});
const pathBeforeApple = window.localStorage.getItem(LOCAL_STORAGE_PATH_BEFORE_APPLE_KEY) || "/";
history.replace(pathBeforeApple);
}
}
})();
}, [appleIdToken, appleCode, history, saveAuthTokenMutation]);
if (authenticationState) {
return <React.Fragment>{children}</React.Fragment>;
} else if (isExchangingAuthToken) {
return <LoadingIndicator show={true}>Logging you in</LoadingIndicator>;
} else {
window.localStorage.setItem(LOCAL_STORAGE_PATH_BEFORE_APPLE_KEY, document.location.pathname);
return <AuthenticationPrompt/>;
}
};
import * as Sentry from "@sentry/browser";
export function initErrorReporting(): void {
if (!process.env.REACT_APP__SENTRY_DSN) {
return
}
Sentry.init({
dsn: process.env.REACT_APP__SENTRY_DSN,
environment: process.env.REACT_APP__ENV,
release: process.env.REACT_APP__SENTRY_RELEASE,
blacklistUrls: [/google-analytics/],
beforeBreadcrumb(breadcrumb, hint) {
// This method gives us a chance to modify or discard breadcrumbs.
if (
breadcrumb.data &&
(breadcrumb.category === "xhr" || breadcrumb.category === "fetch")
) {
if (/google-analytics/.test(breadcrumb.data.url)) {
return null;
}
}
return breadcrumb;
},
});
}
export function setCurrentUser(user: Sentry.User | null): void {
Sentry.configureScope((scope) => {
scope.setUser(user);
});
}
export function showErrorReportDialog(): void {
Sentry.showReportDialog({
title: "Tell us how weʼre wrong.",
subtitle: "We appreciate it. Really!",
subtitle2: "Tell us how we can make it better.",
labelSubmit: "Send",
});
}
import React from "react";
import { Button } from "react-foundation";
import { Link } from 'react-router-dom';
import LoadingIndicator from 'components/LoadingIndicator';
import NotFound from "contexts/NotFound";
export class TestUncaughtRejectionError extends Error {
constructor(message: string) {
super(message);
this.name = "TestUncaughtRejectionError";
}
}
export class TestRenderingError extends Error {
constructor(message: string) {
super(message);
this.name = "TestRenderingError";
}
}
const ErrorTestPage: React.FC = () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [isFetchingServerError, setIsFetchingServerError] = React.useState(false);
const [isShowingRenderError, setIsShowingRenderError] = React.useState(false);
const setPageNotFound = React.useContext(NotFound);
const gimmeServerError = null;
// const gimmeServerError = () => {
// setIsFetchingServerError(true);
// return fetchFromBackend(`/api/gimme/error`, {
// method: 'GET',
// }).then((response) => {
// if (response.ok) {
// setState({
// isFetchingServerError: false,
// })
// } else {
// setState(() => { throw new UnexpectedBackendResponseError(response.status) })
// }
// }).catch((error) => {
// setState(() => { throw error })
// })
// };
const gimmeRenderError = () => {
setIsShowingRenderError(true);
};
const throwRenderError = () => {
throw new TestRenderingError("test");
};
const gimmeUncaughtRejection = () => {
return Promise.reject(new TestUncaughtRejectionError("test"));
};
const gimmeNotFound = () => {
setPageNotFound();
};
return (
<React.Fragment>
<h1>Gimme Errors</h1>
{gimmeServerError && (
<React.Fragment>
<h2>Backend Server Error</h2>
<p>Make a fetch to the backend that results in a 500 status.</p>
{isFetchingServerError && <LoadingIndicator show={true}>Fetching Server Error</LoadingIndicator>}
{!isFetchingServerError && <p><Button onClick={gimmeServerError}>Gimme</Button></p>}
</React.Fragment>
)}
<h2>Rendering Error</h2>
<p>Throw an error during rendering.</p>
{isShowingRenderError && throwRenderError()}
<p><Button onClick={gimmeRenderError}>Gimme</Button></p>
<h2>Uncaught Rejected Promise</h2>
<p>Cause a promise to reject and donʼt catch it.</p>
<p><Button onClick={gimmeUncaughtRejection}>Gimme</Button></p>
<h2>Content Not Found</h2>
<p>When a component requires data to be loaded from the backend, but the backend reports 404.</p>
<p><Button onClick={gimmeNotFound}>Gimme</Button></p>
<h2>Plain Not Found Page</h2>
<p>Go to a page that doesn't exist, so you can see what that looks like.</p>
<p><Link to="/a-page-that-does-not-exist">Gimme</Link></p>
</React.Fragment>
);
};
export default ErrorTestPage;
import React from "react";
import Page from "components/Page";
const NotFoundPage: React.FC = () => {
const handleGoBack = (): void => {
window.history.back();
};
return (
<Page className='not-found' title="Not Found">
<h1>Not Found</h1>
<p>You have landed on a page that does not exist.</p>
<p>
<button onClick={handleGoBack}>
Go back to the previous page
</button>
</p>
</Page>
);
};
export default NotFoundPage;
import React from "react";
import { BrowserRouter, Switch, Route } from "react-router-dom";
import { QueryParamProvider } from 'use-query-params';
import ErrorTestPage from "pages/ErrorTestPage";
import HumanPage from "pages/HumanPage";
import HumansPage from "pages/HumansPage";
import PackPage from "pages/PackPage";
import PupPage from "pages/PupPage";
import NotFoundPage from "pages/NotFoundPage";
export const Routes: React.FC = () => {
return (
<Switch>
<Route path="/humans/:humanId">
<HumanPage />
</Route>
<Route path="/humans">
<HumansPage />
</Route>
<Route path="/packs/:packId">
<PackPage />
</Route>
<Route path="/pups/:pupId">
<PupPage />
</Route>
<Route path="/gimme-error">
<ErrorTestPage />
</Route>
<Route>
<NotFoundPage />
</Route>
</Switch>
);
};
export const Router: React.FC = ({ children }) => {
return (
<BrowserRouter>
<QueryParamProvider ReactRouterRoute={Route}>
{children}
</QueryParamProvider>
</BrowserRouter>
);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment