Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
- [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 with a Class Component

Expand Down Expand Up @@ -1871,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 `<Suspense>` 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 (
<Auth0Provider
domain="YOUR_DOMAIN"
clientId="YOUR_CLIENT_ID"
authorizationParams={{ redirect_uri: window.location.origin }}
>
<MyErrorBoundary fallback={<p>Could not sign you in.</p>}>
<Suspense fallback={<p>Loading...</p>}>
<UserGreeting />
</Suspense>
</MyErrorBoundary>
</Auth0Provider>
);
}

function UserGreeting() {
const { user, isAuthenticated } = useAuth0Suspense();
return isAuthenticated ? <p>Hello, {user?.name}!</p> : <p>Please log in</p>;
}
```

`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.
36 changes: 36 additions & 0 deletions __tests__/auth-provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
154 changes: 154 additions & 0 deletions __tests__/use-auth0-suspense.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
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 ? (
<div>boundary: {this.state.error.message}</div>
) : (
this.props.children
);
}
}

function Greeting() {
const { user, isAuthenticated } = useAuth0Suspense();
return <div>{isAuthenticated ? `Hello ${user?.name}` : 'Please log in'}</div>;
}

const renderWithProvider = async (child: ReactNode) =>
act(async () => {
render(
<Auth0Provider clientId="__test_client_id__" domain="__test_domain__">
<ErrorBoundary>
<Suspense fallback={<div>loading</div>}>{child}</Suspense>
</ErrorBoundary>
</Auth0Provider>
);
});

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(<Greeting />);

// 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(<Greeting />);

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(<Greeting />);

await waitFor(() =>
expect(
screen.getByText(/boundary: .*__redirect_error_description__/)
).toBeInTheDocument()
);
});

it('returns the auth methods, omitting isLoading and _initPromise', async () => {
clientMock.checkSession.mockResolvedValue(undefined);
clientMock.getUser.mockResolvedValue({ name: 'Bob' });

let captured: Record<string, unknown> | undefined;
function Capture() {
captured = useAuth0Suspense() as unknown as Record<string, unknown>;
return <div>captured</div>;
}

await renderWithProvider(<Capture />);
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/
);
});

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');
});
});
8 changes: 8 additions & 0 deletions src/auth0-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,14 @@ export interface Auth0ContextInterface<TUser extends User = User>
* ```
*/
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<void>;
}

/**
Expand Down
21 changes: 19 additions & 2 deletions src/auth0-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,18 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
() => providedClient ?? new Auth0Client(toAuth0ClientOptions(clientOpts))
);
const [state, dispatch] = useReducer(reducer<TUser>, initialAuthState as AuthState<TUser>);
const [initDeferred] = useState(() => {
let resolve!: () => void;
let reject!: (error: Error) => void;
const promise = new Promise<void>((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) => {
Expand Down Expand Up @@ -217,11 +229,14 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
user = await client.getUser();
}
dispatch({ type: 'INITIALISED', user });
initDeferred.resolve();
} catch (error) {
handleError(loginError(error));
const err = loginError(error);
handleError(err);
initDeferred.reject(err);
}
})();
}, [client, onRedirectCallback, skipRedirectCallback, handleError]);
}, [client, onRedirectCallback, skipRedirectCallback, handleError, initDeferred]);

const loginWithRedirect = useCallback(
(opts?: RedirectLoginOptions): Promise<void> => {
Expand Down Expand Up @@ -480,6 +495,7 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
mfa,
passkey,
myAccount,
_initPromise: initDeferred.promise,
};
}, [
state,
Expand All @@ -503,6 +519,7 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
mfa,
passkey,
myAccount,
initDeferred,
]);

return <context.Provider value={contextValue}>{children}</context.Provider>;
Expand Down
4 changes: 4 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
62 changes: 62 additions & 0 deletions src/use-auth0-suspense.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// 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` and the internal `_initPromise`. `error` is
* retained for post-init failures such as `loginWithPopup`.
*/
export type Auth0SuspenseContextInterface<TUser extends User = User> = Omit<
Auth0ContextInterface<TUser>,
'isLoading' | '_initPromise'
>;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* ```jsx
* <Suspense fallback={<Spinner />}>
* <Profile />
* </Suspense>
*
* function Profile() {
* const { user, isAuthenticated } = useAuth0Suspense();
* return isAuthenticated ? <p>Hello {user.name}</p> : <p>Please log in</p>;
* }
* ```
*
* Suspense-enabled variant of `useAuth0`. Suspends the component until Auth0
* initialization completes (letting the nearest `<Suspense>` 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 = <TUser extends User = User>(
context = Auth0Context
): Auth0SuspenseContextInterface<TUser> => {
if (typeof React.use !== 'function') {
throw new Error(
'useAuth0Suspense requires React 19 or later (React.use is unavailable).'
);
}

const ctx = React.useContext(context) as Auth0ContextInterface<TUser>;

if (!ctx._initPromise) {
throw new Error(
'useAuth0Suspense must be used within an <Auth0Provider>.'
);
}

// Suspends until the init promise resolves; re-throws if it rejected.
React.use(ctx._initPromise);

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { isLoading, _initPromise, ...rest } = ctx;
return rest;
};

export default useAuth0Suspense;
Loading