Repository: auth0/nextjs-auth0
Pull request #246 (2021-01-20 02:19:06)

Added checkSession method to useUser hook [SDK-2256]
### Description

This PR adds a method `checkSession` to the `useUser` hook that allows checking the session status from the frontend.

https://user-images.githubusercontent.com/5055789/105118105-8bb98c80-5aac-11eb-8bfd-03341a7b7cab.mov

### Testing

- [X] This change adds test coverage for new/changed/fixed functionality

### Checklist

- [ ] 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`


vercel[bot]: This pull request is being automatically deployed with Vercel ([learn more](https://vercel.link/github-learn-more)).  
To see the status of your deployment, click below or on the icon next to each commit.

🔍 Inspect: [https://vercel.com/auth0/nextjs-auth0/fqth4qw57](https://vercel.com/auth0/nextjs-auth0/fqth4qw57)  
✅ Preview: *Canceled*



Patch:
diff --git a/examples/kitchen-sink-example/pages/profile-ssr.tsx b/examples/kitchen-sink-example/pages/profile-ssr.tsx
index de338ffa..545b4a3a 100644
--- a/examples/kitchen-sink-example/pages/profile-ssr.tsx
+++ b/examples/kitchen-sink-example/pages/profile-ssr.tsx
@@ -11,7 +11,7 @@ export default function Profile({ user }: ProfileProps): React.ReactElement {
       <h1>Profile</h1>
 
       <div>
-        <h3>Profile (server rendered)</h3>
+        <h4>Profile (server rendered)</h4>
         <pre data-testid="profile">{JSON.stringify(user, null, 2)}</pre>
       </div>
     </Layout>
diff --git a/src/frontend/use-user.tsx b/src/frontend/use-user.tsx
index 611d4b8c..dc5e392d 100644
--- a/src/frontend/use-user.tsx
+++ b/src/frontend/use-user.tsx
@@ -1,4 +1,4 @@
-import React, { ReactElement, useState, useEffect, useContext, createContext } from 'react';
+import React, { ReactElement, useState, useEffect, useCallback, useContext, createContext } from 'react';
 
 /**
  * The user claims returned from the {@link useUser} hook.
@@ -25,6 +25,7 @@ export interface UserContext {
   user?: UserProfile;
   error?: Error;
   isLoading: boolean;
+  checkSession: () => Promise<void>;
 }
 
 /**
@@ -63,7 +64,25 @@ export type UserProviderProps = React.PropsWithChildren<{ user?: UserProfile; pr
 /**
  * @ignore
  */
-const User = createContext<UserContext>({ isLoading: false });
+const missingUserProvider = 'You forgot to wrap your app in <UserProvider>';
+
+/**
+ * @ignore
+ */
+const User = createContext<UserContext>({
+  get user(): never {
+    throw new Error(missingUserProvider);
+  },
+  get error(): never {
+    throw new Error(missingUserProvider);
+  },
+  get isLoading(): never {
+    throw new Error(missingUserProvider);
+  },
+  checkSession: (): never => {
+    throw new Error(missingUserProvider);
+  }
+});
 
 /**
  * The `useUser` hook, which will get you the {@link UserProfile} object from the server side session by requesting it
@@ -109,21 +128,24 @@ export default ({
   const [error, setError] = useState<Error | undefined>();
   const [isLoading, setIsLoading] = useState<boolean>(() => !initialUser);
 
+  const checkSession = useCallback(async (): Promise<void> => {
+    try {
+      const response = await fetch(profileUrl);
+      setUser(response.ok ? await response.json() : undefined);
+      setError(undefined);
+    } catch (_e) {
+      setUser(undefined);
+      setError(new Error(`The request to ${profileUrl} failed`));
+    }
+  }, [profileUrl]);
+
   useEffect((): void => {
     if (user) return;
-
     (async (): Promise<void> => {
-      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);
-      }
+      await checkSession();
+      setIsLoading(false);
     })();
   }, [user]);
 
-  return <User.Provider value={{ user, error, isLoading }}>{children}</User.Provider>;
+  return <User.Provider value={{ user, error, isLoading, checkSession }}>{children}</User.Provider>;
 };
diff --git a/src/index.browser.ts b/src/index.browser.ts
index d8de4573..7fbd1fd1 100644
--- a/src/index.browser.ts
+++ b/src/index.browser.ts
@@ -12,33 +12,35 @@ export {
   WithPageAuthRequired
 } from './frontend';
 
+const serverSideOnly = (method: string): string => `The ${method} method can only be used from the server side`;
+
 const instance: SignInWithAuth0 = {
   getSession() {
-    throw new Error('The getSession method can only be used from the server side');
+    throw new Error(serverSideOnly('getSession'));
   },
   getAccessToken() {
-    throw new Error('The getAccessToken method can only be used from the server side');
+    throw new Error(serverSideOnly('getAccessToken'));
   },
   withApiAuthRequired() {
-    throw new Error('The withApiAuthRequired method can only be used from the server side');
+    throw new Error(serverSideOnly('withApiAuthRequired'));
   },
   handleLogin() {
-    throw new Error('The handleLogin method can only be used from the server side');
+    throw new Error(serverSideOnly('handleLogin'));
   },
   handleLogout() {
-    throw new Error('The handleLogout method can only be used from the server side');
+    throw new Error(serverSideOnly('handleLogout'));
   },
   handleCallback() {
-    throw new Error('The handleCallback method can only be used from the server side');
+    throw new Error(serverSideOnly('handleCallback'));
   },
   handleProfile() {
-    throw new Error('The handleProfile method can only be used from the server side');
+    throw new Error(serverSideOnly('handleProfile'));
   },
   handleAuth() {
-    throw new Error('The handleAuth method can only be used from the server side');
+    throw new Error(serverSideOnly('handleAuth'));
   },
   withPageAuthRequired() {
-    throw new Error('The withPageAuthRequired method can only be used from the server side');
+    throw new Error(serverSideOnly('withPageAuthRequired'));
   }
 };
 


Test patch:
diff --git a/tests/frontend/use-user.test.tsx b/tests/frontend/use-user.test.tsx
index 51e1fe8e..140740b9 100644
--- a/tests/frontend/use-user.test.tsx
+++ b/tests/frontend/use-user.test.tsx
@@ -1,6 +1,5 @@
-import { renderHook } from '@testing-library/react-hooks';
+import { renderHook, act } from '@testing-library/react-hooks';
 
-import { useUser } from '../../src';
 import {
   fetchUserMock,
   fetchUserUnsuccessfulMock,
@@ -8,20 +7,23 @@ import {
   withUserProvider,
   user
 } from '../fixtures/frontend';
+import { useUser } from '../../src';
 
 describe('context wrapper', () => {
+  afterEach(() => delete (global as any).fetch);
+
   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.user).toBeUndefined();
+    expect(result.current.error).toBeUndefined();
     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.error).toBeUndefined();
     expect(result.current.isLoading).toEqual(false);
   });
 
@@ -29,14 +31,14 @@ describe('context wrapper', () => {
     (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.user).toBeUndefined();
+    expect(result.current.error).toBeUndefined();
     expect(result.current.isLoading).toEqual(true);
 
     await waitForValueToChange(() => result.current.isLoading);
 
-    expect(result.current.user).toEqual(undefined);
-    expect(result.current.error).toEqual(undefined);
+    expect(result.current.user).toBeUndefined();
+    expect(result.current.error).toBeUndefined();
     expect(result.current.isLoading).toEqual(false);
   });
 
@@ -44,13 +46,13 @@ describe('context wrapper', () => {
     (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.user).toBeUndefined();
+    expect(result.current.error).toBeUndefined();
     expect(result.current.isLoading).toEqual(true);
 
     await waitForValueToChange(() => result.current.isLoading);
 
-    expect(result.current.user).toEqual(undefined);
+    expect(result.current.user).toBeUndefined();
     expect(result.current.error).toEqual(new Error('The request to /api/auth/me failed'));
     expect(result.current.isLoading).toEqual(false);
   });
@@ -59,12 +61,12 @@ describe('context wrapper', () => {
     const { result } = renderHook(() => useUser(), { wrapper: withUserProvider({ user }) });
 
     expect(result.current.user).toEqual(user);
-    expect(result.current.error).toEqual(undefined);
+    expect(result.current.error).toBeUndefined();
     expect(result.current.isLoading).toEqual(false);
   });
 
   test('should use a custom profileUrl', async () => {
-    const fetchSpy = jest.fn();
+    const fetchSpy = jest.fn().mockReturnValue(Promise.resolve());
     (global as any).fetch = fetchSpy;
     const { result, waitForValueToChange } = renderHook(() => useUser(), {
       wrapper: withUserProvider({ profileUrl: '/api/custom-url' })
@@ -74,7 +76,43 @@ describe('context wrapper', () => {
     expect(fetchSpy).toHaveBeenCalledWith('/api/custom-url');
   });
 
-  afterAll(() => {
-    delete (global as any).fetch;
+  test('should check the session when logged in', async () => {
+    (global as any).fetch = fetchUserUnsuccessfulMock;
+    const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUserProvider() });
+
+    await waitForValueToChange(() => result.current.isLoading);
+    expect(result.current.user).toBeUndefined;
+
+    (global as any).fetch = fetchUserMock;
+
+    await act(async () => await result.current.checkSession());
+    expect(result.current.user).toEqual(user);
+    expect(result.current.error).toBeUndefined();
+    expect(result.current.isLoading).toEqual(false);
+  });
+
+  test('should check the session when logged out', async () => {
+    (global as any).fetch = fetchUserMock;
+    const { result, waitForValueToChange } = renderHook(() => useUser(), { wrapper: withUserProvider() });
+
+    await waitForValueToChange(() => result.current.isLoading);
+    expect(result.current.user).toEqual(user);
+
+    (global as any).fetch = fetchUserUnsuccessfulMock;
+
+    await act(async () => await result.current.checkSession());
+    expect(result.current.user).toBeUndefined();
+    expect(result.current.error).toBeUndefined();
+    expect(result.current.isLoading).toEqual(false);
+  });
+
+  test('should throw an error when not wrapped in UserProvider', async () => {
+    const expectedError = 'You forgot to wrap your app in <UserProvider>';
+    const { result } = renderHook(() => useUser());
+
+    expect(() => result.current.user).toThrowError(expectedError);
+    expect(() => result.current.error).toThrowError(expectedError);
+    expect(() => result.current.isLoading).toThrowError(expectedError);
+    expect(result.current.checkSession).toThrowError(expectedError);
   });
 });
diff --git a/tests/frontend/with-page-auth-required.test.tsx b/tests/frontend/with-page-auth-required.test.tsx
index 7c1a4f8b..82b42fde 100644
--- a/tests/frontend/with-page-auth-required.test.tsx
+++ b/tests/frontend/with-page-auth-required.test.tsx
@@ -16,6 +16,8 @@ const routerMock = {
 jest.mock('next/router', () => ({ useRouter: (): any => routerMock }));
 
 describe('with-page-auth-required csr', () => {
+  afterEach(() => delete (global as any).fetch);
+
   it('should block access to a CSR page when not authenticated', async () => {
     (global as any).fetch = fetchUserUnsuccessfulMock;
     const MyPage = (): JSX.Element => <>Private</>;
@@ -35,7 +37,17 @@ describe('with-page-auth-required csr', () => {
     await waitFor(() => expect(screen.getByText('Private')).toBeInTheDocument());
   });
 
-  it('should show a custom redirecting message', async () => {
+  it('should show an empty element when redirecting', async () => {
+    (global as any).fetch = fetchUserUnsuccessfulMock;
+    const MyPage = (): JSX.Element => <>Private</>;
+    const ProtectedPage = withPageAuthRequired(MyPage);
+
+    const { container } = render(<ProtectedPage />, { wrapper: withUserProvider() });
+    await waitFor(() => expect(container).toBeEmptyDOMElement());
+  });
+
+  it('should show a custom element when redirecting', async () => {
+    (global as any).fetch = fetchUserUnsuccessfulMock;
     const MyPage = (): JSX.Element => <>Private</>;
     const OnRedirecting = (): JSX.Element => <>Redirecting</>;
     const ProtectedPage = withPageAuthRequired(MyPage, { onRedirecting: OnRedirecting });
@@ -44,7 +56,16 @@ describe('with-page-auth-required csr', () => {
     await waitFor(() => expect(screen.getByText('Redirecting')).toBeInTheDocument());
   });
 
-  it('should show a fallback in case of error', async () => {
+  it('should show an empty fallback in case of error', async () => {
+    (global as any).fetch = fetchUserErrorMock;
+    const MyPage = (): JSX.Element => <>Private</>;
+    const ProtectedPage = withPageAuthRequired(MyPage);
+
+    const { container } = render(<ProtectedPage />, { wrapper: withUserProvider() });
+    await waitFor(() => expect(container).toBeEmptyDOMElement());
+  });
+
+  it('should show a custom fallback in case of error', async () => {
     (global as any).fetch = fetchUserErrorMock;
     const MyPage = (): JSX.Element => <>Private</>;
     const OnError = (): JSX.Element => <>Error</>;
@@ -55,14 +76,11 @@ describe('with-page-auth-required csr', () => {
   });
 
   it('should accept a returnTo url', async () => {
+    (global as any).fetch = fetchUserUnsuccessfulMock;
     const MyPage = (): JSX.Element => <>Private</>;
     const ProtectedPage = withPageAuthRequired(MyPage, { returnTo: '/foo' });
 
     render(<ProtectedPage />, { wrapper: withUserProvider() });
     await waitFor(() => expect(routerMock.push).toHaveBeenCalledWith(expect.stringContaining('?returnTo=/foo')));
   });
-
-  afterAll(() => {
-    delete (global as any).fetch;
-  });
 });