diff --git a/.changeset/full-showers-serve.md b/.changeset/full-showers-serve.md new file mode 100644 index 00000000000..242b674902f --- /dev/null +++ b/.changeset/full-showers-serve.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-react': patch +--- + +Ensure that useAuth() hook returns isLoaded=false when isomorphicClerk is loaded but we are in transitive state diff --git a/packages/react/src/hooks/__tests__/useAuth.test.tsx b/packages/react/src/hooks/__tests__/useAuth.test.tsx index add6740921c..8f6ff8d94a4 100644 --- a/packages/react/src/hooks/__tests__/useAuth.test.tsx +++ b/packages/react/src/hooks/__tests__/useAuth.test.tsx @@ -77,6 +77,191 @@ describe('useAuth', () => { ); }).not.toThrow(); }); + + test('returns isLoaded false when isomorphicClerk is loaded but in transitive state', () => { + const mockIsomorphicClerk = { + loaded: true, + telemetry: { record: vi.fn() }, + }; + + const mockAuthContext = { + actor: undefined, + factorVerificationAge: null, + orgId: undefined, + orgPermissions: undefined, + orgRole: undefined, + orgSlug: undefined, + sessionClaims: null, + sessionId: undefined, + sessionStatus: undefined, + userId: undefined, + }; + + const { result } = renderHook(() => useAuth(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.isLoaded).toBe(false); + expect(result.current.isSignedIn).toBeUndefined(); + expect(result.current.sessionId).toBeUndefined(); + expect(result.current.userId).toBeUndefined(); + }); + + test('triggers suspense mechanism when suspense option is true and Clerk is not loaded', () => { + const listeners: Array<(payload: any) => void> = []; + const mockIsomorphicClerk = { + loaded: false, + telemetry: { record: vi.fn() }, + addListener: vi.fn((callback: any) => { + listeners.push(callback); + return () => { + const index = listeners.indexOf(callback); + if (index > -1) { + listeners.splice(index, 1); + } + }; + }), + }; + + const mockAuthContext = { + actor: undefined, + factorVerificationAge: null, + orgId: undefined, + orgPermissions: undefined, + orgRole: undefined, + orgSlug: undefined, + sessionClaims: null, + sessionId: undefined, + sessionStatus: undefined, + userId: undefined, + }; + + try { + renderHook(() => useAuth({ suspense: true }), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + } catch { + // renderHook may handle Suspense internally, so we check addListener was called instead + } + + // Verify that the suspense mechanism was triggered by checking if addListener was called + expect(mockIsomorphicClerk.addListener).toHaveBeenCalled(); + }); + + test('does not suspend when suspense option is true and Clerk is loaded', () => { + const mockIsomorphicClerk = { + loaded: true, + telemetry: { record: vi.fn() }, + }; + + const mockAuthContext = { + actor: null, + factorVerificationAge: null, + orgId: null, + orgPermissions: undefined, + orgRole: null, + orgSlug: null, + sessionClaims: null, + sessionId: null, + sessionStatus: undefined, + userId: null, + }; + + const { result } = renderHook(() => useAuth({ suspense: true }), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.isLoaded).toBe(true); + expect(result.current.isSignedIn).toBe(false); + }); + + test('does not suspend when suspense option is false and Clerk is not loaded', () => { + const mockIsomorphicClerk = { + loaded: false, + telemetry: { record: vi.fn() }, + }; + + const mockAuthContext = { + actor: undefined, + factorVerificationAge: null, + orgId: undefined, + orgPermissions: undefined, + orgRole: undefined, + orgSlug: undefined, + sessionClaims: null, + sessionId: undefined, + sessionStatus: undefined, + userId: undefined, + }; + + const { result } = renderHook(() => useAuth({ suspense: false }), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.isLoaded).toBe(false); + expect(result.current.isSignedIn).toBeUndefined(); + }); + + test('triggers suspense mechanism when suspense option is true and in transitive state', () => { + const listeners: Array<(payload: any) => void> = []; + const mockIsomorphicClerk = { + loaded: true, + telemetry: { record: vi.fn() }, + addListener: vi.fn((callback: any) => { + listeners.push(callback); + return () => { + const index = listeners.indexOf(callback); + if (index > -1) { + listeners.splice(index, 1); + } + }; + }), + }; + + const mockAuthContext = { + actor: undefined, + factorVerificationAge: null, + orgId: undefined, + orgPermissions: undefined, + orgRole: undefined, + orgSlug: undefined, + sessionClaims: null, + sessionId: undefined, + sessionStatus: undefined, + userId: undefined, + }; + + try { + renderHook(() => useAuth({ suspense: true }), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + } catch { + // renderHook may handle Suspense internally + } + + // Verify that the suspense mechanism was triggered for transitive state + expect(mockIsomorphicClerk.addListener).toHaveBeenCalled(); + }); }); describe('useDerivedAuth', () => { diff --git a/packages/react/src/hooks/__tests__/useAuth.type.test.ts b/packages/react/src/hooks/__tests__/useAuth.type.test.ts index 34ae3a05176..f41ad935928 100644 --- a/packages/react/src/hooks/__tests__/useAuth.type.test.ts +++ b/packages/react/src/hooks/__tests__/useAuth.type.test.ts @@ -153,9 +153,8 @@ describe('useAuth type tests', () => { it('do not allow invalid option types', () => { const invalidValue = 5; - expectTypeOf({ treatPendingAsSignedOut: invalidValue } satisfies Record< - keyof PendingSessionOptions, - any + expectTypeOf({ treatPendingAsSignedOut: invalidValue } satisfies Partial< + Record >).toMatchTypeOf(); }); }); diff --git a/packages/react/src/hooks/useAuth.ts b/packages/react/src/hooks/useAuth.ts index 6a901d69552..143577b5b56 100644 --- a/packages/react/src/hooks/useAuth.ts +++ b/packages/react/src/hooks/useAuth.ts @@ -5,6 +5,7 @@ import type { GetToken, JwtPayload, PendingSessionOptions, + Resources, SignOut, UseAuthReturn, } from '@clerk/shared/types'; @@ -14,9 +15,81 @@ import { useAuthContext } from '../contexts/AuthContext'; import { useIsomorphicClerkContext } from '../contexts/IsomorphicClerkContext'; import { errorThrower } from '../errors/errorThrower'; import { invalidStateError } from '../errors/messages'; +import type { IsomorphicClerk } from '../isomorphicClerk'; import { useAssertWrappedByClerkProvider } from './useAssertWrappedByClerkProvider'; import { createGetToken, createSignOut } from './utils'; +const clerkLoadedSuspenseCache = new WeakMap>(); +const transitiveStateSuspenseCache = new WeakMap>(); + +function createClerkLoadedSuspensePromise(clerk: IsomorphicClerk): Promise { + if (clerk.loaded) { + return Promise.resolve(); + } + + const existingPromise = clerkLoadedSuspenseCache.get(clerk); + if (existingPromise) { + return existingPromise; + } + + const promise = new Promise(resolve => { + if (clerk.loaded) { + resolve(); + return; + } + + const unsubscribe = clerk.addListener((payload: Resources) => { + if ( + payload.client || + payload.session !== undefined || + payload.user !== undefined || + payload.organization !== undefined + ) { + if (clerk.loaded) { + clerkLoadedSuspenseCache.delete(clerk); + unsubscribe(); + resolve(); + } + } + }); + }); + + clerkLoadedSuspenseCache.set(clerk, promise); + return promise; +} + +function createTransitiveStateSuspensePromise( + clerk: IsomorphicClerk, + authContext: { sessionId?: string | null; userId?: string | null }, +): Promise { + if (authContext.sessionId !== undefined || authContext.userId !== undefined) { + return Promise.resolve(); + } + + const existingPromise = transitiveStateSuspenseCache.get(clerk); + if (existingPromise) { + return existingPromise; + } + + const promise = new Promise(resolve => { + if (authContext.sessionId !== undefined || authContext.userId !== undefined) { + resolve(); + return; + } + + const unsubscribe = clerk.addListener((payload: Resources) => { + if (payload.session !== undefined || payload.user !== undefined) { + transitiveStateSuspenseCache.delete(clerk); + unsubscribe(); + resolve(); + } + }); + }); + + transitiveStateSuspenseCache.set(clerk, promise); + return promise; +} + /** * @inline */ @@ -35,7 +108,7 @@ type UseAuthOptions = Record | PendingSessionOptions | undefined | * @unionReturnHeadings * ["Initialization", "Signed out", "Signed in (no active organization)", "Signed in (with active organization)"] * - * @param [initialAuthStateOrOptions] - An object containing the initial authentication state or options for the `useAuth()` hook. If not provided, the hook will attempt to derive the state from the context. `treatPendingAsSignedOut` is a boolean that indicates whether pending sessions are considered as signed out or not. Defaults to `true`. + * @param [initialAuthStateOrOptions] - An object containing the initial authentication state or options for the `useAuth()` hook. If not provided, the hook will attempt to derive the state from the context. `treatPendingAsSignedOut` is a boolean that indicates whether pending sessions are considered as signed out or not. Defaults to `true`. `suspense` is a boolean that enables React Suspense behavior - when `true`, the hook will suspend instead of returning `isLoaded: false`. Requires a Suspense boundary. Defaults to `false`. * * @function * @@ -95,21 +168,38 @@ type UseAuthOptions = Record | PendingSessionOptions | undefined | export const useAuth = (initialAuthStateOrOptions: UseAuthOptions = {}): UseAuthReturn => { useAssertWrappedByClerkProvider('useAuth'); - const { treatPendingAsSignedOut, ...rest } = initialAuthStateOrOptions ?? {}; + const options = initialAuthStateOrOptions ?? {}; + const suspense = Boolean((options as any).suspense); + const treatPendingAsSignedOut = + 'treatPendingAsSignedOut' in options ? (options.treatPendingAsSignedOut as boolean | undefined) : undefined; + + const { suspense: _s, treatPendingAsSignedOut: _t, ...rest } = options as Record; const initialAuthState = rest as any; const authContextFromHook = useAuthContext(); + const isomorphicClerk = useIsomorphicClerkContext(); let authContext = authContextFromHook; - if (authContext.sessionId === undefined && authContext.userId === undefined) { + if (suspense) { + if (!isomorphicClerk.loaded) { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- React Suspense requires throwing a promise + throw createClerkLoadedSuspensePromise(isomorphicClerk); + } + + if (authContext.sessionId === undefined && authContext.userId === undefined) { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- React Suspense requires throwing a promise + throw createTransitiveStateSuspensePromise(isomorphicClerk, authContext); + } + } + + if (!isomorphicClerk.loaded && authContext.sessionId === undefined && authContext.userId === undefined) { authContext = initialAuthState != null ? initialAuthState : {}; } - const isomorphicClerk = useIsomorphicClerkContext(); - const getToken: GetToken = useCallback(createGetToken(isomorphicClerk), [isomorphicClerk]); - const signOut: SignOut = useCallback(createSignOut(isomorphicClerk), [isomorphicClerk]); + const getToken: GetToken = useCallback(opts => createGetToken(isomorphicClerk)(opts), [isomorphicClerk]); + const signOut: SignOut = useCallback(opts => createSignOut(isomorphicClerk)(opts), [isomorphicClerk]); - isomorphicClerk.telemetry?.record(eventMethodCalled('useAuth', { treatPendingAsSignedOut })); + isomorphicClerk.telemetry?.record(eventMethodCalled('useAuth', { suspense, treatPendingAsSignedOut })); return useDerivedAuth( { diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index 11629a838d4..7c61b40f016 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -38,6 +38,12 @@ export type PendingSessionOptions = { * @default true */ treatPendingAsSignedOut?: boolean; + /** + * When true, the hook will suspend while Clerk is loading instead of returning `isLoaded: false`. + * Requires a React Suspense boundary to be present in the component tree. + * @default false + */ + suspense?: boolean; }; type DisallowSystemPermissions

= P extends `${OrganizationSystemPermissionPrefix}${string}`