Skip to content

Commit 672e325

Browse files
committed
feat: useAuth suspense
1 parent 77d7628 commit 672e325

File tree

4 files changed

+255
-8
lines changed

4 files changed

+255
-8
lines changed

packages/react/src/hooks/__tests__/useAuth.test.tsx

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,158 @@ describe('useAuth', () => {
110110
expect(result.current.sessionId).toBeUndefined();
111111
expect(result.current.userId).toBeUndefined();
112112
});
113+
114+
test('triggers suspense mechanism when suspense option is true and Clerk is not loaded', () => {
115+
const listeners: Array<(payload: any) => void> = [];
116+
const mockIsomorphicClerk = {
117+
loaded: false,
118+
telemetry: { record: vi.fn() },
119+
addListener: vi.fn((callback: any) => {
120+
listeners.push(callback);
121+
return () => {
122+
const index = listeners.indexOf(callback);
123+
if (index > -1) {
124+
listeners.splice(index, 1);
125+
}
126+
};
127+
}),
128+
};
129+
130+
const mockAuthContext = {
131+
actor: undefined,
132+
factorVerificationAge: null,
133+
orgId: undefined,
134+
orgPermissions: undefined,
135+
orgRole: undefined,
136+
orgSlug: undefined,
137+
sessionClaims: null,
138+
sessionId: undefined,
139+
sessionStatus: undefined,
140+
userId: undefined,
141+
};
142+
143+
try {
144+
renderHook(() => useAuth({ suspense: true }), {
145+
wrapper: ({ children }) => (
146+
<ClerkInstanceContext.Provider value={{ value: mockIsomorphicClerk as any }}>
147+
<AuthContext.Provider value={{ value: mockAuthContext as any }}>{children}</AuthContext.Provider>
148+
</ClerkInstanceContext.Provider>
149+
),
150+
});
151+
} catch {
152+
// renderHook may handle Suspense internally, so we check addListener was called instead
153+
}
154+
155+
// Verify that the suspense mechanism was triggered by checking if addListener was called
156+
expect(mockIsomorphicClerk.addListener).toHaveBeenCalled();
157+
});
158+
159+
test('does not suspend when suspense option is true and Clerk is loaded', () => {
160+
const mockIsomorphicClerk = {
161+
loaded: true,
162+
telemetry: { record: vi.fn() },
163+
};
164+
165+
const mockAuthContext = {
166+
actor: null,
167+
factorVerificationAge: null,
168+
orgId: null,
169+
orgPermissions: undefined,
170+
orgRole: null,
171+
orgSlug: null,
172+
sessionClaims: null,
173+
sessionId: null,
174+
sessionStatus: undefined,
175+
userId: null,
176+
};
177+
178+
const { result } = renderHook(() => useAuth({ suspense: true }), {
179+
wrapper: ({ children }) => (
180+
<ClerkInstanceContext.Provider value={{ value: mockIsomorphicClerk as any }}>
181+
<AuthContext.Provider value={{ value: mockAuthContext as any }}>{children}</AuthContext.Provider>
182+
</ClerkInstanceContext.Provider>
183+
),
184+
});
185+
186+
expect(result.current.isLoaded).toBe(true);
187+
expect(result.current.isSignedIn).toBe(false);
188+
});
189+
190+
test('does not suspend when suspense option is false and Clerk is not loaded', () => {
191+
const mockIsomorphicClerk = {
192+
loaded: false,
193+
telemetry: { record: vi.fn() },
194+
};
195+
196+
const mockAuthContext = {
197+
actor: undefined,
198+
factorVerificationAge: null,
199+
orgId: undefined,
200+
orgPermissions: undefined,
201+
orgRole: undefined,
202+
orgSlug: undefined,
203+
sessionClaims: null,
204+
sessionId: undefined,
205+
sessionStatus: undefined,
206+
userId: undefined,
207+
};
208+
209+
const { result } = renderHook(() => useAuth({ suspense: false }), {
210+
wrapper: ({ children }) => (
211+
<ClerkInstanceContext.Provider value={{ value: mockIsomorphicClerk as any }}>
212+
<AuthContext.Provider value={{ value: mockAuthContext as any }}>{children}</AuthContext.Provider>
213+
</ClerkInstanceContext.Provider>
214+
),
215+
});
216+
217+
expect(result.current.isLoaded).toBe(false);
218+
expect(result.current.isSignedIn).toBeUndefined();
219+
});
220+
221+
test('triggers suspense mechanism when suspense option is true and in transitive state', () => {
222+
const listeners: Array<(payload: any) => void> = [];
223+
const mockIsomorphicClerk = {
224+
loaded: true,
225+
telemetry: { record: vi.fn() },
226+
addListener: vi.fn((callback: any) => {
227+
listeners.push(callback);
228+
return () => {
229+
const index = listeners.indexOf(callback);
230+
if (index > -1) {
231+
listeners.splice(index, 1);
232+
}
233+
};
234+
}),
235+
};
236+
237+
const mockAuthContext = {
238+
actor: undefined,
239+
factorVerificationAge: null,
240+
orgId: undefined,
241+
orgPermissions: undefined,
242+
orgRole: undefined,
243+
orgSlug: undefined,
244+
sessionClaims: null,
245+
sessionId: undefined,
246+
sessionStatus: undefined,
247+
userId: undefined,
248+
};
249+
250+
try {
251+
renderHook(() => useAuth({ suspense: true }), {
252+
wrapper: ({ children }) => (
253+
<ClerkInstanceContext.Provider value={{ value: mockIsomorphicClerk as any }}>
254+
<AuthContext.Provider value={{ value: mockAuthContext as any }}>{children}</AuthContext.Provider>
255+
</ClerkInstanceContext.Provider>
256+
),
257+
});
258+
} catch {
259+
// renderHook may handle Suspense internally
260+
}
261+
262+
// Verify that the suspense mechanism was triggered for transitive state
263+
expect(mockIsomorphicClerk.addListener).toHaveBeenCalled();
264+
});
113265
});
114266

