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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does importing a named export that is absent in an older CommonJS React version (e.g. use) via import { use } from 'react' trigger build errors/warnings under bundlers with strict export-presence checks (e.g. Create React App / webpack strictExportPresence)?

💡 Result:

Yes, importing a named export that does not exist in an older version of React (such as import { use } from 'react' in React 18 or earlier) will trigger a build error under bundlers configured with strict export-presence checks [1][2]. When strictExportPresence is enabled in Webpack—a configuration used by default in older versions of Create React App—a missing named export is elevated from a build-time warning to a hard compilation error [3][4][5]. ### Why this happens 1. Static Analysis: Modern bundlers like Webpack perform static analysis on your code to verify imports. When they encounter an import statement for a named export, they check the target module (in this case, the react package) to ensure that the export exists [5][6]. 2. Strict Presence Checks: When module.strictExportPresence is set to true, the bundler strictly enforces that all imported members must be present in the target module [3][7]. If a library (such as a router or UI library) uses a new API like use() that is not available in the installed version of React, the bundler identifies this discrepancy during the build process and halts compilation [1][2]. ### Common Scenarios * Create React App (CRA): Older versions of CRA specifically enabled strictExportPresence to ensure that developers were alerted to missing imports, which helps prevent runtime issues [4][5]. * Dependency Incompatibility: This issue is frequently encountered when upgrading third-party dependencies (like react-router v7) that utilize new features (like the React 19 use hook) while the project is still running an older version of React (e.g., React 18) [1][2]. ### How to Resolve * Upgrade React: The most direct fix is to upgrade react and react-dom to a version that supports the required exports (e.g., React 19 for the use hook) [1][8][9]. * Adjust Bundler Configuration: If an immediate upgrade is not possible, you can temporarily downgrade the error to a warning by adjusting your Webpack configuration [2]: javascript // In your webpack.config.js module: { parser: { javascript: { importExportsPresence: 'warn' // Or 'false' to suppress } } } Note that setting this to false or warn suppresses the build error but may lead to runtime undefined errors if the code attempts to call the missing import [5][2][10].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## package files"
git ls-files | rg '(^|/)(package\.json|tsconfig.*\.json|vite\.config\.[jt]s|webpack\.config\.[jt]s|rollup\.config\.[jt]s|babel\.config\.[jt]s|\.npmrc|pnpm-workspace\.yaml)$' || true

echo
echo "## relevant package.json react version mentions"
python3 - <<'PY'
import json, pathlib
for p in pathlib.Path('.').rglob('package.json'):
    try:
        data=json.loads(p.read_text())
    except Exception as e:
        continue
    keys=set()
    for sec in ('dependencies','devDependencies','peerDependencies','optionalDependencies'):
        if isinstance(data.get(sec), dict):
            keys.update(k for k,v in data[sec].items() if k=='react' or 'react' in k)
    if keys:
        print(p)
        for sec in ('dependencies','devDependencies','peerDependencies','optionalDependencies'):
            vals={k:data[sec][k] for k in keys if k in data.get(sec, {})}
            if vals:
                print("  ", sec, "=>", vals)
PY

echo
echo "## target file outline and contents"
file="$(git ls-files | rg '(^|/)use-auth0-suspense\.tsx$' | head -n1 || true)"
if [ -n "$file" ]; then
  wc -l "$file"
  ast-grep outline "$file" || true
  echo
  cat -n "$file"
fi

echo
echo "## named import usages of react/use"
rg -n "import \{[^}]*\buse\b[^\}]*\} from ['\"]react['\"]|React\.use|useHook|typeof use|strictExportPresence|importExportsPresence" . -g '!node_modules' -g '!dist' -g '!build' || true

Repository: auth0/auth0-react

Length of output: 11380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## react package shape in node_modules"
python3 - <<'PY'
import json, pathlib
print("## react package.json")
try:
    p=pathlib.Path("node_modules/react/package.json")
    data=json.loads(p.read_text())
    print(data.get("exports"))
    print(data.get("module"))
    print([k for k in data if k in ("exports","types","type","main","module","name","version")])
except Exception as e:
    print("missing node_modules/react/package.json:", e)

print("\n## exports snippets")
for p in pathlib.Path("node_modules/react").glob("**"):
    if p.is_file() and p.name in ("index.cjs","index.production.min.js","index.development.js","index.js","package.json"):
        text=p.read_text(errors="ignore")
        if "use" in text:
            print("\n---", p, "---")
            lines=text.splitlines()
            for i,l in enumerate(lines,1):
                if "use" in l.lower() and ("export" in l or "function" in l or "useContext" in l or "React.createElement" in l):
                    print(i,l)
            txt="\n".join(lines[:220])
            print(txt[-1500:])
PY

echo
echo "## test around React.use unavailable"
testf="$(git ls-files | rg '__tests__/use-auth0-suspense\.test\.tsx$' | head -n1 || true)"
if [ -n "$testf" ]; then
  wc -l "$testf"
  sed -n '80,140p' "$testf" | cat -n
fi

Repository: auth0/auth0-react

Length of output: 2849


Use React.use for React 16/17/18 compatibility.

import { use } from 'react' requires the static named export added in React 19. This hook is documented for React 19+, but the package still supports down to React 16.11 and tests cover unavailable use; consumers with older React can fail build-time named-export checks, so use a namespace import and call React.use to avoid breaking React 18 and older consumers for an optional feature.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/use-auth0-suspense.tsx` at line 1, Update the import in
use-auth0-suspense.tsx to use a React namespace import, and change the hook’s
use invocation to React.use while preserving useContext and the existing
behavior. This avoids requiring a static named use export from older supported
React versions.

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 on lines +10 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

_initPromise leaks into the public type and runtime return value.

The docstring promises the hook "returns the full useAuth0 interface minus isLoading ... and error", but Auth0SuspenseContextInterface's Omit list and the { isLoading, error, ...rest } destructure both leave out _initPromise. Since _initPromise is documented as internal/not-public-API on Auth0ContextInterface, it should not be exposed through this hook's type or return value either.

🔧 Proposed fix
 export type Auth0SuspenseContextInterface<TUser extends User = User> = Omit<
   Auth0ContextInterface<TUser>,
-  'isLoading' | 'error'
+  'isLoading' | 'error' | '_initPromise'
 >;
-  // eslint-disable-next-line `@typescript-eslint/no-unused-vars`
-  const { isLoading, error, ...rest } = ctx;
+  // eslint-disable-next-line `@typescript-eslint/no-unused-vars`
+  const { isLoading, error, _initPromise, ...rest } = ctx;
   return rest;

Also applies to: 54-56

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/use-auth0-suspense.tsx` around lines 10 - 13, Update
Auth0SuspenseContextInterface and the useAuth0Suspense return destructuring to
omit _initPromise alongside isLoading and error, ensuring the internal field is
excluded from both the public type and runtime result.


/**
* ```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