diff --git a/.changeset/tokencache-broadcast-eviction.md b/.changeset/tokencache-broadcast-eviction.md new file mode 100644 index 00000000000..ab04025ae8b --- /dev/null +++ b/.changeset/tokencache-broadcast-eviction.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Prevent a cross-tab broadcast failure from evicting a freshly cached session token. Previously, if broadcasting a token update to other tabs threw (for example when the `BroadcastChannel` was racing a close), the token that was just cached got dropped and the next `getToken()` made an unnecessary network request. The broadcast is now isolated so a failure no longer discards a valid cached token. diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index baad7691c7d..d9d66fca750 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -506,6 +506,42 @@ describe('SessionTokenCache', () => { }); }); + describe('broadcast resilience', () => { + it('a failing postMessage does not evict the freshly cached token', async () => { + // A broadcast failure (postMessage throwing, e.g. InvalidStateError when the + // channel races a close) is a side effect that must not evict the freshly + // cached token or force an unnecessary refetch (SDK-119). + mockBroadcastChannel.postMessage.mockImplementationOnce(() => { + throw new Error('channel closed'); + }); + + const futureExp = Math.floor(Date.now() / 1000) + 3600; + const tokenResolver = Promise.resolve({ + getRawString: () => mockJwt, + jwt: { claims: { exp: futureExp, iat: 1675876730, sid: 'session_123' } }, + } as any); + + expect(() => + SessionTokenCache.set({ + tokenId: 'session_123', + tokenResolver, + }), + ).not.toThrow(); + + await tokenResolver; + // Flush the cache write's .then (broadcast) and .catch microtasks. Fake timers + // are active in this suite, so flush microtasks rather than using a setTimeout. + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } + + // The broadcast was attempted and threw, but the token must stay cached. + expect(mockBroadcastChannel.postMessage).toHaveBeenCalledTimes(1); + expect(SessionTokenCache.size()).toBe(1); + expect(SessionTokenCache.get({ tokenId: 'session_123' })).toBeDefined(); + }); + }); + describe('clear()', () => { it('removes all entries and clears timeouts', async () => { const futureExp = Math.floor(Date.now() / 1000) + 3600; diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index dd00df3dc64..3fe6a780e64 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -433,40 +433,50 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const channel = broadcastChannel; if (channel && options.broadcast) { - const tokenRaw = newToken.getRawString(); - if (tokenRaw && claims.sid) { - const sessionId = claims.sid; - const organizationId = claims.org_id || (claims.o as any)?.id; - const template = TokenId.extractTemplate(entry.tokenId, sessionId, organizationId); - - const expectedTokenId = TokenId.build(sessionId, template, organizationId); - if (entry.tokenId === expectedTokenId) { - const traceId = `bc_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; - - debugLogger.info( - 'Broadcasting token update to other tabs', - { + // Best-effort side effect: a broadcast failure (e.g. postMessage racing a + // channel close) must not reach the outer catch and evict the cached token (SDK-119). + try { + const tokenRaw = newToken.getRawString(); + if (tokenRaw && claims.sid) { + const sessionId = claims.sid; + const organizationId = claims.org_id || (claims.o as any)?.id; + const template = TokenId.extractTemplate(entry.tokenId, sessionId, organizationId); + + const expectedTokenId = TokenId.build(sessionId, template, organizationId); + if (entry.tokenId === expectedTokenId) { + const traceId = `bc_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + + debugLogger.info( + 'Broadcasting token update to other tabs', + { + organizationId, + sessionId, + tabId, + template, + tokenId: entry.tokenId, + traceId, + }, + 'tokenCache', + ); + + const message: SessionTokenEvent = { organizationId, sessionId, - tabId, template, tokenId: entry.tokenId, + tokenRaw, traceId, - }, - 'tokenCache', - ); - - const message: SessionTokenEvent = { - organizationId, - sessionId, - template, - tokenId: entry.tokenId, - tokenRaw, - traceId, - }; - - channel.postMessage(message); + }; + + channel.postMessage(message); + } } + } catch (error) { + debugLogger.warn( + 'Failed to broadcast token update to other tabs', + { error, tabId, tokenId: entry.tokenId }, + 'tokenCache', + ); } } })