diff --git a/.changeset/upset-words-fly.md b/.changeset/upset-words-fly.md new file mode 100644 index 00000000000..035ff88fda2 --- /dev/null +++ b/.changeset/upset-words-fly.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fix session setting immediately after sign in diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 1cccffbbcd8..79ffc0c71c4 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -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] })); @@ -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(); + }); }); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 72213936304..d59fc82fb18 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1443,7 +1443,7 @@ export class Clerk implements ClerkInterface { return; } - if (newSession?.status !== 'pending') { + if (newSession?.status !== 'pending' && (this.session?.id !== newSession?.id || shouldSwitchOrganization)) { this.#setTransitiveState(); } @@ -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 @@ -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();