Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/full-showers-serve.md
Original file line number Diff line number Diff line change
@@ -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
185 changes: 185 additions & 0 deletions packages/react/src/hooks/__tests__/useAuth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<ClerkInstanceContext.Provider value={{ value: mockIsomorphicClerk as any }}>
<AuthContext.Provider value={{ value: mockAuthContext as any }}>{children}</AuthContext.Provider>
</ClerkInstanceContext.Provider>
),
});

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 }) => (
<ClerkInstanceContext.Provider value={{ value: mockIsomorphicClerk as any }}>
<AuthContext.Provider value={{ value: mockAuthContext as any }}>{children}</AuthContext.Provider>
</ClerkInstanceContext.Provider>
),
});
} 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 }) => (
<ClerkInstanceContext.Provider value={{ value: mockIsomorphicClerk as any }}>
<AuthContext.Provider value={{ value: mockAuthContext as any }}>{children}</AuthContext.Provider>
</ClerkInstanceContext.Provider>
),
});

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 }) => (
<ClerkInstanceContext.Provider value={{ value: mockIsomorphicClerk as any }}>
<AuthContext.Provider value={{ value: mockAuthContext as any }}>{children}</AuthContext.Provider>
</ClerkInstanceContext.Provider>
),
});

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 }) => (
<ClerkInstanceContext.Provider value={{ value: mockIsomorphicClerk as any }}>
<AuthContext.Provider value={{ value: mockAuthContext as any }}>{children}</AuthContext.Provider>
</ClerkInstanceContext.Provider>
),
});
} catch {
// renderHook may handle Suspense internally
}

// Verify that the suspense mechanism was triggered for transitive state
expect(mockIsomorphicClerk.addListener).toHaveBeenCalled();
});
});

describe('useDerivedAuth', () => {
Expand Down
5 changes: 2 additions & 3 deletions packages/react/src/hooks/__tests__/useAuth.type.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<keyof PendingSessionOptions, any>
>).toMatchTypeOf<UseAuthParameters>();
});
});
Expand Down
104 changes: 97 additions & 7 deletions packages/react/src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
GetToken,
JwtPayload,
PendingSessionOptions,
Resources,
SignOut,
UseAuthReturn,
} from '@clerk/shared/types';
Expand All @@ -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<IsomorphicClerk, Promise<void>>();
const transitiveStateSuspenseCache = new WeakMap<IsomorphicClerk, Promise<void>>();

function createClerkLoadedSuspensePromise(clerk: IsomorphicClerk): Promise<void> {
if (clerk.loaded) {
return Promise.resolve();
}

const existingPromise = clerkLoadedSuspenseCache.get(clerk);
if (existingPromise) {
return existingPromise;
}

const promise = new Promise<void>(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<void> {
if (authContext.sessionId !== undefined || authContext.userId !== undefined) {
return Promise.resolve();
}

const existingPromise = transitiveStateSuspenseCache.get(clerk);
if (existingPromise) {
return existingPromise;
}

const promise = new Promise<void>(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
*/
Expand All @@ -35,7 +108,7 @@ type UseAuthOptions = Record<string, any> | 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
*
Expand Down Expand Up @@ -95,21 +168,38 @@ type UseAuthOptions = Record<string, any> | 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<string, any>;
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(
{
Expand Down
6 changes: 6 additions & 0 deletions packages/shared/src/types/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 string> = P extends `${OrganizationSystemPermissionPrefix}${string}`
Expand Down
Loading