From e56696db014348958518b10565278a8ca647eccb Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 10 Feb 2026 16:10:45 -0600 Subject: [PATCH 1/3] fix(clerk-js): Suppress intermediate emissions from updateClient during setActive During setActive, the touch() call triggers a piggybacked client update via BaseResource._baseFetch, which calls updateClient() and emits state to React before setTransitiveState() runs. With useSyncExternalStore (unlike the old useState+addListener approach), each emission causes a synchronous re-render. This exposed the new orgId to components before the transitive state (undefined) was set, causing flickering and stale data issues during org switches. Gate the #emit() in updateClient with the existing __internal_setActiveInProgress flag so intermediate state from piggybacked client updates is suppressed. setActive emits the final state itself via #setTransitiveState or #updateAccessors. --- .../clerk-js/src/core/__tests__/clerk.test.ts | 117 ++++++++++++++++++ packages/clerk-js/src/core/clerk.ts | 9 +- 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index b467a476cad..b84145976d5 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -284,6 +284,123 @@ describe('Clerk singleton', () => { }); }); + it('does not emit intermediate state to listeners when updateClient is called during setActive', async () => { + const orgA = { id: 'org_a', slug: 'org-a', name: 'Org A' }; + const orgB = { id: 'org_b', slug: 'org-b', name: 'Org B' }; + + const mockSessionWithOrgs = { + id: 'sess_1', + status: 'active' as const, + lastActiveOrganizationId: orgA.id, + user: { + organizationMemberships: [ + { id: 'orgmem_a', organization: orgA }, + { id: 'orgmem_b', organization: orgB }, + ], + }, + touch: vi.fn(), + getToken: vi.fn(), + lastActiveToken: { getRawString: () => 'mocked-token' }, + }; + + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSessionWithOrgs] })); + const sut = new Clerk(productionPublishableKey); + await sut.load(); + + // Verify initial state has orgA + expect(sut.organization?.id).toBe(orgA.id); + + // Simulate what happens in production: touch()'s API response triggers + // updateClient via BaseResource._baseFetch client piggybacking. + // The updated client from the server reflects the new org. + mockSessionWithOrgs.touch.mockImplementationOnce(() => { + const updatedSession = { + ...mockSessionWithOrgs, + lastActiveOrganizationId: orgB.id, + }; + sut.updateClient({ + signedInSessions: [updatedSession], + } as any); + return Promise.resolve(); + }); + mockSessionWithOrgs.getToken.mockReturnValue(Promise.resolve('mocked-token')); + + // Track all emissions to listeners + const emissions: Array<{ orgId: string | null | undefined }> = []; + sut.addListener(({ organization }) => { + emissions.push({ orgId: organization?.id ?? (organization as any) }); + }); + + const navigate = vi.fn(); + await sut.setActive({ organization: orgB.id, navigate }); + + // The listener should never have seen orgB before transitive state (undefined). + // Without the fix, emissions would be: [orgB, undefined, orgB] + // With the fix, emissions should be: [undefined, orgB] + const orgBBeforeTransitive = emissions.findIndex((e, i) => { + return e.orgId === orgB.id && emissions.slice(i + 1).some(later => later.orgId === undefined); + }); + expect(orgBBeforeTransitive).toBe(-1); + + // Verify transitive state (undefined) appeared before the final orgB state + const transitiveIndex = emissions.findIndex(e => e.orgId === undefined); + const finalOrgBIndex = emissions.findLastIndex(e => e.orgId === orgB.id); + expect(transitiveIndex).toBeGreaterThanOrEqual(0); + expect(finalOrgBIndex).toBeGreaterThan(transitiveIndex); + }); + + it('does not emit intermediate state when updateClient is called during setActive without navigation', async () => { + const orgA = { id: 'org_a', slug: 'org-a', name: 'Org A' }; + const orgB = { id: 'org_b', slug: 'org-b', name: 'Org B' }; + + const mockSessionWithOrgs = { + id: 'sess_1', + status: 'active' as const, + lastActiveOrganizationId: orgA.id, + user: { + organizationMemberships: [ + { id: 'orgmem_a', organization: orgA }, + { id: 'orgmem_b', organization: orgB }, + ], + }, + touch: vi.fn(), + getToken: vi.fn(), + lastActiveToken: { getRawString: () => 'mocked-token' }, + }; + + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSessionWithOrgs] })); + const sut = new Clerk(productionPublishableKey); + await sut.load(); + + expect(sut.organization?.id).toBe(orgA.id); + + mockSessionWithOrgs.touch.mockImplementationOnce(() => { + const updatedSession = { + ...mockSessionWithOrgs, + lastActiveOrganizationId: orgB.id, + }; + sut.updateClient({ + signedInSessions: [updatedSession], + } as any); + return Promise.resolve(); + }); + mockSessionWithOrgs.getToken.mockReturnValue(Promise.resolve('mocked-token')); + + // Track emissions after initial state + const emissions: Array<{ orgId: string | null | undefined }> = []; + sut.addListener(({ organization }) => { + emissions.push({ orgId: organization?.id ?? (organization as any) }); + }, { skipInitialEmit: true }); + + // No navigate or redirectUrl — no transitive state + await sut.setActive({ organization: orgB.id }); + + // Without the fix, emissions would be: [orgB (from updateClient), orgB (from #updateAccessors)] + // With the fix, there should be exactly one emission with the final state + expect(emissions).toHaveLength(1); + expect(emissions[0].orgId).toBe(orgB.id); + }); + it('redirects the user to the /v1/client/touch endpoint if the cookie_expires_at is less than 8 days away', async () => { mockSession.touch.mockReturnValue(Promise.resolve()); mockClientFetch.mockReturnValue( diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 0ddb68921bf..34cbdf742c0 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -2702,7 +2702,14 @@ export class Clerk implements ClerkInterface { eventBus.emit(events.TokenUpdate, { token: this.session?.lastActiveToken }); } - this.#emit(); + // During setActive, we suppress intermediate emissions from piggybacked client + // updates (e.g. from touch). setActive will emit the final state itself via + // #setTransitiveState or #updateAccessors once the transition is complete. + // Without this guard, useSyncExternalStore causes a synchronous re-render with + // partially-updated state (new orgId before transitive state is set). + if (!this.__internal_setActiveInProgress) { + this.#emit(); + } }; get __internal_environment(): EnvironmentResource | null | undefined { From d3a7b54fdcc85ac657890f0369c0aba5e9e4562b Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 10 Feb 2026 16:13:01 -0600 Subject: [PATCH 2/3] chore: add changeset --- .changeset/fix-setactive-intermediate-emission.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-setactive-intermediate-emission.md diff --git a/.changeset/fix-setactive-intermediate-emission.md b/.changeset/fix-setactive-intermediate-emission.md new file mode 100644 index 00000000000..8a508d152fa --- /dev/null +++ b/.changeset/fix-setactive-intermediate-emission.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fix premature org state emission during `setActive` org switching From f47d344ac8def36dea6f345653a68a61c806f41a Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 10 Feb 2026 16:23:40 -0600 Subject: [PATCH 3/3] fix: format clerk.test.ts --- packages/clerk-js/src/core/__tests__/clerk.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index b84145976d5..e67cbf9d4ff 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -388,9 +388,12 @@ describe('Clerk singleton', () => { // Track emissions after initial state const emissions: Array<{ orgId: string | null | undefined }> = []; - sut.addListener(({ organization }) => { - emissions.push({ orgId: organization?.id ?? (organization as any) }); - }, { skipInitialEmit: true }); + sut.addListener( + ({ organization }) => { + emissions.push({ orgId: organization?.id ?? (organization as any) }); + }, + { skipInitialEmit: true }, + ); // No navigate or redirectUrl — no transitive state await sut.setActive({ organization: orgB.id });