-
Notifications
You must be signed in to change notification settings - Fork 290
feat: add useAuth0Suspense hook for handling auth loading state with React 19+ #1184
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import { use, useContext } 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` (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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
The docstring promises the hook "returns the full 🔧 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 |
||
|
|
||
| /** | ||
| * ```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; | ||
There was a problem hiding this comment.
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) viaimport { 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]. WhenstrictExportPresenceis 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 animportstatement for a named export, they check the target module (in this case, thereactpackage) to ensure that the export exists [5][6]. 2. Strict Presence Checks: Whenmodule.strictExportPresenceis set totrue, 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 likeuse()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 enabledstrictExportPresenceto 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 (likereact-routerv7) that utilize new features (like the React 19usehook) 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 upgradereactandreact-domto a version that supports the required exports (e.g., React 19 for theusehook) [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 tofalseorwarnsuppresses the build error but may lead to runtimeundefinederrors if the code attempts to call the missing import [5][2][10].Citations:
ifstatement) webpack/webpack#14814usenot found in React export with React18.2vercel/next.js#42548🏁 Script executed:
Repository: auth0/auth0-react
Length of output: 11380
🏁 Script executed:
Repository: auth0/auth0-react
Length of output: 2849
Use
React.usefor 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 unavailableuse; consumers with older React can fail build-time named-export checks, so use a namespace import and callReact.useto avoid breaking React 18 and older consumers for an optional feature.🤖 Prompt for AI Agents