Skip to content
Draft
5 changes: 5 additions & 0 deletions .changeset/upset-words-fly.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Fix session setting immediately after sign in
126 changes: 126 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,92 @@ describe('Clerk singleton', () => {
expect(mockSession.touch).toHaveBeenCalled();
});

it('does not call setTransitiveState when session ID stays the same during setActive with navigation', async () => {
const mockSession1 = {
id: 'session_1',
status: 'active',
user: { id: 'user_1' },
touch: vi.fn(() => Promise.resolve()),
getToken: vi.fn(() => Promise.resolve('token_1')),
lastActiveToken: { getRawString: () => 'token_1' },
};

mockClientFetch.mockReturnValue(
Promise.resolve({ signedInSessions: [mockSession1], isEligibleForTouch: () => false }),
);

const sut = new Clerk(productionPublishableKey);
await sut.load();

await sut.setActive({ session: mockSession1 as any as ActiveSessionResource });
expect(sut.session?.id).toBe('session_1');

// Track if session becomes undefined (transitive state)
let sessionBecameUndefined = false;
sut.addListener(state => {
if (state.session === undefined) {
sessionBecameUndefined = true;
}
});

// Call setActive with SAME session ID and redirectUrl
// Should NOT trigger setTransitiveState since session ID is not changing
await sut.setActive({
session: mockSession1 as any as ActiveSessionResource,
redirectUrl: '/dashboard',
});

expect(sessionBecameUndefined).toBe(false);
expect(sut.session?.id).toBe('session_1');
});

it('calls setTransitiveState when session ID changes during setActive with navigation', async () => {
const mockSession1 = {
id: 'session_1',
status: 'active',
user: { id: 'user_1' },
touch: vi.fn(() => Promise.resolve()),
getToken: vi.fn(() => Promise.resolve('token_1')),
lastActiveToken: { getRawString: () => 'token_1' },
};

const mockSession2 = {
id: 'session_2',
status: 'active',
user: { id: 'user_2' },
touch: vi.fn(() => Promise.resolve()),
getToken: vi.fn(() => Promise.resolve('token_2')),
lastActiveToken: { getRawString: () => 'token_2' },
};

mockClientFetch.mockReturnValue(
Promise.resolve({ signedInSessions: [mockSession1, mockSession2], isEligibleForTouch: () => false }),
);

const sut = new Clerk(productionPublishableKey);
await sut.load();

await sut.setActive({ session: mockSession1 as any as ActiveSessionResource });
expect(sut.session?.id).toBe('session_1');

// Track if session becomes undefined (transitive state)
let sessionBecameUndefined = false;
sut.addListener(state => {
if (state.session === undefined) {
sessionBecameUndefined = true;
}
});

// Call setActive with different session ID and redirectUrl
await sut.setActive({
session: mockSession2 as any as ActiveSessionResource,
redirectUrl: '/dashboard',
});

expect(sessionBecameUndefined).toBe(true);
expect(sut.session?.id).toBe('session_2');
});

it('sets __session and __client_uat cookie before calling __unstable__onBeforeSetActive', async () => {
mockSession.touch.mockReturnValueOnce(Promise.resolve());
mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] }));
Expand Down Expand Up @@ -2507,5 +2593,45 @@ describe('Clerk singleton', () => {
expect(mockOnAfterSetActive).toHaveBeenCalledTimes(1);
});
});

it('sets session after sign-in when touch() response triggers updateClient', () => {
const mockSession = {
id: 'session_1',
status: 'active',
user: { id: 'user_1' },
lastActiveToken: { getRawString: () => 'token_1' },
};

const mockInitialClient = {
sessions: [],
signedInSessions: [],
lastActiveSessionId: null,
};

const mockClientWithSession = {
sessions: [mockSession],
signedInSessions: [mockSession],
lastActiveSessionId: 'session_1',
};

const sut = new Clerk(productionPublishableKey);

sut.updateClient(mockInitialClient as any);
expect(sut.session).toBe(null);

const eventBusSpy = vi.spyOn(eventBus, 'emit');

sut.updateClient(mockClientWithSession as any);

expect(sut.session).toBeDefined();
expect(sut.session?.id).toBe('session_1');
expect(sut.session?.status).toBe('active');

expect(eventBusSpy).toHaveBeenCalledWith(events.TokenUpdate, {
token: mockSession.lastActiveToken,
});

eventBusSpy.mockRestore();
});
});
});
16 changes: 14 additions & 2 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1443,7 +1443,7 @@ export class Clerk implements ClerkInterface {
return;
}

if (newSession?.status !== 'pending') {
if (newSession?.status !== 'pending' && (this.session?.id !== newSession?.id || shouldSwitchOrganization)) {
Copy link
Member

Choose a reason for hiding this comment

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

what problem is this addressing? The transitive state sets undefined, not null. If we're not entirely sure, I would recommend that we remove this.

Copy link
Member

Choose a reason for hiding this comment

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

This is unclear to me as well. Could this cause a chain of events that result to null eventually ?

Copy link
Member Author

Choose a reason for hiding this comment

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

This check prevents calling #setTransitiveState() unnecessarily. It ensures we only clear the transitive state (which sets the session to undefined) when the session is changing or the org is changing.

Without it you get unnecessary session → undefined → session when nothing changed

Copy link

Choose a reason for hiding this comment

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

Is this what triggered during the sign-in, causing session to be undefined when the updateClient call ran, causing it to not update the session?

So this PR kind of fixes the sign-in bug from two separate angles?

Copy link
Member Author

Choose a reason for hiding this comment

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

@Ephem Exactly! Without this, every navigation with setActive() would clear the session unnecessarily, opening the window for a race condition where updateClient() would run before setActive() can set and emit the session

this.#setTransitiveState();
}

Expand Down Expand Up @@ -2382,7 +2382,9 @@ export class Clerk implements ClerkInterface {
}

updateClient = (newClient: ClientResource): void => {
if (!this.client) {
const isFirstClientSet = !this.client;

if (isFirstClientSet) {
// This is the first time client is being
// set, so we also need to set session
const session = this.#options.selectInitialSession
Expand Down Expand Up @@ -2419,6 +2421,16 @@ export class Clerk implements ClerkInterface {
);
}
eventBus.emit(events.TokenUpdate, { token: this.session?.lastActiveToken });
} else if (!isFirstClientSet && newClient.sessions?.length > 0) {
// Handles the case where updateClient() is called (e.g., from a touch() response) with session data,
// but this.session is falsy. This commonly occurs after sign-in when the touch() response
// includes fresh client data before setActive() completes, preventing a session null flash.
const session = this.#options.selectInitialSession
? this.#options.selectInitialSession(newClient)
: this.#defaultSession(newClient);
this.#setAccessors(session);

eventBus.emit(events.TokenUpdate, { token: session?.lastActiveToken || null });
}

this.#emit();
Expand Down
Loading