From 7eeb79af4e816106179a66b7878a03df33740248 Mon Sep 17 00:00:00 2001 From: Gyanesh Gouraw Date: Wed, 29 Jul 2026 14:00:11 +0530 Subject: [PATCH] feat: add useAuth0Suspense hook for handling auth loading state with React 19+ --- EXAMPLES.md | 39 ++++++++ __tests__/auth-provider.test.tsx | 36 +++++++ __tests__/use-auth0-suspense.test.tsx | 133 ++++++++++++++++++++++++++ src/auth0-context.tsx | 8 ++ src/auth0-provider.tsx | 21 +++- src/index.tsx | 4 + src/use-auth0-suspense.tsx | 59 ++++++++++++ 7 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 __tests__/use-auth0-suspense.test.tsx create mode 100644 src/use-auth0-suspense.tsx diff --git a/EXAMPLES.md b/EXAMPLES.md index e9255a97..ef109ce5 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -21,6 +21,45 @@ - [Passkeys](#passkeys) - [MyAccount API](#myaccount-api) - [Session Expiry from Upstream IdP (IPSIE)](#session-expiry-from-upstream-idp-ipsie) +- [Use Suspense for loading state (React 19+)](#use-suspense-for-loading-state-react-19) + +## Use Suspense for loading state (React 19+) + +On React 19 and later, `useAuth0Suspense` lets a `` boundary handle the +auth loading state and an Error Boundary handle initialization errors, so your +component code stays focused on rendering. Unlike `useAuth0`, it does not return +`isLoading` (the component suspends until auth is ready) or `error` (it is thrown +to the nearest Error Boundary). + +```jsx +import { Suspense } from 'react'; +import { Auth0Provider, useAuth0Suspense } from '@auth0/auth0-react'; + +function App() { + return ( + + Could not sign you in.

}> + Loading...

}> + +
+
+
+ ); +} + +function UserGreeting() { + const { user, isAuthenticated } = useAuth0Suspense(); + return isAuthenticated ?

Hello, {user?.name}!

:

Please log in

; +} +``` + +`useAuth0Suspense` requires React 19; calling it on an earlier version throws a +clear error. All the auth methods available on `useAuth0` (`loginWithRedirect`, +`logout`, `getAccessTokenSilently`, etc.) are also available here. ## Use with a Class Component diff --git a/__tests__/auth-provider.test.tsx b/__tests__/auth-provider.test.tsx index 9956f288..3afef34a 100644 --- a/__tests__/auth-provider.test.tsx +++ b/__tests__/auth-provider.test.tsx @@ -1635,4 +1635,40 @@ describe('Auth0Provider', () => { expect(result.current.error).toBeUndefined(); }); }); + + describe('Auth0Provider _initPromise', () => { + it('exposes an _initPromise that resolves after successful init', async () => { + clientMock.getUser.mockResolvedValue({ name: 'Bob' }); + const wrapper = createWrapper(); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); + + expect(result.current._initPromise).toBeInstanceOf(Promise); + // resolves (not rejects) once init completes + await expect(result.current._initPromise).resolves.toBeUndefined(); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + }); + + it('rejects _initPromise when init fails', async () => { + clientMock.checkSession.mockRejectedValueOnce({ + error: '__test_error__', + error_description: '__test_error_description__', + }); + const wrapper = createWrapper(); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); + + await expect(result.current._initPromise).rejects.toThrowError( + '__test_error_description__' + ); + }); + + it('keeps a stable _initPromise reference across re-renders', async () => { + clientMock.getUser.mockResolvedValue({ name: 'Bob' }); + const wrapper = createWrapper(); + const { result, rerender } = renderHook(() => useContext(Auth0Context), { wrapper }); + const first = result.current._initPromise; + await waitFor(() => expect(result.current.isLoading).toBe(false)); + rerender(); + expect(result.current._initPromise).toBe(first); + }); + }); }); diff --git a/__tests__/use-auth0-suspense.test.tsx b/__tests__/use-auth0-suspense.test.tsx new file mode 100644 index 00000000..2f073f20 --- /dev/null +++ b/__tests__/use-auth0-suspense.test.tsx @@ -0,0 +1,133 @@ +import { Auth0Client } from '@auth0/auth0-spa-js'; +import '@testing-library/jest-dom'; +import { + act, + render, + renderHook, + screen, + waitFor, +} from '@testing-library/react'; +import React, { Component, ReactNode, Suspense } from 'react'; +import { Auth0Provider } from '../src'; +import useAuth0Suspense from '../src/use-auth0-suspense'; +import { defer } from './helpers'; + +const clientMock = jest.mocked(new Auth0Client({ clientId: '', domain: '' })); + +class ErrorBoundary extends Component< + { children: ReactNode }, + { error: Error | null } +> { + state = { error: null as Error | null }; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + return this.state.error ? ( +
boundary: {this.state.error.message}
+ ) : ( + this.props.children + ); + } +} + +function Greeting() { + const { user, isAuthenticated } = useAuth0Suspense(); + return
{isAuthenticated ? `Hello ${user?.name}` : 'Please log in'}
; +} + +const renderWithProvider = async (child: ReactNode) => + act(async () => { + render( + + + loading}>{child} + + + ); + }); + +describe('useAuth0Suspense', () => { + afterEach(() => { + window.history.pushState({}, document.title, '/'); + }); + + it('shows the Suspense fallback while init is pending, then the content', async () => { + const userDefer = defer<{ name: string }>(); + clientMock.checkSession.mockResolvedValue(undefined); + clientMock.getUser.mockReturnValue(userDefer.promise as never); + + await renderWithProvider(); + + // Still initializing -> fallback + expect(screen.getByText('loading')).toBeInTheDocument(); + + userDefer.resolve({ name: 'Bob' }); + + await waitFor(() => + expect(screen.getByText('Hello Bob')).toBeInTheDocument() + ); + }); + + it('throws init errors to the nearest Error Boundary', async () => { + clientMock.checkSession.mockRejectedValueOnce({ + error: '__test_error__', + error_description: '__test_error_description__', + }); + + await renderWithProvider(); + + await waitFor(() => + expect( + screen.getByText(/boundary: .*__test_error_description__/) + ).toBeInTheDocument() + ); + }); + + it('throws redirect-callback init errors to the nearest Error Boundary', async () => { + // Presence of code/state in the URL makes hasAuthParams() true, so init + // takes the handleRedirectCallback branch instead of checkSession. + window.history.pushState( + {}, + document.title, + '/?code=__test_code__&state=__test_state__' + ); + clientMock.handleRedirectCallback.mockRejectedValueOnce({ + error: '__redirect_error__', + error_description: '__redirect_error_description__', + }); + + await renderWithProvider(); + + await waitFor(() => + expect( + screen.getByText(/boundary: .*__redirect_error_description__/) + ).toBeInTheDocument() + ); + }); + + it('throws a clear error when used outside an Auth0Provider', () => { + expect(() => renderHook(() => useAuth0Suspense())).toThrowError( + /must be used within/ + ); + }); + + it('throws a clear error when React.use is unavailable', async () => { + jest.resetModules(); + jest.doMock('react', () => { + const actual = jest.requireActual('react'); + return { ...actual, use: undefined }; + }); + const { default: hook } = await import('../src/use-auth0-suspense'); + expect(() => hook()).toThrowError(/requires React 19/); + jest.dontMock('react'); + jest.resetModules(); + }); +}); + +describe('useAuth0Suspense exports', () => { + it('is exported from the package root', async () => { + const pkg = await import('../src'); + expect(typeof pkg.useAuth0Suspense).toBe('function'); + }); +}); diff --git a/src/auth0-context.tsx b/src/auth0-context.tsx index 8ff52da1..1b96c018 100644 --- a/src/auth0-context.tsx +++ b/src/auth0-context.tsx @@ -474,6 +474,14 @@ export interface Auth0ContextInterface * ``` */ myAccount: MyAccountApiClient; + + /** + * Internal. A promise that resolves when Auth0 initialization completes and + * rejects with the initialization error if it fails. Consumed by + * `useAuth0Suspense`. Not part of the supported public API. + * @ignore + */ + _initPromise?: Promise; } /** diff --git a/src/auth0-provider.tsx b/src/auth0-provider.tsx index e9dedd76..7121a476 100644 --- a/src/auth0-provider.tsx +++ b/src/auth0-provider.tsx @@ -189,6 +189,18 @@ const Auth0Provider = (opts: Auth0ProviderOptions providedClient ?? new Auth0Client(toAuth0ClientOptions(clientOpts)) ); const [state, dispatch] = useReducer(reducer, initialAuthState as AuthState); + const [initDeferred] = useState(() => { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + // Avoid unhandled-rejection warnings when no one is consuming the promise + // (i.e. useAuth0Suspense is not used). useAuth0Suspense attaches its own handler via use(). + promise.catch(() => undefined); + return { promise, resolve, reject }; + }); const didInitialise = useRef(false); const handleError = useCallback((error: Error) => { @@ -217,11 +229,14 @@ const Auth0Provider = (opts: Auth0ProviderOptions => { @@ -480,6 +495,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions(opts: Auth0ProviderOptions{children}; diff --git a/src/index.tsx b/src/index.tsx index 06fc6834..5c167feb 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,6 +7,10 @@ export { ConnectedAccount } from './auth0-provider'; export { default as useAuth0 } from './use-auth0'; +export { + default as useAuth0Suspense, + Auth0SuspenseContextInterface, +} from './use-auth0-suspense'; export { default as withAuth0, WithAuth0Props } from './with-auth0'; export { default as withAuthenticationRequired, diff --git a/src/use-auth0-suspense.tsx b/src/use-auth0-suspense.tsx new file mode 100644 index 00000000..eacbb52a --- /dev/null +++ b/src/use-auth0-suspense.tsx @@ -0,0 +1,59 @@ +import { use, useContext } from 'react'; +import { User } from '@auth0/auth0-spa-js'; +import Auth0Context, { Auth0ContextInterface } from './auth0-context'; + +/** + * The value returned by `useAuth0Suspense`: the full `useAuth0` interface minus + * `isLoading` (always resolved by the time the hook returns) and `error` + * (thrown to the nearest Error Boundary instead of returned). + */ +export type Auth0SuspenseContextInterface = Omit< + Auth0ContextInterface, + 'isLoading' | 'error' +>; + +/** + * ```jsx + * }> + * + * + * + * function Profile() { + * const { user, isAuthenticated } = useAuth0Suspense(); + * return isAuthenticated ?

Hello {user.name}

:

Please log in

; + * } + * ``` + * + * Suspense-enabled variant of `useAuth0`. Suspends the component until Auth0 + * initialization completes (letting the nearest `` fallback render), + * and throws initialization errors so the nearest Error Boundary can handle + * them. Requires React 19 or later. + * + * TUser is an optional type param to provide a type to the `user` field. + */ +const useAuth0Suspense = ( + context = Auth0Context +): Auth0SuspenseContextInterface => { + if (typeof use !== 'function') { + throw new Error( + 'useAuth0Suspense requires React 19 or later (React.use is unavailable).' + ); + } + + const ctx = useContext(context) as Auth0ContextInterface; + + if (!ctx._initPromise) { + throw new Error( + 'useAuth0Suspense must be used within an .' + ); + } + + // Suspends until the init promise resolves; re-throws if it rejected. + use(ctx._initPromise); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { isLoading, error, ...rest } = ctx; + return rest; +}; + +export default useAuth0Suspense;