Skip to content

Commit e56696d

Browse files
committed
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.
1 parent 4ddb821 commit e56696d

2 files changed

Lines changed: 125 additions & 1 deletion

File tree

packages/clerk-js/src/core/__tests__/clerk.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,123 @@ describe('Clerk singleton', () => {
284284
});
285285
});
286286

287+
it('does not emit intermediate state to listeners when updateClient is called during setActive', async () => {
288+
const orgA = { id: 'org_a', slug: 'org-a', name: 'Org A' };
289+
const orgB = { id: 'org_b', slug: 'org-b', name: 'Org B' };
290+
291+
const mockSessionWithOrgs = {
292+
id: 'sess_1',
293+
status: 'active' as const,
294+
lastActiveOrganizationId: orgA.id,
295+
user: {
296+
organizationMemberships: [
297+
{ id: 'orgmem_a', organization: orgA },
298+
{ id: 'orgmem_b', organization: orgB },
299+
],
300+
},
301+
touch: vi.fn(),
302+
getToken: vi.fn(),
303+
lastActiveToken: { getRawString: () => 'mocked-token' },
304+
};
305+
306+
mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSessionWithOrgs] }));
307+
const sut = new Clerk(productionPublishableKey);
308+
await sut.load();
309+
310+
// Verify initial state has orgA
311+
expect(sut.organization?.id).toBe(orgA.id);
312+
313+
// Simulate what happens in production: touch()'s API response triggers
314+
// updateClient via BaseResource._baseFetch client piggybacking.
315+
// The updated client from the server reflects the new org.
316+
mockSessionWithOrgs.touch.mockImplementationOnce(() => {
317+
const updatedSession = {
318+
...mockSessionWithOrgs,
319+
lastActiveOrganizationId: orgB.id,
320+
};
321+
sut.updateClient({
322+
signedInSessions: [updatedSession],
323+
} as any);
324+
return Promise.resolve();
325+
});
326+
mockSessionWithOrgs.getToken.mockReturnValue(Promise.resolve('mocked-token'));
327+
328+
// Track all emissions to listeners
329+
const emissions: Array<{ orgId: string | null | undefined }> = [];
330+
sut.addListener(({ organization }) => {
331+
emissions.push({ orgId: organization?.id ?? (organization as any) });
332+
});
333+
334+
const navigate = vi.fn();
335+
await sut.setActive({ organization: orgB.id, navigate });
336+
337+
// The listener should never have seen orgB before transitive state (undefined).
338+
// Without the fix, emissions would be: [orgB, undefined, orgB]
339+
// With the fix, emissions should be: [undefined, orgB]
340+
const orgBBeforeTransitive = emissions.findIndex((e, i) => {
341+
return e.orgId === orgB.id && emissions.slice(i + 1).some(later => later.orgId === undefined);
342+
});
343+
expect(orgBBeforeTransitive).toBe(-1);
344+
345+
// Verify transitive state (undefined) appeared before the final orgB state
346+
const transitiveIndex = emissions.findIndex(e => e.orgId === undefined);
347+
const finalOrgBIndex = emissions.findLastIndex(e => e.orgId === orgB.id);
348+
expect(transitiveIndex).toBeGreaterThanOrEqual(0);
349+
expect(finalOrgBIndex).toBeGreaterThan(transitiveIndex);
350+
});
351+
352+
it('does not emit intermediate state when updateClient is called during setActive without navigation', async () => {
353+
const orgA = { id: 'org_a', slug: 'org-a', name: 'Org A' };
354+
const orgB = { id: 'org_b', slug: 'org-b', name: 'Org B' };
355+
356+
const mockSessionWithOrgs = {
357+
id: 'sess_1',
358+
status: 'active' as const,
359+
lastActiveOrganizationId: orgA.id,
360+
user: {
361+
organizationMemberships: [
362+
{ id: 'orgmem_a', organization: orgA },
363+
{ id: 'orgmem_b', organization: orgB },
364+
],
365+
},
366+
touch: vi.fn(),
367+
getToken: vi.fn(),
368+
lastActiveToken: { getRawString: () => 'mocked-token' },
369+
};
370+
371+
mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSessionWithOrgs] }));
372+
const sut = new Clerk(productionPublishableKey);
373+
await sut.load();
374+
375+
expect(sut.organization?.id).toBe(orgA.id);
376+
377+
mockSessionWithOrgs.touch.mockImplementationOnce(() => {
378+
const updatedSession = {
379+
...mockSessionWithOrgs,
380+
lastActiveOrganizationId: orgB.id,
381+
};
382+
sut.updateClient({
383+
signedInSessions: [updatedSession],
384+
} as any);
385+
return Promise.resolve();
386+
});
387+
mockSessionWithOrgs.getToken.mockReturnValue(Promise.resolve('mocked-token'));
388+
389+
// Track emissions after initial state
390+
const emissions: Array<{ orgId: string | null | undefined }> = [];
391+
sut.addListener(({ organization }) => {
392+
emissions.push({ orgId: organization?.id ?? (organization as any) });
393+
}, { skipInitialEmit: true });
394+
395+
// No navigate or redirectUrl — no transitive state
396+
await sut.setActive({ organization: orgB.id });
397+
398+
// Without the fix, emissions would be: [orgB (from updateClient), orgB (from #updateAccessors)]
399+
// With the fix, there should be exactly one emission with the final state
400+
expect(emissions).toHaveLength(1);
401+
expect(emissions[0].orgId).toBe(orgB.id);
402+
});
403+
287404
it('redirects the user to the /v1/client/touch endpoint if the cookie_expires_at is less than 8 days away', async () => {
288405
mockSession.touch.mockReturnValue(Promise.resolve());
289406
mockClientFetch.mockReturnValue(

packages/clerk-js/src/core/clerk.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2702,7 +2702,14 @@ export class Clerk implements ClerkInterface {
27022702
eventBus.emit(events.TokenUpdate, { token: this.session?.lastActiveToken });
27032703
}
27042704

2705-
this.#emit();
2705+
// During setActive, we suppress intermediate emissions from piggybacked client
2706+
// updates (e.g. from touch). setActive will emit the final state itself via
2707+
// #setTransitiveState or #updateAccessors once the transition is complete.
2708+
// Without this guard, useSyncExternalStore causes a synchronous re-render with
2709+
// partially-updated state (new orgId before transitive state is set).
2710+
if (!this.__internal_setActiveInProgress) {
2711+
this.#emit();
2712+
}
27062713
};
27072714

27082715
get __internal_environment(): EnvironmentResource | null | undefined {

0 commit comments

Comments
 (0)