@@ -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 (
0 commit comments