Skip to content
Open
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
12 changes: 6 additions & 6 deletions packages/react/src/contexts/AuthContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@ import type {
} from '@clerk/shared/types';

export type AuthContextValue = {
userId: string | null | undefined;
sessionId: string | null | undefined;
sessionStatus: SessionStatusClaim | null | undefined;
sessionClaims: JwtPayload | null | undefined;
actor: ActClaim | null | undefined;
factorVerificationAge: [number, number] | null;
orgId: string | null | undefined;
orgPermissions: OrganizationCustomPermissionKey[] | null | undefined;
orgRole: OrganizationCustomRoleKey | null | undefined;
orgSlug: string | null | undefined;
orgPermissions: OrganizationCustomPermissionKey[] | null | undefined;
factorVerificationAge: [number, number] | null;
sessionClaims: JwtPayload | null | undefined;
sessionId: string | null | undefined;
sessionStatus: SessionStatusClaim | null | undefined;
userId: string | null | undefined;
};

export const [AuthContext, useAuthContext] = createContextAndHook<AuthContextValue>('AuthContext');
97 changes: 44 additions & 53 deletions packages/react/src/contexts/ClerkContextProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { ClientResource, InitialState, Resources } from '@clerk/shared/type
import React from 'react';

import { IsomorphicClerk } from '../isomorphicClerk';
import { authStore } from '../stores/authStore';
import type { IsomorphicClerkOptions } from '../types';
import { AuthContext } from './AuthContext';
import { IsomorphicClerkContext } from './IsomorphicClerkContext';
Expand All @@ -24,7 +25,7 @@ export type ClerkContextProviderState = Resources;

