From 7eeb79af4e816106179a66b7878a03df33740248 Mon Sep 17 00:00:00 2001
From: Gyanesh Gouraw
Date: Wed, 29 Jul 2026 14:00:11 +0530
Subject: [PATCH 1/2] 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;
From 44b10e2a09093be150f04b9170aa3453a8cc58f5 Mon Sep 17 00:00:00 2001
From: Gyanesh Gouraw
Date: Mon, 3 Aug 2026 14:12:46 +0530
Subject: [PATCH 2/2] refactor: use namespace React import and retain error in
useAuth0Suspense
---
EXAMPLES.md | 75 ++++++++++++++-------------
__tests__/use-auth0-suspense.test.tsx | 21 ++++++++
src/use-auth0-suspense.tsx | 19 ++++---
3 files changed, 70 insertions(+), 45 deletions(-)
diff --git a/EXAMPLES.md b/EXAMPLES.md
index ef109ce5..5d7e8a97 100644
--- a/EXAMPLES.md
+++ b/EXAMPLES.md
@@ -23,43 +23,6 @@
- [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
@@ -1910,3 +1873,41 @@ function CallApi() {
```
Using `withAuthenticationRequired` on protected routes is the simpler alternative — the redirect happens automatically without the null check.
+
+
+## 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.
+
+```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.
\ No newline at end of file
diff --git a/__tests__/use-auth0-suspense.test.tsx b/__tests__/use-auth0-suspense.test.tsx
index 2f073f20..eeaf149b 100644
--- a/__tests__/use-auth0-suspense.test.tsx
+++ b/__tests__/use-auth0-suspense.test.tsx
@@ -106,6 +106,27 @@ describe('useAuth0Suspense', () => {
);
});
+ it('returns the auth methods, omitting isLoading and _initPromise', async () => {
+ clientMock.checkSession.mockResolvedValue(undefined);
+ clientMock.getUser.mockResolvedValue({ name: 'Bob' });
+
+ let captured: Record | undefined;
+ function Capture() {
+ captured = useAuth0Suspense() as unknown as Record;
+ return captured
;
+ }
+
+ await renderWithProvider();
+ await waitFor(() =>
+ expect(screen.getByText('captured')).toBeInTheDocument()
+ );
+
+ expect(captured).not.toHaveProperty('isLoading');
+ expect(captured).not.toHaveProperty('_initPromise');
+ expect(captured).toHaveProperty('error');
+ expect(typeof captured!.loginWithRedirect).toBe('function');
+ });
+
it('throws a clear error when used outside an Auth0Provider', () => {
expect(() => renderHook(() => useAuth0Suspense())).toThrowError(
/must be used within/
diff --git a/src/use-auth0-suspense.tsx b/src/use-auth0-suspense.tsx
index eacbb52a..155bc66b 100644
--- a/src/use-auth0-suspense.tsx
+++ b/src/use-auth0-suspense.tsx
@@ -1,15 +1,18 @@
-import { use, useContext } from 'react';
+// Namespace import: `use` only exists as a named export from React 19, so
+// `import { use }` fails at link time for React 16-18 consumers even if they
+// never call this hook. Property access stays late-bound.
+import * as React 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).
+ * `isLoading` and the internal `_initPromise`. `error` is
+ * retained for post-init failures such as `loginWithPopup`.
*/
export type Auth0SuspenseContextInterface = Omit<
Auth0ContextInterface,
- 'isLoading' | 'error'
+ 'isLoading' | '_initPromise'
>;
/**
@@ -34,13 +37,13 @@ export type Auth0SuspenseContextInterface = Omit<
const useAuth0Suspense = (
context = Auth0Context
): Auth0SuspenseContextInterface => {
- if (typeof use !== 'function') {
+ if (typeof React.use !== 'function') {
throw new Error(
'useAuth0Suspense requires React 19 or later (React.use is unavailable).'
);
}
- const ctx = useContext(context) as Auth0ContextInterface;
+ const ctx = React.useContext(context) as Auth0ContextInterface;
if (!ctx._initPromise) {
throw new Error(
@@ -49,10 +52,10 @@ const useAuth0Suspense = (
}
// Suspends until the init promise resolves; re-throws if it rejected.
- use(ctx._initPromise);
+ React.use(ctx._initPromise);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
- const { isLoading, error, ...rest } = ctx;
+ const { isLoading, _initPromise, ...rest } = ctx;
return rest;
};