Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
39 changes: 39 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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) 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 (
<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.

## Use with a Class Component

Expand Down
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);
});
});
});
133 changes: 133 additions & 0 deletions __tests__/use-auth0-suspense.test.tsx
Original file line number Diff line number Diff line change
@@ -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 ? (
<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('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
59 changes: 59 additions & 0 deletions src/use-auth0-suspense.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { use, useContext } from 'react';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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<TUser extends User = User> = Omit<
Auth0ContextInterface<TUser>,
'isLoading' | 'error'
>;
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 use !== 'function') {
throw new Error(
'useAuth0Suspense requires React 19 or later (React.use is unavailable).'
);
}

const ctx = 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.
use(ctx._initPromise);

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

export default useAuth0Suspense;
Loading