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
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
33 changes: 33 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,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(), {
Copy link

Choose a reason for hiding this comment

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

This test is passing even with the existing logic for me.

I think the prerequisite for the bug to happen is that we explicitly pass in initialAuthState (like we do from usePromisifiedAuth, which is the reason this is failing in Next).

For a test that fails with the old code and pass with the new (might want to update the test description too):

Suggested change
const { result } = renderHook(() => useAuth(), {
const mockInitialAuthState = {
sessionId: null,
userId: null,
};
// Test that we don't fall back to the initial state when
// isomorphicClerk is loaded but in transitive state
const { result } = renderHook(() => useAuth(mockInitialAuthState), {

We could also keep this test as is since it covers a new case we didn't have coverage for, and add a new test with the above.

Overall, I think several tests here might benefit from the test approach you are using here btw, useAuth is the main public API so we should be testing that directly instead of useDerivedAuth.

(One could also argue we should move/duplicate this test to usePromisifiedAuth since that's really the broken public API here, but I don't think we should do that right now)

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();
});
});

describe('useDerivedAuth', () => {
Expand Down
8 changes: 2 additions & 6 deletions packages/react/src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,9 @@ export const useAuth = (initialAuthStateOrOptions: UseAuthOptions = {}): UseAuth
const initialAuthState = rest as any;

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

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

Choose a reason for hiding this comment

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

Oh, so previously it was falling back to the initial auth state when sessionId and userId was undefined? This was likely fine in CSR apps as the initial state there would be isLoading: true, but not so fine in SSRd apps.

I think you might have stumbled on the root cause of the sign in bug here too! When signing in, the initial state would be sessionId: null, user is signed out. When sign in transitions to the transitive state, sessionId and userId becomes undefined, so this would fall back to the initial state, which is null.

Copy link

Choose a reason for hiding this comment

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

Yep, this PR fixes the sign in bug independently of the other one:

CleanShot 2025-11-06 at 10 53 06


const isomorphicClerk = useIsomorphicClerkContext();
const authContext = !isomorphicClerk.loaded && initialAuthState ? initialAuthState : authContextFromHook;
Copy link

Choose a reason for hiding this comment

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

I just want to verify my understanding here. The reason this is safe is because:

  • isomorphicClerk changes reference when status changes, so it's reactive specifically to that value
  • IsomorphicClerk.hydrateClerkJS first emits the state update, and then emits the status update
    • Meaning authContextFromHook is guaranteed to already be set when loaded changes from false->true?
  • isomorphicClerk.loaded never goes true->false

Correct? (These are a lot of hidden assumptions/guarantees btw)

What happens if we want to start making the auth state updates in transitions as per #6905? I think it might be possible we get a race condition where loaded: true but we don't have the authContextFromHook yet? I don't think we need or should figure that out now, just wanted to point it out as some complexity to watch for. Solution might be to do the entire hydration as a transition.

@panteliselef FYI ☝️

Copy link
Member

Choose a reason for hiding this comment

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

You are spot on regarding the assumptions. We can of course change how things work if we want to support transitions.


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

Expand Down
Loading