Repository: auth0/nextjs-auth0
Pull request #229 (2021-01-06 17:55:56)

Added error handling to useUser [SDK-2236]
### Description

This PR adds an `error` property to the return object of the `useUser` hook, that will contain an `Error` instance when the `fetch()` or `response.json()` calls throw.
The tests, documentation and example apps were updated accordingly.

### Testing

- [ ] This change adds test coverage for new/changed/fixed functionality

### Checklist

- [x] I have added documentation for new/changed functionality in this PR or in auth0.com/docs
- [X] All active GitHub checks for tests, formatting, and security are passing
- [X] The correct base branch is being used, if not `master`



Patch:
diff --git a/EXAMPLES.md b/EXAMPLES.md
index 73037ca9..c5fa7798 100644
--- a/EXAMPLES.md
+++ b/EXAMPLES.md
@@ -61,9 +61,10 @@ Check the user's authentication state and log them in or out from the front end
 import { useUser } from '@auth0/nextjs-auth0';
 
 export default () => {
-  const { user, isLoading } = useUser();
+  const { user, error, isLoading } = useUser();
 
   if (isLoading) return <div>Loading...</div>;
+  if (error) return <div>{error.message}</div>;
 
   if (user) {
     return (
diff --git a/README.md b/README.md
index eb986206..12c7771e 100644
--- a/README.md
+++ b/README.md
@@ -105,9 +105,10 @@ Check whether a user is authenticated by checking that `user` has a value, and l
 import { useUser } from '@auth0/nextjs-auth0';
 
 export default () => {
-  const { user, isLoading } = useUser();
+  const { user, error, isLoading } = useUser();
 
   if (isLoading) return <div>Loading...</div>;
+  if (error) return <div>{error.message}</div>;
 
   if (user) {
     return (
diff --git a/examples/basic-example/pages/index.jsx b/examples/basic-example/pages/index.jsx
index e3940e65..fc61c5ab 100644
--- a/examples/basic-example/pages/index.jsx
+++ b/examples/basic-example/pages/index.jsx
@@ -4,7 +4,7 @@ import { useUser } from '@auth0/nextjs-auth0';
 import Layout from '../components/layout';
 
 export default function Home() {
-  const { user, isLoading } = useUser();
+  const { user, error, isLoading } = useUser();
 
   return (
     <Layout>
@@ -12,14 +12,10 @@ export default function Home() {
 
       {isLoading && <p>Loading login info...</p>}
 
-      {!isLoading && !user && (
+      {error && (
         <>
-          <p>
-            To test the login click in <i>Login</i>
-          </p>
-          <p>
-            Once you have logged in you should be able to click in <i>Protected Page</i> and <i>Logout</i>
-          </p>
+          <h4>Error</h4>
+          <pre>{error.message}</pre>
         </>
       )}
 
@@ -29,6 +25,17 @@ export default function Home() {
           <pre id="profile">{JSON.stringify(user, null, 2)}</pre>
         </>
       )}
+
+      {!isLoading && !error && !user && (
+        <>
+          <p>
+            To test the login click in <i>Login</i>
+          </p>
+          <p>
+            Once you have logged in you should be able to click in <i>Protected Page</i> and <i>Logout</i>
+          </p>
+        </>
+      )}
     </Layout>
   );
 }
diff --git a/examples/basic-example/pages/protected-page.jsx b/examples/basic-example/pages/protected-page.jsx
index 1b50251b..e2a056a8 100644
--- a/examples/basic-example/pages/protected-page.jsx
+++ b/examples/basic-example/pages/protected-page.jsx
@@ -4,7 +4,7 @@ import { useUser, withPageAuthRequired } from '@auth0/nextjs-auth0';
 import Layout from '../components/layout';
 
 export default function ProtectedPage() {
-  const { user, isLoading } = useUser();
+  const { user, error, isLoading } = useUser();
 
   return (
     <Layout>
@@ -12,9 +12,16 @@ export default function ProtectedPage() {
 
       {isLoading && <p>Loading profile...</p>}
 
-      {!isLoading && user && (
+      {error && (
         <>
-          <p>Profile:</p>
+          <h4>Error</h4>
+          <pre>{error.message}</pre>
+        </>
+      )}
+
+      {user && (
+        <>
+          <h4>Profile</h4>
           <pre>{JSON.stringify(user, null, 2)}</pre>
         </>
       )}
diff --git a/examples/kitchen-sink-example/pages/index.tsx b/examples/kitchen-sink-example/pages/index.tsx
index eee70756..96050018 100644
--- a/examples/kitchen-sink-example/pages/index.tsx
+++ b/examples/kitchen-sink-example/pages/index.tsx
@@ -4,7 +4,7 @@ import { useUser } from '@auth0/nextjs-auth0';
 import Layout from '../components/layout';
 
 export default function Home(): React.ReactElement {
-  const { user, isLoading } = useUser();
+  const { user, error, isLoading } = useUser();
 
   return (
     <Layout>
@@ -12,14 +12,10 @@ export default function Home(): React.ReactElement {
 
       {isLoading && <p>Loading login info...</p>}
 
-      {!isLoading && !user && (
+      {error && (
         <>
-          <p>
-            To test the login click in <i>Login</i>
-          </p>
-          <p>
-            Once you have logged in you should be able to click in <i>Profile</i> and <i>Logout</i>
-          </p>
+          <h4>Error</h4>
+          <pre>{error.message}</pre>
         </>
       )}
 
@@ -29,6 +25,17 @@ export default function Home(): React.ReactElement {
           <pre id="profile">{JSON.stringify(user, null, 2)}</pre>
         </>
       )}
+
+      {!isLoading && !error && !user && (
+        <>
+          <p>
+            To test the login click in <i>Login</i>
+          </p>
+          <p>
+            Once you have logged in you should be able to click in <i>Protected Page</i> and <i>Logout</i>
+          </p>
+        </>
+      )}
     </Layout>
   );
 }
diff --git a/examples/kitchen-sink-example/pages/profile.tsx b/examples/kitchen-sink-example/pages/profile.tsx
index d01966d6..bb63bfa3 100644
--- a/examples/kitchen-sink-example/pages/profile.tsx
+++ b/examples/kitchen-sink-example/pages/profile.tsx
@@ -4,7 +4,7 @@ import { useUser, withPageAuthRequired } from '@auth0/nextjs-auth0';
 import Layout from '../components/layout';
 
 export default withPageAuthRequired(function Profile(): React.ReactElement {
-  const { user, isLoading } = useUser();
+  const { user, error, isLoading } = useUser();
 
   return (
     <Layout>
@@ -12,9 +12,16 @@ export default withPageAuthRequired(function Profile(): React.ReactElement {
 
       {isLoading && <p>Loading profile...</p>}
 
-      {!isLoading && user && (
+      {error && (
         <>
-          <p>Profile:</p>
+          <h4>Error</h4>
+          <pre>{error.message}</pre>
+        </>
+      )}
+
+      {user && (
+        <>
+          <h4>Profile</h4>
           <pre id="profile">{JSON.stringify(user, null, 2)}</pre>
         </>
       )}
diff --git a/src/frontend/index.ts b/src/frontend/index.ts
index d960b171..c652b9e4 100644
--- a/src/frontend/index.ts
+++ b/src/frontend/index.ts
@@ -1,2 +1,2 @@
-export { default as UserProvider, UserProfile, UserContext, useUser } from './use-user';
+export { default as UserProvider, UserProviderProps, UserProfile, UserContext, useUser } from './use-user';
 export { default as withPageAuthRequired, WithPageAuthRequired } from './with-page-auth-required';
diff --git a/src/frontend/use-user.tsx b/src/frontend/use-user.tsx
index fdb6bb82..02965a53 100644
--- a/src/frontend/use-user.tsx
+++ b/src/frontend/use-user.tsx
@@ -23,6 +23,7 @@ export interface UserProfile {
  */
 export interface UserContext {
   user?: UserProfile;
+  error?: Error;
   isLoading: boolean;
 }
 
@@ -41,7 +42,7 @@ export interface UserContext {
  * export default function App({ Component, pageProps }) {
  *   // If you've used `withAuth`, pageProps.user can pre-populate the hook
  *   // if you haven't used `withAuth`, pageProps.user is undefined so the hook
- *   // fetches the user from the API routes
+ *   // fetches the user from the API route
  *   const { user } = pageProps;
  *
  *   return (
@@ -57,7 +58,7 @@ export interface UserContext {
  *
  * @category Client
  */
-type UserProviderProps = React.PropsWithChildren<{ user?: UserProfile; profileUrl?: string }>;
+export type UserProviderProps = React.PropsWithChildren<{ user?: UserProfile; profileUrl?: string }>;
 
 /**
  * @ignore
@@ -74,9 +75,10 @@ const User = createContext<UserContext>({ isLoading: false });
  * import { useUser } from '@auth0/nextjs-auth0`;
  *
  * export default function Profile() {
- *   const { user, isLoading } = useUser();
+ *   const { user, error, isLoading } = useUser();
  *
  *   if (isLoading) return <div>Loading...</div>;
+ *   if (error) return <div>{error.message}</div>;
  *   if (!user) return <Link href="/api/auth/login"><a>Login</a></Link>;
  *   return <div>Hello {user.name}, <Link href="/api/auth/logout"><a>Logout</a></Link></div>;
  * }
@@ -104,19 +106,24 @@ export default ({
   profileUrl = '/api/auth/me'
 }: UserProviderProps): ReactElement<UserContext> => {
   const [user, setUser] = useState<UserProfile | undefined>(() => initialUser);
+  const [error, setError] = useState<Error | undefined>();
   const [isLoading, setIsLoading] = useState<boolean>(() => !initialUser);
 
   useEffect((): void => {
     if (user) return;
 
     (async (): Promise<void> => {
-      const response = await fetch(profileUrl);
-      const result = response.ok ? await response.json() : undefined;
-
-      setUser(result);
-      setIsLoading(false);
+      try {
+        const response = await fetch(profileUrl);
+        setUser(response.ok ? await response.json() : undefined);
+        setError(undefined);
+      } catch (_e) {
+        setError(new Error(`The request to ${profileUrl} failed`));
+      } finally {
+        setIsLoading(false);
+      }
     })();
   }, [user]);
 
-  return <User.Provider value={{ user, isLoading }}>{children}</User.Provider>;
+  return <User.Provider value={{ user, error, isLoading }}>{children}</User.Provider>;
 };
diff --git a/src/frontend/with-page-auth-required.tsx b/src/frontend/with-page-auth-required.tsx
index e2b9b605..3b20ab30 100644
--- a/src/frontend/with-page-auth-required.tsx
+++ b/src/frontend/with-page-auth-required.tsx
@@ -8,6 +8,11 @@ import { useUser } from './use-user';
  */
 const defaultOnRedirecting = (): JSX.Element => <></>;
 
+/**
+ * @ignore
+ */
+const defaultOnError = (): JSX.Element => <></>;
+
 /**
  * Options for the withPageAuthRequired Higher Order Component
  *
@@ -43,6 +48,16 @@ export interface WithPageAuthRequiredOptions {
    * Render a message to show that the user is being redirected to the login.
    */
   onRedirecting?: () => JSX.Element;
+  /**
+   * ```js
+   * withPageAuthRequired(Profile, {
+   *   onError: error => <div>Error: {error.message}</div>
+   * });
+   * ```
+   *
+   * Render a fallback in case of error fetching the user from the profile API route.
+   */
+  onError?: (error: Error) => JSX.Element;
 }
 
 /**
@@ -66,18 +81,26 @@ export type WithPageAuthRequired = <P extends object>(
 const withPageAuthRequired: WithPageAuthRequired = (Component, options = {}) => {
   return function withPageAuthRequired(props): JSX.Element {
     const router = useRouter();
-    const { returnTo = router.asPath, onRedirecting = defaultOnRedirecting, loginUrl = '/api/auth/login' } = options;
-    const { user, isLoading } = useUser();
+    const {
+      returnTo = router.asPath,
+      onRedirecting = defaultOnRedirecting,
+      onError = defaultOnError,
+      loginUrl = '/api/auth/login'
+    } = options;
+    const { user, error, isLoading } = useUser();
 
     useEffect(() => {
-      if (user || isLoading) return;
+      if ((user && !error) || isLoading) return;
 
       (async (): Promise<void> => {
         await router.push(`${loginUrl}?returnTo=${returnTo}`);
       })();
-    }, [user, isLoading, router, loginUrl, returnTo]);
+    }, [user, error, isLoading]);
+
+    if (error) return onError(error);
+    if (user) return <Component {...props} />;
 
-    return user ? <Component {...props} /> : onRedirecting();
+    return onRedirecting();
   };
 };
 
diff --git a/src/index.browser.ts b/src/index.browser.ts
index 04eb734b..d8de4573 100644
--- a/src/index.browser.ts
+++ b/src/index.browser.ts
@@ -4,6 +4,7 @@ import { WithApiAuthRequired } from './helpers';
 import { HandleAuth, HandleCallback, HandleLogin, HandleLogout, HandleProfile } from './handlers';
 export {
   UserProvider,
+  UserProviderProps,
   UserProfile,
   UserContext,
   useUser,
diff --git a/src/index.ts b/src/index.ts
index 30bb7798..106f3fd6 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -92,7 +92,7 @@ export const handleCallback: HandleCallback = (...args) => getInstance().handleC
 export const handleProfile: HandleProfile = (...args) => getInstance().handleProfile(...args);
 export const handleAuth: HandleAuth = (...args) => getInstance().handleAuth(...args);
 
-export { UserProvider, UserProfile, UserContext, useUser } from './frontend';
+export { UserProvider, UserProviderProps, UserProfile, UserContext, useUser } from './frontend';
 
 export {
   Config,


Test patch:
diff --git a/tests/fixtures/frontend.tsx b/tests/fixtures/frontend.tsx
index 617bcc6d..688771a7 100644
--- a/tests/fixtures/frontend.tsx
+++ b/tests/fixtures/frontend.tsx
@@ -1,5 +1,5 @@
 import React from 'react';
-import { UserProfile, UserProvider } from '../../src';
+import { UserProvider, UserProviderProps, UserProfile } from '../../src';
 
 type FetchUserMock = {
   ok: boolean;
@@ -16,16 +16,21 @@ export const user: UserProfile = {
   updated_at: null
 };
 
-export const withUser = (user?: UserProfile): React.ComponentType => {
-  return (props: any): React.ReactElement => <UserProvider {...props} user={user} />;
+export const withUserProvider = ({ user, profileUrl }: UserProviderProps = {}): React.ComponentType => {
+  return (props: any): React.ReactElement => <UserProvider {...props} user={user} profileUrl={profileUrl} />;
 };
 
-export const fetchUserMock = (): FetchUserMock => ({
-  ok: true,
-  json: (): Promise<UserProfile> => Promise.resolve(user)
-});
+export const fetchUserMock = (): Promise<FetchUserMock> => {
+  return Promise.resolve({
+    ok: true,
+    json: () => Promise.resolve(user)
+  });
+};
+
+export const fetchUserUnsuccessfulMock = (): Promise<FetchUserMock> => {
+  return Promise.resolve({
+    ok: false
+  });
+};
 
-export const fetchUserFailureMock = (): FetchUserMock => ({
-  ok: false,
-  json: undefined
-});
+export const fetchUserErrorMock = (): Promise<FetchUserMock> => Promise.reject(new Error('Error'));
diff --git a/tests/frontend/use-user.test.tsx b/tests/frontend/use-user.test.tsx
index 61817039..51e1fe8e 100644
--- a/tests/frontend/use-user.test.tsx
+++ b/tests/frontend/use-user.test.tsx
@@ -1,44 +1,79 @@
 import { renderHook } from '@testing-library/react-hooks';
 
 import { useUser } from '../../src';
-import { fetchUserMock, fetchUserFailureMock, withUser, user } from '../fixtures/frontend';
+import {
+  fetchUserMock,
+  fetchUserUnsuccessfulMock,
+  fetchUserErrorMock,
+  withUserProvider,
+  user
+} from '../fixtures/frontend';
 
 describe('context wrapper', () => {
-  test('should use the initial user', async () => {
-    const { result } = renderHook(() => useUser(), { wrapper: withUser(user) });
+  test('should fetch the user', async () => {
+    (global as any).fetch = fetchUserMock;
+    const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUserProvider() });
+
+    expect(result.current.user).toEqual(undefined);
+    expect(result.current.error).toEqual(undefined);
+    expect(result.current.isLoading).toEqual(true);
+
+    await waitForValueToChange(() => result.current.isLoading);
 
     expect(result.current.user).toEqual(user);
+    expect(result.current.error).toEqual(undefined);
     expect(result.current.isLoading).toEqual(false);
   });
 
-  test('should fetch the user', async () => {
-    (global as any).fetch = fetchUserMock;
-
-    const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUser() });
+  test('should discard the response when the status code is not successful', async () => {
+    (global as any).fetch = fetchUserUnsuccessfulMock;
+    const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUserProvider() });
 
     expect(result.current.user).toEqual(undefined);
+    expect(result.current.error).toEqual(undefined);
     expect(result.current.isLoading).toEqual(true);
 
     await waitForValueToChange(() => result.current.isLoading);
 
-    expect(result.current.user).toEqual(user);
+    expect(result.current.user).toEqual(undefined);
+    expect(result.current.error).toEqual(undefined);
     expect(result.current.isLoading).toEqual(false);
   });
 
   test('should fail to fetch the user', async () => {
-    (global as any).fetch = fetchUserFailureMock;
-
-    const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUser() });
+    (global as any).fetch = fetchUserErrorMock;
+    const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUserProvider() });
 
     expect(result.current.user).toEqual(undefined);
+    expect(result.current.error).toEqual(undefined);
     expect(result.current.isLoading).toEqual(true);
 
     await waitForValueToChange(() => result.current.isLoading);
 
     expect(result.current.user).toEqual(undefined);
+    expect(result.current.error).toEqual(new Error('The request to /api/auth/me failed'));
     expect(result.current.isLoading).toEqual(false);
   });
 
+  test('should use the existing user', async () => {
+    const { result } = renderHook(() => useUser(), { wrapper: withUserProvider({ user }) });
+
+    expect(result.current.user).toEqual(user);
+    expect(result.current.error).toEqual(undefined);
+    expect(result.current.isLoading).toEqual(false);
+  });
+
+  test('should use a custom profileUrl', async () => {
+    const fetchSpy = jest.fn();
+    (global as any).fetch = fetchSpy;
+    const { result, waitForValueToChange } = renderHook(() => useUser(), {
+      wrapper: withUserProvider({ profileUrl: '/api/custom-url' })
+    });
+
+    await waitForValueToChange(() => result.current.isLoading);
+    expect(fetchSpy).toHaveBeenCalledWith('/api/custom-url');
+  });
+
   afterAll(() => {
     delete (global as any).fetch;
   });
diff --git a/tests/frontend/with-page-auth-required.test.tsx b/tests/frontend/with-page-auth-required.test.tsx
index d1b13543..7c1a4f8b 100644
--- a/tests/frontend/with-page-auth-required.test.tsx
+++ b/tests/frontend/with-page-auth-required.test.tsx
@@ -5,7 +5,7 @@ import '@testing-library/jest-dom/extend-expect';
 import React from 'react';
 import { render, screen, waitFor } from '@testing-library/react';
 
-import { fetchUserFailureMock, withUser, user } from '../fixtures/frontend';
+import { fetchUserUnsuccessfulMock, fetchUserErrorMock, withUserProvider, user } from '../fixtures/frontend';
 import { withPageAuthRequired } from '../../src/frontend';
 
 const routerMock = {
@@ -17,11 +17,11 @@ jest.mock('next/router', () => ({ useRouter: (): any => routerMock }));
 
 describe('with-page-auth-required csr', () => {
   it('should block access to a CSR page when not authenticated', async () => {
-    (global as any).fetch = fetchUserFailureMock;
+    (global as any).fetch = fetchUserUnsuccessfulMock;
     const MyPage = (): JSX.Element => <>Private</>;
     const ProtectedPage = withPageAuthRequired(MyPage);
 
-    render(<ProtectedPage />, { wrapper: withUser() });
+    render(<ProtectedPage />, { wrapper: withUserProvider() });
     await waitFor(() => expect(routerMock.push).toHaveBeenCalledTimes(1));
     await waitFor(() => expect(screen.queryByText('Private')).not.toBeInTheDocument());
   });
@@ -30,7 +30,7 @@ describe('with-page-auth-required csr', () => {
     const MyPage = (): JSX.Element => <>Private</>;
     const ProtectedPage = withPageAuthRequired(MyPage);
 
-    render(<ProtectedPage />, { wrapper: withUser(user) });
+    render(<ProtectedPage />, { wrapper: withUserProvider({ user }) });
     await waitFor(() => expect(routerMock.push).not.toHaveBeenCalled());
     await waitFor(() => expect(screen.getByText('Private')).toBeInTheDocument());
   });
@@ -40,15 +40,25 @@ describe('with-page-auth-required csr', () => {
     const OnRedirecting = (): JSX.Element => <>Redirecting</>;
     const ProtectedPage = withPageAuthRequired(MyPage, { onRedirecting: OnRedirecting });
 
-    render(<ProtectedPage />, { wrapper: withUser() });
+    render(<ProtectedPage />, { wrapper: withUserProvider() });
     await waitFor(() => expect(screen.getByText('Redirecting')).toBeInTheDocument());
   });
 
+  it('should show a fallback in case of error', async () => {
+    (global as any).fetch = fetchUserErrorMock;
+    const MyPage = (): JSX.Element => <>Private</>;
+    const OnError = (): JSX.Element => <>Error</>;
+    const ProtectedPage = withPageAuthRequired(MyPage, { onError: OnError });
+
+    render(<ProtectedPage />, { wrapper: withUserProvider() });
+    await waitFor(() => expect(screen.getByText('Error')).toBeInTheDocument());
+  });
+
   it('should accept a returnTo url', async () => {
     const MyPage = (): JSX.Element => <>Private</>;
     const ProtectedPage = withPageAuthRequired(MyPage, { returnTo: '/foo' });
 
-    render(<ProtectedPage />, { wrapper: withUser() });
+    render(<ProtectedPage />, { wrapper: withUserProvider() });
     await waitFor(() => expect(routerMock.push).toHaveBeenCalledWith(expect.stringContaining('?returnTo=/foo')));
   });