diff --git a/EXAMPLES.md b/EXAMPLES.md index 83e5e6aa..9c0fa276 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -22,6 +22,7 @@ - [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 @@ -1940,3 +1941,47 @@ 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. + +If initialization fails and the user subsequently signs in by some other means — +for example a `loginWithPopup` triggered from outside the boundary — the SDK +re-checks the session once. If that check succeeds, retrying your Error Boundary +renders the subtree normally; if it fails again, the boundary keeps showing the +error. \ No newline at end of file diff --git a/README.md b/README.md index d991d169..2b13abe9 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ Explore public API's available in auth0-react. - [Auth0Provider](https://auth0.github.io/auth0-react/functions/Auth0Provider.html) - [Auth0ProviderOptions](https://auth0.github.io/auth0-react/interfaces/Auth0ProviderOptions.html) - [useAuth0](https://auth0.github.io/auth0-react/functions/useAuth0.html) +- [useAuth0Suspense](https://auth0.github.io/auth0-react/functions/useAuth0Suspense.html) - [withAuth0](https://auth0.github.io/auth0-react/functions/withAuth0.html) - [withAuthenticationRequired](https://auth0.github.io/auth0-react/functions/withAuthenticationRequired.html) 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..6b113d77 --- /dev/null +++ b/__tests__/use-auth0-suspense.test.tsx @@ -0,0 +1,553 @@ +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, + useEffect, + useState, +} from 'react'; +import { Auth0Provider, useAuth0 } from '../src'; +import { initialContext } from '../src/auth0-context'; +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('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('returns a referentially stable value across re-renders', async () => { + clientMock.checkSession.mockResolvedValue(undefined); + clientMock.getUser.mockResolvedValue({ name: 'Bob' }); + + const identities = new Set(); + let effectRuns = 0; + + function Consumer() { + const auth = useAuth0Suspense(); + identities.add(auth); + const [tick, setTick] = useState(0); + // The common "re-fetch when auth changes" pattern. An unstable return + // value turns this into an infinite loop. + useEffect(() => { + effectRuns += 1; + }, [auth]); + return ; + } + + await renderWithProvider(); + await waitFor(() => + expect(screen.getByText('rerender')).toBeInTheDocument() + ); + + // Force three consumer-local re-renders. + for (let i = 0; i < 3; i++) { + await act(async () => { + screen.getByText('rerender').click(); + }); + } + + expect(identities.size).toBe(1); + expect(effectRuns).toBe(1); + }); + + it('still reflects state changes despite the memo', async () => { + // Guards the other side of the memo: stable *identity* must not mean a + // frozen *value*. A `useMemo(..., [])` would satisfy the test above while + // reporting a signed-out user as still authenticated forever. + clientMock.checkSession.mockResolvedValue(undefined); + clientMock.getUser.mockResolvedValue({ name: 'Bob' }); + clientMock.logout.mockResolvedValue(undefined); + + function Consumer() { + const { isAuthenticated, user, logout } = useAuth0Suspense(); + return ( + + ); + } + + await renderWithProvider(); + await waitFor(() => + expect(screen.getByText('state:true:Bob')).toBeInTheDocument() + ); + + await act(async () => { + screen.getByText('state:true:Bob').click(); + }); + + await waitFor(() => + expect(screen.getByText('state:false:none')).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(); + }); +}); + +class ResettableBoundary extends Component< + { children: ReactNode }, + { error: Error | null; nonce: number } +> { + state = { error: null as Error | null, nonce: 0 }; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + if (this.state.error) { + return ( + + ); + } + return
{this.props.children}
; + } +} + +function SuspenseConsumer() { + const { isAuthenticated } = useAuth0Suspense(); + return
suspense-ok:{String(isAuthenticated)}
; +} + +describe('useAuth0Suspense recovery', () => { + it('recovers on boundary retry after auth succeeds via loginWithPopup', async () => { + // First call is init (fails). The retry re-checks the session and succeeds. + clientMock.checkSession + .mockRejectedValueOnce({ + error: '__test_error__', + error_description: '__init_failed__', + }) + .mockResolvedValue(undefined); + clientMock.loginWithPopup.mockResolvedValue(undefined); + clientMock.getUser.mockResolvedValue({ name: 'Bob' }); + + // Lives outside the boundary so it survives the init failure. + function Recovery() { + const { loginWithPopup } = useAuth0(); + return ; + } + + await act(async () => { + render( + + + + loading}> + + + + + ); + }); + + // Init failed -> boundary caught it. + await waitFor(() => expect(screen.getByText('retry')).toBeInTheDocument()); + + // Successful login makes the app authenticated, which triggers the retry. + await act(async () => { + screen.getByText('popup').click(); + }); + + // Boundary retry must now render, not re-throw the stale rejection. + await act(async () => { + screen.getByText('retry').click(); + }); + + await waitFor(() => + expect(screen.getByText('suspense-ok:true')).toBeInTheDocument() + ); + }); + + it('recovers after a silent token succeeds, not just loginWithPopup', async () => { + // GET_ACCESS_TOKEN_COMPLETE does not clear `error`, so recovery must key + // off `isAuthenticated` rather than the error being cleared. + clientMock.checkSession + .mockRejectedValueOnce({ + error: '__test_error__', + error_description: '__init_failed__', + }) + .mockResolvedValue(undefined); + clientMock.getTokenSilently.mockResolvedValue('__token__' as never); + clientMock.getUser.mockResolvedValue({ name: 'Bob' }); + + function Recovery() { + const { getAccessTokenSilently } = useAuth0(); + return ( + + ); + } + + await act(async () => { + render( + + + + loading}> + + + + + ); + }); + + await waitFor(() => expect(screen.getByText('retry')).toBeInTheDocument()); + + await act(async () => { + screen.getByText('token').click(); + }); + await act(async () => { + screen.getByText('retry').click(); + }); + + await waitFor(() => + expect(screen.getByText('suspense-ok:true')).toBeInTheDocument() + ); + }); + + it('keeps rejecting when the retried session check also fails', async () => { + // The retry must report what checkSession actually did. If the session is + // still bad, the boundary keeps showing an error rather than a fake success. + clientMock.checkSession + .mockRejectedValueOnce({ + error: '__test_error__', + error_description: '__init_failed__', + }) + .mockRejectedValue({ + error: '__test_error__', + error_description: '__retry_also_failed__', + }); + clientMock.loginWithPopup.mockResolvedValue(undefined); + clientMock.getUser.mockResolvedValue({ name: 'Bob' }); + + function Recovery() { + const { loginWithPopup } = useAuth0(); + return ; + } + + await act(async () => { + render( + + + + loading}> + + + + + ); + }); + + await waitFor(() => expect(screen.getByText('retry')).toBeInTheDocument()); + + await act(async () => { + screen.getByText('popup').click(); + }); + await act(async () => { + screen.getByText('retry').click(); + }); + + // The retried check failed too, so the boundary catches again. The hook must + // never render content off a fabricated success. + await waitFor(() => expect(screen.getByText('retry')).toBeInTheDocument()); + expect(screen.queryByText('suspense-ok:true')).not.toBeInTheDocument(); + }); + + it('does not retry while init is still pending', async () => { + // Init hangs until we release it. A successful login in the meantime must + // not be mistaken for init having failed-then-recovered. + const sessionDefer = defer(); + clientMock.checkSession.mockReturnValue(sessionDefer.promise as never); + clientMock.getUser.mockResolvedValue({ name: 'Bob' }); + clientMock.loginWithPopup.mockResolvedValue(undefined); + + function Recovery() { + const { loginWithPopup } = useAuth0(); + return ; + } + + await act(async () => { + render( + + + + loading}> + + + + + ); + }); + + await act(async () => { + screen.getByText('popup').click(); + }); + + // Still suspended: init has not finished, so the hook must still wait. + expect(screen.getByText('loading')).toBeInTheDocument(); + expect(clientMock.checkSession).toHaveBeenCalledTimes(1); + + // Now let init finish. The consumer must render -- not hang forever on a + // deferred that was swapped out from under it. + await act(async () => { + sessionDefer.resolve(); + }); + + await waitFor(() => + expect(screen.getByText('suspense-ok:true')).toBeInTheDocument() + ); + }); + + it('retries at most once', async () => { + // A retry that also fails must not re-arm itself, or it would re-run + // checkSession for as long as the user stays authenticated. + clientMock.checkSession.mockRejectedValue({ + error: '__test_error__', + error_description: '__init_failed__', + }); + clientMock.loginWithPopup.mockResolvedValue(undefined); + clientMock.getUser.mockResolvedValue({ name: 'Bob' }); + + function Recovery() { + const { loginWithPopup } = useAuth0(); + return ; + } + + await act(async () => { + render( + + + + loading}> + + + + + ); + }); + + await waitFor(() => expect(clientMock.checkSession).toHaveBeenCalled()); + + for (let i = 0; i < 3; i++) { + await act(async () => { + screen.getByText('popup').click(); + }); + } + + // One init + exactly one retry. + expect(clientMock.checkSession).toHaveBeenCalledTimes(2); + }); + + it('does not re-run the redirect callback on retry', async () => { + // handleRedirectCallback is single-use: replaying it would fail on a + // consumed code, and onRedirectCallback must not fire twice. + window.history.pushState( + {}, + document.title, + '/?code=__test_code__&state=__test_state__' + ); + clientMock.handleRedirectCallback.mockRejectedValueOnce({ + error: '__redirect_error__', + error_description: '__redirect_failed__', + }); + clientMock.checkSession.mockResolvedValue(undefined); + clientMock.loginWithPopup.mockResolvedValue(undefined); + clientMock.getUser.mockResolvedValue({ name: 'Bob' }); + const onRedirectCallback = jest.fn(); + + function Recovery() { + const { loginWithPopup } = useAuth0(); + return ; + } + + await act(async () => { + render( + + + + loading}> + + + + + ); + }); + + await waitFor(() => expect(screen.getByText('retry')).toBeInTheDocument()); + + await act(async () => { + screen.getByText('popup').click(); + }); + await act(async () => { + screen.getByText('retry').click(); + }); + + await waitFor(() => + expect(screen.getByText('suspense-ok:true')).toBeInTheDocument() + ); + // The retry took the checkSession branch, not the redirect branch. + expect(clientMock.handleRedirectCallback).toHaveBeenCalledTimes(1); + expect(onRedirectCallback).not.toHaveBeenCalled(); + }); +}); + +describe('useAuth0Suspense provider detection', () => { + // Regression guard: the missing-provider check in use-auth0-suspense.tsx + // relies on initialContext NOT carrying an _initPromise. If a future change + // adds a stub promise there, the guard would silently pass and consumers + // would get the throwing stub methods instead of a clear error. + it('initialContext carries no _initPromise', () => { + expect( + (initialContext as { _initPromise?: Promise })._initPromise + ).toBeUndefined(); + }); +}); + +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..6c62eb6f 100644 --- a/src/auth0-context.tsx +++ b/src/auth0-context.tsx @@ -474,6 +474,16 @@ 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`, which also treats its absence as "no provider" — do not + * add a stub value for this field to `initialContext`. Not part of the + * supported public API. + * @ignore + */ + _initPromise?: Promise; } /** diff --git a/src/auth0-provider.tsx b/src/auth0-provider.tsx index 89a93816..a5a110cd 100644 --- a/src/auth0-provider.tsx +++ b/src/auth0-provider.tsx @@ -157,6 +157,32 @@ const defaultOnRedirectCallback = (appState?: AppState): void => { ); }; +/** + * @ignore + */ +interface InitDeferred { + promise: Promise; + resolve: () => void; + reject: (error: Error) => void; +} + +/** + * @ignore + */ +const createInitDeferred = (): InitDeferred => { + 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 }; +}; + /** * ```jsx * (opts: Auth0ProviderOptions providedClient ?? new Auth0Client(toAuth0ClientOptions(clientOpts)) ); const [state, dispatch] = useReducer(reducer, initialAuthState as AuthState); + // In state so the init retry below can swap in a fresh, non-rejected promise. + // Without that swap the first rejection would re-throw on every later render, + // making Error Boundary retries useless. + const [initDeferred, setInitDeferred] = useState(createInitDeferred); + // Set only once initialization has genuinely rejected. State, not a ref, so + // the retry effect re-evaluates when init fails *after* the app has become + // authenticated by some other means. + const [initFailed, setInitFailed] = useState(false); + // Guards against retrying more than once: a retry that also fails would + // otherwise re-trigger the effect for as long as the user stays signed in. + const initRetried = useRef(false); const didInitialise = useRef(false); const handleError = useCallback((error: Error) => { @@ -201,15 +238,15 @@ const Auth0Provider = (opts: Auth0ProviderOptions { - if (didInitialise.current) { - return; - } - didInitialise.current = true; - (async (): Promise => { + // Settles `deferred` according to what initialization actually did, so + // `_initPromise` never claims success that did not happen. `allowRedirect` + // is false on a retry: the redirect callback is single-use, so a retry can + // only re-verify the session. + const runInit = useCallback( + async (deferred: InitDeferred, allowRedirect: boolean): Promise => { try { let user: TUser | undefined; - if (hasAuthParams() && !skipRedirectCallback) { + if (allowRedirect && hasAuthParams() && !skipRedirectCallback) { const { appState = {}, response_type, ...result } = await client.handleRedirectCallback(); user = await client.getUser(); appState.response_type = response_type; @@ -222,11 +259,44 @@ const Auth0Provider = (opts: Auth0ProviderOptions { + if (didInitialise.current) { + return; + } + didInitialise.current = true; + // Always the first deferred: `didInitialise` means this only ever runs once, + // so a later swap by the retry effect re-runs this effect but returns above. + void runInit(initDeferred, true); + }, [runInit, initDeferred]); + + // A rejected promise stays rejected forever, so React.use() would re-throw on + // every later render and an Error Boundary retry could never recover. Once + // init has failed but the app has since become authenticated by some other + // means (loginWithPopup, a silent token, a passkey login), re-run the session + // check against a fresh deferred. The promise then reflects a real + // checkSession() outcome rather than an assumption that things are fine -- + // if the session is still bad it rejects again and the boundary keeps showing + // the error. + useEffect(() => { + if (!initFailed || initRetried.current || !state.isAuthenticated) { + return; + } + initRetried.current = true; + const retry = createInitDeferred(); + setInitDeferred(retry); + void runInit(retry, false); + }, [initFailed, state.isAuthenticated, runInit]); const loginWithRedirect = useCallback( (opts?: RedirectLoginOptions): Promise => { @@ -485,6 +555,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..c727f915 --- /dev/null +++ b/src/use-auth0-suspense.tsx @@ -0,0 +1,71 @@ +// 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 = Omit< + Auth0ContextInterface, + 'isLoading' | '_initPromise' +>; + +/** + * ```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. + * + * If initialization fails and the app later becomes authenticated by other + * means, the session is re-checked once; retrying the Error Boundary then + * renders if that check succeeded, or throws again if it did not. + * + * TUser is an optional type param to provide a type to the `user` field. + */ +const useAuth0Suspense = ( + context = Auth0Context +): Auth0SuspenseContextInterface => { + 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; + + if (!ctx._initPromise) { + throw new Error( + 'useAuth0Suspense must be used within an .' + ); + } + + // Suspends until the init promise resolves; re-throws if it rejected. + React.use(ctx._initPromise); + + // Memoized so the returned object is referentially stable across renders, + // matching useAuth0, which hands back the provider's memoized context. + // Without this, `useEffect(..., [auth])` in a consumer re-runs every render. + return React.useMemo(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { isLoading, _initPromise, ...rest } = ctx; + return rest; + }, [ctx]); +}; + +export default useAuth0Suspense;