Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/modern-cars-fall.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Optimize Session.#hydrateCache to only cache token if it's new/different
35 changes: 35 additions & 0 deletions packages/clerk-js/src/core/__tests__/tokenCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 'sess_2GbDB4enNdCa5vS1zpC3Xzg9tK9';
const tokenResolver = Promise.resolve(
new Token({
id: tokenId,
jwt: mockJwt,
object: 'token',
}) as TokenResource,
);

SessionTokenCache.set({ tokenId, tokenResolver });
await tokenResolver;

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 tokenResolver2;

expect(mockBroadcastChannel.postMessage).toHaveBeenCalledTimes(1);
});
});

describe('token expiration with absolute time', () => {
Expand Down
10 changes: 8 additions & 2 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,15 @@ export class Session extends BaseResource implements SessionResource {
};

#hydrateCache = (token: TokenResource | null) => {
if (token) {
if (!token) {
return;
}

const tokenId = this.#getCacheId();
const existing = SessionTokenCache.get({ tokenId });
if (!existing) {
SessionTokenCache.set({
tokenId: this.#getCacheId(),
tokenId,
tokenResolver: Promise.resolve(token),
});
}
Expand Down
95 changes: 83 additions & 12 deletions packages/clerk-js/src/core/resources/__tests__/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ describe('Session', () => {

beforeEach(() => {
dispatchSpy = vi.spyOn(eventBus, 'emit');
BaseResource.clerk = clerkMock() as any;
BaseResource.clerk = clerkMock();
});

afterEach(() => {
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -362,7 +433,7 @@ describe('Session', () => {

beforeEach(() => {
dispatchSpy = vi.spyOn(eventBus, 'emit');
BaseResource.clerk = clerkMock() as any;
BaseResource.clerk = clerkMock();
});

afterEach(() => {
Expand Down
Loading