From 10aab8f165ca898752888117bcf5e7adc6ff05df Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 5 Nov 2025 10:09:44 -0600 Subject: [PATCH 1/6] fix(react): Set isLoaded to false during transitive state --- .../src/hooks/__tests__/useAuth.test.tsx | 26 +++++++++++++++++++ packages/react/src/hooks/useAuth.ts | 4 +-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/react/src/hooks/__tests__/useAuth.test.tsx b/packages/react/src/hooks/__tests__/useAuth.test.tsx index add6740921c..ce430413fd8 100644 --- a/packages/react/src/hooks/__tests__/useAuth.test.tsx +++ b/packages/react/src/hooks/__tests__/useAuth.test.tsx @@ -317,4 +317,30 @@ describe('useDerivedAuth', () => { current.has?.({ permission: 'org:sys_foo' }); }); + + it('returns not loaded state during transitive state (undefined values after being loaded)', () => { + const authObject = { + sessionId: undefined, + userId: undefined, + sessionStatus: undefined, + sessionClaims: null, + actor: undefined, + orgId: undefined, + orgRole: undefined, + orgSlug: undefined, + orgPermissions: undefined, + factorVerificationAge: null, + signOut: vi.fn(), + getToken: vi.fn(), + }; + + const { + result: { current }, + } = renderHook(() => useDerivedAuth(authObject)); + + expect(current.isLoaded).toBe(false); + expect(current.isSignedIn).toBeUndefined(); + expect(current.sessionId).toBeUndefined(); + expect(current.userId).toBeUndefined(); + }); }); diff --git a/packages/react/src/hooks/useAuth.ts b/packages/react/src/hooks/useAuth.ts index 6a901d69552..36a76aa64ca 100644 --- a/packages/react/src/hooks/useAuth.ts +++ b/packages/react/src/hooks/useAuth.ts @@ -99,13 +99,13 @@ export const useAuth = (initialAuthStateOrOptions: UseAuthOptions = {}): UseAuth const initialAuthState = rest as any; const authContextFromHook = useAuthContext(); + const isomorphicClerk = useIsomorphicClerkContext(); let authContext = authContextFromHook; - if (authContext.sessionId === undefined && authContext.userId === undefined) { + 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]); From 67581b5c0c6f8f145e1992e60327a5fa82686f1a Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 5 Nov 2025 10:25:04 -0600 Subject: [PATCH 2/6] wip --- packages/react/src/hooks/__tests__/useAuth.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/react/src/hooks/__tests__/useAuth.test.tsx b/packages/react/src/hooks/__tests__/useAuth.test.tsx index ce430413fd8..a5fa6c192a5 100644 --- a/packages/react/src/hooks/__tests__/useAuth.test.tsx +++ b/packages/react/src/hooks/__tests__/useAuth.test.tsx @@ -320,16 +320,16 @@ describe('useDerivedAuth', () => { it('returns not loaded state during transitive state (undefined values after being loaded)', () => { const authObject = { - sessionId: undefined, - userId: undefined, - sessionStatus: undefined, - sessionClaims: null, actor: undefined, + factorVerificationAge: null, orgId: undefined, + orgPermissions: undefined, orgRole: undefined, orgSlug: undefined, - orgPermissions: undefined, - factorVerificationAge: null, + sessionClaims: null, + sessionId: undefined, + sessionStatus: undefined, + userId: undefined, signOut: vi.fn(), getToken: vi.fn(), }; From 35b286593200b173e6024c960f3030c920163b75 Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 5 Nov 2025 10:25:20 -0600 Subject: [PATCH 3/6] wip --- packages/react/src/hooks/__tests__/useAuth.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/hooks/__tests__/useAuth.test.tsx b/packages/react/src/hooks/__tests__/useAuth.test.tsx index a5fa6c192a5..4ecdb4f01d7 100644 --- a/packages/react/src/hooks/__tests__/useAuth.test.tsx +++ b/packages/react/src/hooks/__tests__/useAuth.test.tsx @@ -318,7 +318,7 @@ describe('useDerivedAuth', () => { current.has?.({ permission: 'org:sys_foo' }); }); - it('returns not loaded state during transitive state (undefined values after being loaded)', () => { + it('returns not loaded state during transitive state', () => { const authObject = { actor: undefined, factorVerificationAge: null, From eb81b286e3906272c997ea4ea31fcf666f5019ef Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 5 Nov 2025 14:17:15 -0600 Subject: [PATCH 4/6] changeset --- .changeset/full-showers-serve.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/full-showers-serve.md 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 From 77d7628b3c657ff063e00b7e4b94d7f8115e6ae5 Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 5 Nov 2025 14:35:23 -0600 Subject: [PATCH 5/6] Update test --- .../src/hooks/__tests__/useAuth.test.tsx | 59 +++++++++++-------- 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/packages/react/src/hooks/__tests__/useAuth.test.tsx b/packages/react/src/hooks/__tests__/useAuth.test.tsx index 4ecdb4f01d7..d5f2dfbc694 100644 --- a/packages/react/src/hooks/__tests__/useAuth.test.tsx +++ b/packages/react/src/hooks/__tests__/useAuth.test.tsx @@ -77,6 +77,39 @@ 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(); + }); }); describe('useDerivedAuth', () => { @@ -317,30 +350,4 @@ describe('useDerivedAuth', () => { current.has?.({ permission: 'org:sys_foo' }); }); - - it('returns not loaded state during transitive state', () => { - const authObject = { - actor: undefined, - factorVerificationAge: null, - orgId: undefined, - orgPermissions: undefined, - orgRole: undefined, - orgSlug: undefined, - sessionClaims: null, - sessionId: undefined, - sessionStatus: undefined, - userId: undefined, - signOut: vi.fn(), - getToken: vi.fn(), - }; - - const { - result: { current }, - } = renderHook(() => useDerivedAuth(authObject)); - - expect(current.isLoaded).toBe(false); - expect(current.isSignedIn).toBeUndefined(); - expect(current.sessionId).toBeUndefined(); - expect(current.userId).toBeUndefined(); - }); }); From 672e325b4cbb51a680d3426f2218285f66f1f095 Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 5 Nov 2025 15:03:38 -0600 Subject: [PATCH 6/6] feat: useAuth suspense --- .../src/hooks/__tests__/useAuth.test.tsx | 152 ++++++++++++++++++ .../src/hooks/__tests__/useAuth.type.test.ts | 5 +- packages/react/src/hooks/useAuth.ts | 100 +++++++++++- packages/shared/src/types/session.ts | 6 + 4 files changed, 255 insertions(+), 8 deletions(-) diff --git a/packages/react/src/hooks/__tests__/useAuth.test.tsx b/packages/react/src/hooks/__tests__/useAuth.test.tsx index d5f2dfbc694..8f6ff8d94a4 100644 --- a/packages/react/src/hooks/__tests__/useAuth.test.tsx +++ b/packages/react/src/hooks/__tests__/useAuth.test.tsx @@ -110,6 +110,158 @@ describe('useAuth', () => { 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 36a76aa64ca..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 (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 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}`