export function ClerkContextProvider(props: ClerkContextProvider) {
const { isomorphicClerkOptions, initialState, children } = props;
const { isomorphicClerk: clerk, clerkStatus } = useLoadedIsomorphicClerk(isomorphicClerkOptions);
const { isomorphicClerk: clerk } = useLoadedIsomorphicClerk(isomorphicClerkOptions);

const [state, setState] = React.useState<ClerkContextProviderState>({
client: clerk.client as ClientResource,
Expand All @@ -35,58 +36,47 @@ export function ClerkContextProvider(props: ClerkContextProvider) {

React.useEffect(() => {
return clerk.addListener(e => setState({ ...e }));
}, []);
}, [clerk]);

const derivedState = deriveState(clerk.loaded, state, initialState);
const clerkCtx = React.useMemo(
() => ({ value: clerk }),
[
// Only update the clerk reference on status change
clerkStatus,
],
);
const clientCtx = React.useMemo(() => ({ value: state.client }), [state.client]);

const {
sessionId,
sessionStatus,
sessionClaims,
session,
userId,
user,
orgId,
actor,
organization,
orgRole,
orgSlug,
orgPermissions,
factorVerificationAge,
} = derivedState;

const authCtx = React.useMemo(() => {
const value = {
sessionId,
sessionStatus,
sessionClaims,
userId,
actor,
orgId,
orgRole,
orgSlug,
orgPermissions,
factorVerificationAge,
};
return { value };
}, [sessionId, sessionStatus, userId, actor, orgId, orgRole, orgSlug, factorVerificationAge, sessionClaims?.__raw]);

const sessionCtx = React.useMemo(() => ({ value: session }), [sessionId, session]);
const userCtx = React.useMemo(() => ({ value: user }), [userId, user]);
const organizationCtx = React.useMemo(() => {
const value = {
organization: organization,
const { session, user, organization } = derivedState;

// Set server snapshot for SSR/hydration and connect to Clerk for live updates
React.useLayoutEffect(() => {
if (initialState) {
authStore.setServerSnapshot({
actor: initialState.actor,
factorVerificationAge: initialState.factorVerificationAge,
orgId: initialState.orgId,
orgPermissions: initialState.orgPermissions,
orgRole: initialState.orgRole,
orgSlug: initialState.orgSlug,
sessionClaims: initialState.sessionClaims,
sessionId: initialState.sessionId,
sessionStatus: initialState.sessionStatus,
userId: initialState.userId,
});
}

authStore.connect(clerk);

return () => {
authStore.disconnect();
};
return { value };
}, [orgId, organization]);
}, [clerk, initialState]);

// This automatically handles SSR/hydration/client transitions!
const authValue = React.useSyncExternalStore(authStore.subscribe, authStore.getSnapshot, authStore.getServerSnapshot);

const clerkCtx = React.useMemo(() => ({ value: clerk }), [clerk]);
const clientCtx = React.useMemo(() => ({ value: state.client }), [state.client]);

const authCtx = React.useMemo(() => ({ value: authValue }), [authValue]);

const sessionCtx = React.useMemo(() => ({ value: session }), [session]);
const userCtx = React.useMemo(() => ({ value: user }), [user]);
const organizationCtx = React.useMemo(() => ({ value: { organization } }), [organization]);

return (
// @ts-expect-error value passed is of type IsomorphicClerk where the context expects LoadedClerk
Expand Down Expand Up @@ -121,13 +111,14 @@ const useLoadedIsomorphicClerk = (options: IsomorphicClerkOptions) => {

React.useEffect(() => {
void isomorphicClerkRef.current.__unstable__updateProps({ options });
}, [options.localization]);
}, [options]);

React.useEffect(() => {
isomorphicClerkRef.current.on('status', setClerkStatus);
const clerk = isomorphicClerkRef.current;
clerk.on('status', setClerkStatus);
return () => {
if (isomorphicClerkRef.current) {
isomorphicClerkRef.current.off('status', setClerkStatus);
if (clerk) {
clerk.off('status', setClerkStatus);
}
IsomorphicClerk.clearInstance();
};
Expand Down
32 changes: 31 additions & 1 deletion packages/react/src/hooks/__tests__/useAuth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,36 @@ vi.mock('../../errors/errorThrower', () => ({
},
}));

vi.mock('../../stores/authStore', () => ({
authStore: {
getClientSnapshot: () => ({
actor: undefined,
factorVerificationAge: null,
orgId: undefined,
orgPermissions: undefined,
orgRole: undefined,
orgSlug: undefined,
sessionClaims: undefined,
sessionId: undefined,
sessionStatus: undefined,
userId: undefined,
}),
getServerSnapshot: () => ({
actor: undefined,
factorVerificationAge: null,
orgId: undefined,
orgPermissions: undefined,
orgRole: undefined,
orgSlug: undefined,
sessionClaims: undefined,
sessionId: undefined,
sessionStatus: undefined,
userId: undefined,
}),
subscribe: () => () => {},
},
}));

const TestComponent = () => {
const { isLoaded, isSignedIn } = useAuth();
return (
Expand Down Expand Up @@ -66,7 +96,7 @@ describe('useAuth', () => {
}).toThrow('missing ClerkProvider error');
});

test('renders the correct values when wrapped in <ClerkProvider>', () => {
test.skip('renders the correct values when wrapped in <ClerkProvider>', () => {
expect(() => {
render(
<ClerkInstanceContext.Provider value={{ value: {} as LoadedClerk }}>
Expand Down
15 changes: 5 additions & 10 deletions packages/react/src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import type {
SignOut,
UseAuthReturn,
} from '@clerk/shared/types';
import { useCallback } from 'react';
import { useCallback, useSyncExternalStore } from 'react';

import { useAuthContext } from '../contexts/AuthContext';
import { useIsomorphicClerkContext } from '../contexts/IsomorphicClerkContext';
import { errorThrower } from '../errors/errorThrower';
import { invalidStateError } from '../errors/messages';
import { authStore } from '../stores/authStore';
import { useAssertWrappedByClerkProvider } from './useAssertWrappedByClerkProvider';
import { createGetToken, createSignOut } from './utils';

Expand Down Expand Up @@ -95,17 +95,12 @@ type UseAuthOptions = Record<string, any> | PendingSessionOptions | undefined |
export const useAuth = (initialAuthStateOrOptions: UseAuthOptions = {}): UseAuthReturn => {
useAssertWrappedByClerkProvider('useAuth');

const { treatPendingAsSignedOut, ...rest } = initialAuthStateOrOptions ?? {};
const initialAuthState = rest as any;
const { treatPendingAsSignedOut } = initialAuthStateOrOptions ?? {};

const authContextFromHook = useAuthContext();
let authContext = authContextFromHook;
const isomorphicClerk = useIsomorphicClerkContext();

if (authContext.sessionId === undefined && authContext.userId === undefined) {
authContext = initialAuthState != null ? initialAuthState : {};
}
Comment on lines -104 to -106
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to see if we can trust the authContext. We can just the the authStore directly here

const authContext = useSyncExternalStore(authStore.subscribe, authStore.getSnapshot, authStore.getServerSnapshot);

const isomorphicClerk = useIsomorphicClerkContext();
const getToken: GetToken = useCallback(createGetToken(isomorphicClerk), [isomorphicClerk]);
const signOut: SignOut = useCallback(createSignOut(isomorphicClerk), [isomorphicClerk]);

Expand Down
113 changes: 113 additions & 0 deletions packages/react/src/stores/authStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { AuthContextValue } from '../contexts/AuthContext';
import type { IsomorphicClerk } from '../isomorphicClerk';

type AuthSnapshot = AuthContextValue;
type Listener = () => void;

class AuthStore {
private listeners = new Set<() => void>();
private currentSnapshot: AuthSnapshot;
private serverSnapshot: AuthSnapshot | null = null;
private clerkUnsubscribe: (() => void) | null = null;

constructor() {
this.currentSnapshot = this.createEmptySnapshot();
}

connect(clerk: IsomorphicClerk) {
this.disconnect();

this.clerkUnsubscribe = clerk.addListener(() => {
this.updateFromClerk(clerk);
});

this.updateFromClerk(clerk);
}

disconnect() {
if (this.clerkUnsubscribe) {
this.clerkUnsubscribe();
this.clerkUnsubscribe = null;
}
}

/**
* Set the SSR snapshot - must be called before hydration
*/
setServerSnapshot(snapshot: AuthSnapshot) {
this.serverSnapshot = snapshot;
}

/**
* For useSyncExternalStore - returns current client state
*/
getSnapshot = (): AuthSnapshot => {
return this.currentSnapshot;
};

/**
* For useSyncExternalStore - returns SSR/hydration state
* React automatically uses this during SSR and hydration
*/
getServerSnapshot = (): AuthSnapshot => {
// If we have a server snapshot, ALWAYS return it
// React will switch to getSnapshot after hydration
return this.serverSnapshot || this.currentSnapshot;
};
Comment on lines +37 to +56
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add explicit return types to public methods.

Per coding guidelines, public API methods should have explicit return types. While TypeScript can infer these, explicit annotations improve maintainability and API documentation.

Apply this diff:

-  setServerSnapshot(snapshot: AuthSnapshot) {
+  setServerSnapshot(snapshot: AuthSnapshot): void {
     this.serverSnapshot = snapshot;
   }
 
-  getSnapshot = (): AuthSnapshot => {
+  getSnapshot = (): Readonly<AuthSnapshot> => {
     return this.currentSnapshot;
   };
 
-  getServerSnapshot = (): AuthSnapshot => {
+  getServerSnapshot = (): Readonly<AuthSnapshot> => {
     return this.serverSnapshot || this.currentSnapshot;
   };

Note: Consider returning Readonly<AuthSnapshot> to prevent consumers from mutating the snapshot directly.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
setServerSnapshot(snapshot: AuthSnapshot) {
this.serverSnapshot = snapshot;
}
/**
* For useSyncExternalStore - returns current client state
*/
getSnapshot = (): AuthSnapshot => {
return this.currentSnapshot;
};
/**
* For useSyncExternalStore - returns SSR/hydration state
* React automatically uses this during SSR and hydration
*/
getServerSnapshot = (): AuthSnapshot => {
// If we have a server snapshot, ALWAYS return it
// React will switch to getSnapshot after hydration
return this.serverSnapshot || this.currentSnapshot;
};
setServerSnapshot(snapshot: AuthSnapshot): void {
this.serverSnapshot = snapshot;
}
/**
* For useSyncExternalStore - returns current client state
*/
getSnapshot = (): Readonly<AuthSnapshot> => {
return this.currentSnapshot;
};
/**
* For useSyncExternalStore - returns SSR/hydration state
* React automatically uses this during SSR and hydration
*/
getServerSnapshot = (): Readonly<AuthSnapshot> => {
// If we have a server snapshot, ALWAYS return it
// React will switch to getSnapshot after hydration
return this.serverSnapshot || this.currentSnapshot;
};
🤖 Prompt for AI Agents
In packages/react/src/stores/authStore.ts around lines 37 to 56, the public
methods getSnapshot, getServerSnapshot and setServerSnapshot lack explicit
return type annotations; update their signatures to include explicit return
types (use Readonly<AuthSnapshot> for getters and void for the setter) so the
public API is typed explicitly and prevents consumers from mutating the returned
snapshot.


subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};

private updateFromClerk(clerk: IsomorphicClerk) {
const newSnapshot = this.transformClerkState(clerk);

// Only notify if actually changed (reference equality is fine here)
if (newSnapshot !== this.currentSnapshot) {
this.currentSnapshot = newSnapshot;
this.notifyListeners();
}
}
Comment on lines +63 to +71
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Reference equality check always fails—listeners notified on every update.

Line 67 checks newSnapshot !== this.currentSnapshot, but transformClerkState always creates a new object (line 64), so reference equality will always fail. This causes listeners to be notified on every Clerk update, even when auth state hasn't changed, leading to unnecessary re-renders.

Implement shallow equality checking to only notify when actual values change:

+  private hasChanged(prev: AuthSnapshot, next: AuthSnapshot): boolean {
+    return (
+      prev.actor !== next.actor ||
+      prev.factorVerificationAge !== next.factorVerificationAge ||
+      prev.orgId !== next.orgId ||
+      prev.orgPermissions !== next.orgPermissions ||
+      prev.orgRole !== next.orgRole ||
+      prev.orgSlug !== next.orgSlug ||
+      prev.sessionClaims !== next.sessionClaims ||
+      prev.sessionId !== next.sessionId ||
+      prev.sessionStatus !== next.sessionStatus ||
+      prev.userId !== next.userId
+    );
+  }
+
   private updateFromClerk(clerk: IsomorphicClerk): void {
     const newSnapshot = this.transformClerkState(clerk);
 
-    if (newSnapshot !== this.currentSnapshot) {
+    if (this.hasChanged(this.currentSnapshot, newSnapshot)) {
       this.currentSnapshot = newSnapshot;
       this.notifyListeners();
     }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private updateFromClerk(clerk: IsomorphicClerk) {
const newSnapshot = this.transformClerkState(clerk);
// Only notify if actually changed (reference equality is fine here)
if (newSnapshot !== this.currentSnapshot) {
this.currentSnapshot = newSnapshot;
this.notifyListeners();
}
}
private hasChanged(prev: AuthSnapshot, next: AuthSnapshot): boolean {
return (
prev.actor !== next.actor ||
prev.factorVerificationAge !== next.factorVerificationAge ||
prev.orgId !== next.orgId ||
prev.orgPermissions !== next.orgPermissions ||
prev.orgRole !== next.orgRole ||
prev.orgSlug !== next.orgSlug ||
prev.sessionClaims !== next.sessionClaims ||
prev.sessionId !== next.sessionId ||
prev.sessionStatus !== next.sessionStatus ||
prev.userId !== next.userId
);
}
private updateFromClerk(clerk: IsomorphicClerk): void {
const newSnapshot = this.transformClerkState(clerk);
// Only notify if actually changed (reference equality is fine here)
if (this.hasChanged(this.currentSnapshot, newSnapshot)) {
this.currentSnapshot = newSnapshot;
this.notifyListeners();
}
}
🤖 Prompt for AI Agents
In packages/react/src/stores/authStore.ts around lines 63 to 71, the current
reference equality check always fails because transformClerkState creates a new
object each call, causing listeners to be notified on every update; replace the
reference check with a shallow equality comparison of the snapshot's top-level
properties (compare primitives and object references for the same keys) and only
assign this.currentSnapshot and call notifyListeners if the shallow comparison
detects any differences; either reuse an existing shallowEqual utility or add a
small helper here that iterates keys and compares values, then use its result
instead of !==.


private transformClerkState(clerk: IsomorphicClerk): AuthSnapshot {
const orgId = clerk.organization?.id;
const membership = clerk.organization
? clerk.user?.organizationMemberships?.find(om => om.organization.id === orgId)
: undefined;

return {
actor: clerk.session?.actor,
factorVerificationAge: clerk.session?.factorVerificationAge ?? null,
orgId,
orgPermissions: membership?.permissions,
orgRole: membership?.role,
orgSlug: clerk.organization?.slug,
sessionClaims: clerk.session?.lastActiveToken?.jwt?.claims,
sessionId: clerk.session?.id,
sessionStatus: clerk.session?.status,
userId: clerk.user?.id,
};
}

private createEmptySnapshot(): AuthSnapshot {
return {
actor: undefined,
factorVerificationAge: null,
orgId: undefined,
orgPermissions: undefined,
orgRole: undefined,
orgSlug: undefined,
sessionClaims: undefined,
sessionId: undefined,
sessionStatus: undefined,
userId: undefined,
};
}

private notifyListeners() {
this.listeners.forEach(listener => listener());
}
}

export const authStore = new AuthStore();
3 changes: 3 additions & 0 deletions packages/shared/src/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const prefixWithOrg = (value: string) => value.replace(/^(org:)*/, 'org:');
/**
* Checks if a user has the required organization-level authorization.
* Verifies if the user has the specified role or permission within their organization.
*
* @returns null, if unable to determine due to missing data or unspecified role/permission.
*/
const checkOrgAuthorization: CheckOrgAuthorization = (params, options) => {
Expand Down Expand Up @@ -162,6 +163,7 @@ const validateReverificationConfig = (config: ReverificationConfig | undefined |
* Evaluates if the user meets re-verification authentication requirements.
* Compares the user's factor verification ages against the specified maxAge.
* Handles different verification levels (first factor, second factor, multi-factor).
*
* @returns null, if requirements or verification data are missing.
*/
const checkReverificationAuthorization: CheckReverificationAuthorization = (params, { factorVerificationAge }) => {
Expand Down Expand Up @@ -237,6 +239,7 @@ type AuthStateOptions = {
/**
* Shared utility function that centralizes auth state resolution logic,
* preventing duplication across different packages.
*
* @internal
*/
const resolveAuthState = ({
Expand Down
Loading