115267
describe('useDerivedAuth', () => {

packages/react/src/hooks/__tests__/useAuth.type.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,8 @@ describe('useAuth type tests', () => {
153153

154154
it('do not allow invalid option types', () => {
155155
const invalidValue = 5;
156-
expectTypeOf({ treatPendingAsSignedOut: invalidValue } satisfies Record<
157-
keyof PendingSessionOptions,
158-
any
156+
expectTypeOf({ treatPendingAsSignedOut: invalidValue } satisfies Partial<
157+
Record<keyof PendingSessionOptions, any>
159158
>).toMatchTypeOf<UseAuthParameters>();
160159
});
161160
});

packages/react/src/hooks/useAuth.ts

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
GetToken,
66
JwtPayload,
77
PendingSessionOptions,
8+
Resources,
89
SignOut,
910
UseAuthReturn,
1011
} from '@clerk/shared/types';
@@ -14,9 +15,81 @@ import { useAuthContext } from '../contexts/AuthContext';
1415
import { useIsomorphicClerkContext } from '../contexts/IsomorphicClerkContext';
1516
import { errorThrower } from '../errors/errorThrower';
1617
import { invalidStateError } from '../errors/messages';
18+
import type { IsomorphicClerk } from '../isomorphicClerk';
1719
import { useAssertWrappedByClerkProvider } from './useAssertWrappedByClerkProvider';
1820
import { createGetToken, createSignOut } from './utils';
1921

22+
const clerkLoadedSuspenseCache = new WeakMap<IsomorphicClerk, Promise<void>>();
23+
const transitiveStateSuspenseCache = new WeakMap<IsomorphicClerk, Promise<void>>();
24+
25+
function createClerkLoadedSuspensePromise(clerk: IsomorphicClerk): Promise<void> {
26+
if (clerk.loaded) {
27+
return Promise.resolve();
28+
}
29+
30+
const existingPromise = clerkLoadedSuspenseCache.get(clerk);
31+
if (existingPromise) {
32+
return existingPromise;
33+
}
34+
35+
const promise = new Promise<void>(resolve => {
36+
if (clerk.loaded) {
37+
resolve();
38+
return;
39+
}
40+
41+
const unsubscribe = clerk.addListener((payload: Resources) => {
42+
if (
43+
payload.client ||
44+
payload.session !== undefined ||
45+
payload.user !== undefined ||
46+
payload.organization !== undefined
47+
) {
48+
if (clerk.loaded) {
49+
clerkLoadedSuspenseCache.delete(clerk);
50+
unsubscribe();
51+
resolve();
52+
}
53+
}
54+
});
55+
});
56+
57+
clerkLoadedSuspenseCache.set(clerk, promise);
58+
return promise;
59+
}
60+
61+
function createTransitiveStateSuspensePromise(
62+
clerk: IsomorphicClerk,
63+
authContext: { sessionId?: string | null; userId?: string | null },
64+
): Promise<void> {
65+
if (authContext.sessionId !== undefined || authContext.userId !== undefined) {
66+
return Promise.resolve();
67+
}
68+
69+
const existingPromise = transitiveStateSuspenseCache.get(clerk);
70+
if (existingPromise) {
71+
return existingPromise;
72+
}
73+
74+
const promise = new Promise<void>(resolve => {
75+
if (authContext.sessionId !== undefined || authContext.userId !== undefined) {
76+
resolve();
77+
return;
78+
}
79+
80+
const unsubscribe = clerk.addListener((payload: Resources) => {
81+
if (payload.session !== undefined || payload.user !== undefined) {
82+
transitiveStateSuspenseCache.delete(clerk);
83+
unsubscribe();
84+
resolve();
85+
}
86+
});
87+
});
88+
89+
transitiveStateSuspenseCache.set(clerk, promise);
90+
return promise;
91+
}
92+
2093
/**
2194
* @inline
2295
*/
@@ -35,7 +108,7 @@ type UseAuthOptions = Record<string, any> | PendingSessionOptions | undefined |
35108
* @unionReturnHeadings
36109
* ["Initialization", "Signed out", "Signed in (no active organization)", "Signed in (with active organization)"]
37110
*
38-
* @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`.
111+
* @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`.
39112
*
40113
* @function
41114
*
@@ -95,21 +168,38 @@ type UseAuthOptions = Record<string, any> | PendingSessionOptions | undefined |
95168
export const useAuth = (initialAuthStateOrOptions: UseAuthOptions = {}): UseAuthReturn => {
96169
useAssertWrappedByClerkProvider('useAuth');
97170

98-
const { treatPendingAsSignedOut, ...rest } = initialAuthStateOrOptions ?? {};
171+
const options = initialAuthStateOrOptions ?? {};
172+
const suspense = Boolean((options as any).suspense);
173+
const treatPendingAsSignedOut =
174+
'treatPendingAsSignedOut' in options ? (options.treatPendingAsSignedOut as boolean | undefined) : undefined;
175+
176+
const { suspense: _s, treatPendingAsSignedOut: _t, ...rest } = options as Record<string, any>;
99177
const initialAuthState = rest as any;
100178

101179
const authContextFromHook = useAuthContext();
102180
const isomorphicClerk = useIsomorphicClerkContext();
103181
let authContext = authContextFromHook;
104182

183+
if (suspense) {
184+
if (!isomorphicClerk.loaded) {
185+
// eslint-disable-next-line @typescript-eslint/only-throw-error -- React Suspense requires throwing a promise
186+
throw createClerkLoadedSuspensePromise(isomorphicClerk);
187+
}
188+
189+
if (authContext.sessionId === undefined && authContext.userId === undefined) {
190+
// eslint-disable-next-line @typescript-eslint/only-throw-error -- React Suspense requires throwing a promise
191+
throw createTransitiveStateSuspensePromise(isomorphicClerk, authContext);
192+
}
193+
}
194+
105195
if (!isomorphicClerk.loaded && authContext.sessionId === undefined && authContext.userId === undefined) {
106196
authContext = initialAuthState != null ? initialAuthState : {};
107197
}
108198

109-
const getToken: GetToken = useCallback(createGetToken(isomorphicClerk), [isomorphicClerk]);
110-
const signOut: SignOut = useCallback(createSignOut(isomorphicClerk), [isomorphicClerk]);
199+
const getToken: GetToken = useCallback(opts => createGetToken(isomorphicClerk)(opts), [isomorphicClerk]);
200+
const signOut: SignOut = useCallback(opts => createSignOut(isomorphicClerk)(opts), [isomorphicClerk]);
111201

112-
isomorphicClerk.telemetry?.record(eventMethodCalled('useAuth', { treatPendingAsSignedOut }));
202+
isomorphicClerk.telemetry?.record(eventMethodCalled('useAuth', { suspense, treatPendingAsSignedOut }));
113203

114204
return useDerivedAuth(
115205
{

packages/shared/src/types/session.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ export type PendingSessionOptions = {
3838
* @default true
3939
*/
4040
treatPendingAsSignedOut?: boolean;
41+
/**
42+
* When true, the hook will suspend while Clerk is loading instead of returning `isLoaded: false`.
43+
* Requires a React Suspense boundary to be present in the component tree.
44+
* @default false
45+
*/
46+
suspense?: boolean;
4147
};
4248

4349
type DisallowSystemPermissions<P extends string> = P extends `${OrganizationSystemPermissionPrefix}${string}`

0 commit comments

Comments
 (0)