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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny nit, non-blocking — the trailing newline at EOF got dropped here, and there's an extra blank line in the TOC entry above. Kindly restore both.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once this rejects, the promise is settled for the provider's whole lifetime, so React.use() re-throws on every render — an Error Boundary "Try again" (or a key reset) can never recover, unlike useAuth0 where error clears on a later INITIALISED. Can we create a fresh deferred and reset didInitialise when the tree retries?

}
})();
}, [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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Listing the whole object initDeferred as the useMemo's dependency seems harmless but still worth flagging, because of its misleading nature, since only initDeferred.promise is actually used inside the memo body.
initDeferred is stable (created once via useState), this has no runtime impact, the memo will never re-run because of this dep. The dep should be the actual value the memo output is derived from.

]);

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small thing — can we add useAuth0Suspense to the API reference list in README.md too? Every other public export is listed there, so that's the one place a consumer would look for it.

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>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normal react hooks like useContext above should be called at top level of the component and not after conditionals, so they are executed with same order during re renders.
This could be a problem:
The lint rule doesn't know that: eslint-plugin-react-hooks does static analysis, it sees a throw before useContext and flags it as a conditional hook call. This will generate a lint error in any consumer's codebase that has the react-hooks lint rule enabled.
Would suggest if we could move the hooks at the top.


if (!ctx._initPromise) {
throw new Error(
'useAuth0Suspense must be used within an <Auth0Provider>.'
);
Comment on lines +48 to +51

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This outside guard works today but it seems fragile. It relies on _initPromise being absent from initialContext as a proxy for "no provider"
Two scenarios could break it silently:

  1. If a future PR adds a stub _initPromise: Promise.resolve() to initialContext (reasonable, to keep the type fully satisfied), the guard passes, React.use() runs against the default context, and the consumer gets no error but receives the broken stub auth methods, silent misbehavior instead of a clear throw.
  2. A consumer using the multi-provider context param who constructs their own context value without _initPromise gets the "must be used within" error even when they do have a provider, a
    misleading false positive.

Comparing this with how useAuth0 handles this: the stubs in initialContext throw missingProviderError when called, so the sentinel is behavior, not field presence, much more robust.

}

// 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rest-spread returns a new object each render, whereas useAuth0 hands back the provider's memoized context — so a consumer doing useEffect(..., [auth]) will re-run forever. Better to memoize it:

Suggested change
const { isLoading, _initPromise, ...rest } = ctx;
const value = React.useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { isLoading, _initPromise, ...rest } = ctx;
return rest;
}, [ctx]);
return value;

return rest;
};

export default useAuth0Suspense;
Loading