From 9b81188afa0c6530b15f657364cea8dee952de24 Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 30 Oct 2025 13:41:42 -0500 Subject: [PATCH 1/5] feat: remove hydrateCache --- packages/clerk-js/src/core/resources/Session.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index c2b5760fd58..c531c5bed87 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -67,7 +67,6 @@ export class Session extends BaseResource implements SessionResource { super(); this.fromJSON(data); - this.#hydrateCache(this.lastActiveToken); } end = (): Promise => { @@ -130,15 +129,6 @@ export class Session extends BaseResource implements SessionResource { })(params); }; - #hydrateCache = (token: TokenResource | null) => { - if (token) { - SessionTokenCache.set({ - tokenId: this.#getCacheId(), - tokenResolver: Promise.resolve(token), - }); - } - }; - // If it's a session token, retrieve it with their session id, otherwise it's a jwt template token // and retrieve it using the session id concatenated with the jwt template name. // e.g. session id is 'sess_abc12345' and jwt template name is 'haris' From 5d22353711e895afa0b3ac8af3dccad74e4ffc26 Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 30 Oct 2025 13:41:47 -0500 Subject: [PATCH 2/5] feat: remove hydrateCache --- packages/clerk-js/src/core/resources/Session.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index c531c5bed87..f08fb2ab721 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -188,7 +188,7 @@ export class Session extends BaseResource implements SessionResource { body: { ...config, strategy: factor.strategy, - } as any, + }, }) )?.response as unknown as SessionVerificationJSON; @@ -214,7 +214,7 @@ export class Session extends BaseResource implements SessionResource { await BaseResource._fetch({ method: 'POST', path: `/client/sessions/${this.id}/verify/attempt_first_factor`, - body: { ...config, strategy: attemptFactor.strategy } as any, + body: { ...config, strategy: attemptFactor.strategy }, }) )?.response as unknown as SessionVerificationJSON; From 627d87259ae0fe35ddf8d1e6a5b36c4d57f9e780 Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 30 Oct 2025 14:13:39 -0500 Subject: [PATCH 3/5] wip --- .../src/core/__tests__/tokenCache.test.ts | 35 +++++++ .../clerk-js/src/core/resources/Session.ts | 20 +++- .../core/resources/__tests__/Session.test.ts | 95 ++++++++++++++++--- 3 files changed, 136 insertions(+), 14 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index accf272dc66..58bf9252316 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -277,6 +277,41 @@ describe('SessionTokenCache', () => { // Critical: postMessage should NOT be called when handling a broadcast expect(mockBroadcastChannel.postMessage).not.toHaveBeenCalled(); }); + + it('always broadcasts regardless of cache state', async () => { + mockBroadcastChannel.postMessage.mockClear(); + + const tokenId = 'session_456'; + const tokenResolver = Promise.resolve( + new Token({ + id: tokenId, + jwt: mockJwt, + object: 'token', + }) as TokenResource, + ); + + SessionTokenCache.set({ tokenId, tokenResolver }); + await Promise.resolve(); + + expect(mockBroadcastChannel.postMessage).toHaveBeenCalledTimes(1); + const firstCall = mockBroadcastChannel.postMessage.mock.calls[0][0]; + expect(firstCall.tokenId).toBe(tokenId); + + mockBroadcastChannel.postMessage.mockClear(); + + const tokenResolver2 = Promise.resolve( + new Token({ + id: tokenId, + jwt: mockJwt, + object: 'token', + }) as TokenResource, + ); + + SessionTokenCache.set({ tokenId, tokenResolver: tokenResolver2 }); + await Promise.resolve(); + + expect(mockBroadcastChannel.postMessage).toHaveBeenCalledTimes(1); + }); }); describe('token expiration with absolute time', () => { diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index f08fb2ab721..b774b12730f 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -67,6 +67,7 @@ export class Session extends BaseResource implements SessionResource { super(); this.fromJSON(data); + this.#hydrateCache(this.lastActiveToken); } end = (): Promise => { @@ -129,6 +130,21 @@ export class Session extends BaseResource implements SessionResource { })(params); }; + #hydrateCache = (token: TokenResource | null) => { + if (!token) { + return; + } + + const tokenId = this.#getCacheId(); + const existing = SessionTokenCache.get({ tokenId }); + if (!existing) { + SessionTokenCache.set({ + tokenId, + tokenResolver: Promise.resolve(token), + }); + } + }; + // If it's a session token, retrieve it with their session id, otherwise it's a jwt template token // and retrieve it using the session id concatenated with the jwt template name. // e.g. session id is 'sess_abc12345' and jwt template name is 'haris' @@ -188,7 +204,7 @@ export class Session extends BaseResource implements SessionResource { body: { ...config, strategy: factor.strategy, - }, + } as any, }) )?.response as unknown as SessionVerificationJSON; @@ -214,7 +230,7 @@ export class Session extends BaseResource implements SessionResource { await BaseResource._fetch({ method: 'POST', path: `/client/sessions/${this.id}/verify/attempt_first_factor`, - body: { ...config, strategy: attemptFactor.strategy }, + body: { ...config, strategy: attemptFactor.strategy } as any, }) )?.response as unknown as SessionVerificationJSON; diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 59569d0b207..aef82673054 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -35,7 +35,7 @@ describe('Session', () => { beforeEach(() => { dispatchSpy = vi.spyOn(eventBus, 'emit'); - BaseResource.clerk = clerkMock() as any; + BaseResource.clerk = clerkMock(); }); afterEach(() => { @@ -76,7 +76,7 @@ describe('Session', () => { it('hydrates token cache from lastActiveToken', async () => { BaseResource.clerk = clerkMock({ organization: new Organization({ id: 'activeOrganization' } as OrganizationJSON), - }) as any; + }); const session = new Session({ status: 'active', @@ -100,10 +100,81 @@ describe('Session', () => { expect(dispatchSpy).toHaveBeenCalledTimes(2); }); + it('does not re-cache token when Session is reconstructed with same token', async () => { + BaseResource.clerk = clerkMock({ + organization: new Organization({ id: 'activeOrganization' } as OrganizationJSON), + }); + + SessionTokenCache.clear(); + + const session1 = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: 'activeOrganization', + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + expect(SessionTokenCache.size()).toBe(1); + const cachedEntry1 = SessionTokenCache.get({ tokenId: 'session_1::activeOrganization' }); + expect(cachedEntry1).toBeDefined(); + + const session2 = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: 'activeOrganization', + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + expect(SessionTokenCache.size()).toBe(1); + + const token1 = await session1.getToken(); + const token2 = await session2.getToken(); + + expect(token1).toBe(token2); + expect(token1).toEqual(mockJwt); + expect(BaseResource.clerk.getFapiClient().request).not.toHaveBeenCalled(); + }); + + it('caches token from cookie during degraded mode recovery', async () => { + BaseResource.clerk = clerkMock(); + + SessionTokenCache.clear(); + + const sessionFromCookie = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + expect(SessionTokenCache.size()).toBe(1); + const cachedEntry = SessionTokenCache.get({ tokenId: 'session_1' }); + expect(cachedEntry).toBeDefined(); + + const token = await sessionFromCookie.getToken(); + expect(token).toEqual(mockJwt); + expect(BaseResource.clerk.getFapiClient().request).not.toHaveBeenCalled(); + }); + it('dispatches token:update event on getToken with active organization', async () => { BaseResource.clerk = clerkMock({ organization: new Organization({ id: 'activeOrganization' } as OrganizationJSON), - }) as any; + }); const session = new Session({ status: 'active', @@ -138,7 +209,7 @@ describe('Session', () => { it('does not dispatch token:update if template is provided', async () => { BaseResource.clerk = clerkMock({ organization: new Organization({ id: 'activeOrganization' } as OrganizationJSON), - }) as any; + }); const session = new Session({ status: 'active', @@ -159,7 +230,7 @@ describe('Session', () => { it('dispatches token:update when provided organization ID matches current active organization', async () => { BaseResource.clerk = clerkMock({ organization: new Organization({ id: 'activeOrganization' } as OrganizationJSON), - }) as any; + }); const session = new Session({ status: 'active', @@ -178,7 +249,7 @@ describe('Session', () => { }); it('does not dispatch token:update when provided organization ID does not match current active organization', async () => { - BaseResource.clerk = clerkMock() as any; + BaseResource.clerk = clerkMock(); const session = new Session({ status: 'active', @@ -240,7 +311,7 @@ describe('Session', () => { it(`uses the current session's lastActiveOrganizationId by default, not clerk.organization.id`, async () => { BaseResource.clerk = clerkMock({ organization: new Organization({ id: 'oldActiveOrganization' } as OrganizationJSON), - }) as any; + }); const session = new Session({ status: 'active', @@ -261,7 +332,7 @@ describe('Session', () => { }); it('deduplicates concurrent getToken calls to prevent multiple API requests', async () => { - BaseResource.clerk = clerkMock() as any; + BaseResource.clerk = clerkMock(); const session = new Session({ status: 'active', @@ -286,7 +357,7 @@ describe('Session', () => { }); it('deduplicates concurrent getToken calls with same template', async () => { - BaseResource.clerk = clerkMock() as any; + BaseResource.clerk = clerkMock(); const session = new Session({ status: 'active', @@ -313,7 +384,7 @@ describe('Session', () => { }); it('does not deduplicate getToken calls with different templates', async () => { - BaseResource.clerk = clerkMock() as any; + BaseResource.clerk = clerkMock(); const session = new Session({ status: 'active', @@ -335,7 +406,7 @@ describe('Session', () => { }); it('does not deduplicate getToken calls with different organization IDs', async () => { - BaseResource.clerk = clerkMock() as any; + BaseResource.clerk = clerkMock(); const session = new Session({ status: 'active', @@ -362,7 +433,7 @@ describe('Session', () => { beforeEach(() => { dispatchSpy = vi.spyOn(eventBus, 'emit'); - BaseResource.clerk = clerkMock() as any; + BaseResource.clerk = clerkMock(); }); afterEach(() => { From 555ab903dee9b2a3994c29c9ec381e5ec9a485c9 Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 30 Oct 2025 14:16:41 -0500 Subject: [PATCH 4/5] changeset --- .changeset/modern-cars-fall.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/modern-cars-fall.md diff --git a/.changeset/modern-cars-fall.md b/.changeset/modern-cars-fall.md new file mode 100644 index 00000000000..f54b7a6bc1d --- /dev/null +++ b/.changeset/modern-cars-fall.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Optimize Session.#hydrateCache to only cache token if it's new/different From bed5cdfadc2d3ab525de2db9348a5350a976040e Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 30 Oct 2025 14:59:17 -0500 Subject: [PATCH 5/5] fix tests --- packages/clerk-js/src/core/__tests__/tokenCache.test.ts | 6 +++--- .../clerk-js/src/core/resources/__tests__/Session.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index 58bf9252316..a5d7f892bb8 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -281,7 +281,7 @@ describe('SessionTokenCache', () => { it('always broadcasts regardless of cache state', async () => { mockBroadcastChannel.postMessage.mockClear(); - const tokenId = 'session_456'; + const tokenId = 'sess_2GbDB4enNdCa5vS1zpC3Xzg9tK9'; const tokenResolver = Promise.resolve( new Token({ id: tokenId, @@ -291,7 +291,7 @@ describe('SessionTokenCache', () => { ); SessionTokenCache.set({ tokenId, tokenResolver }); - await Promise.resolve(); + await tokenResolver; expect(mockBroadcastChannel.postMessage).toHaveBeenCalledTimes(1); const firstCall = mockBroadcastChannel.postMessage.mock.calls[0][0]; @@ -308,7 +308,7 @@ describe('SessionTokenCache', () => { ); SessionTokenCache.set({ tokenId, tokenResolver: tokenResolver2 }); - await Promise.resolve(); + await tokenResolver2; expect(mockBroadcastChannel.postMessage).toHaveBeenCalledTimes(1); }); diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index aef82673054..4de20208046 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -120,7 +120,7 @@ describe('Session', () => { } as SessionJSON); expect(SessionTokenCache.size()).toBe(1); - const cachedEntry1 = SessionTokenCache.get({ tokenId: 'session_1::activeOrganization' }); + const cachedEntry1 = SessionTokenCache.get({ tokenId: 'session_1-activeOrganization' }); expect(cachedEntry1).toBeDefined(); const session2 = new Session({