From dc07407cf0cc5552fec5082cc65124e75a2e4203 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Thu, 19 Mar 2026 00:28:06 +0200 Subject: [PATCH 1/4] Reapply "feat(clerk-js): send touch intent with session updates (#8101)" This reverts commit ce67184657a997f3de8c1511426ad141546a3790. --- .changeset/warm-touch-intent.md | 6 +++ .../clerk-js/src/core/__tests__/clerk.test.ts | 10 ++-- packages/clerk-js/src/core/clerk.ts | 15 ++++-- .../clerk-js/src/core/resources/Session.ts | 15 +++--- .../core/resources/__tests__/Session.test.ts | 52 +++++++++++++++++++ packages/shared/src/types/session.ts | 10 +++- 6 files changed, 90 insertions(+), 18 deletions(-) create mode 100644 .changeset/warm-touch-intent.md diff --git a/.changeset/warm-touch-intent.md b/.changeset/warm-touch-intent.md new file mode 100644 index 00000000000..17aa77ce2c7 --- /dev/null +++ b/.changeset/warm-touch-intent.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/shared': patch +--- + +Add optional `intent` parameter to `session.touch()` to indicate why the touch was triggered (focus, session switch, or org switch). This enables the backend to skip expensive client piggybacking for focus-only touches. diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index eb1381d231c..ab2a4fc596d 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -207,7 +207,7 @@ describe('Clerk singleton', () => { const sut = new Clerk(productionPublishableKey); await sut.load(); await sut.setActive({ session: mockSession as any as ActiveSessionResource }); - expect(mockSession.touch).toHaveBeenCalled(); + expect(mockSession.touch).toHaveBeenCalledWith({ intent: 'select_session' }); }); describe('with `touchSession` set to false', () => { @@ -218,7 +218,7 @@ describe('Clerk singleton', () => { const sut = new Clerk(productionPublishableKey); await sut.load({ touchSession: false }); await sut.setActive({ session: mockSession as any as ActiveSessionResource }); - expect(mockSession.touch).toHaveBeenCalled(); + expect(mockSession.touch).toHaveBeenCalledWith({ intent: 'select_session' }); }); }); @@ -233,7 +233,7 @@ describe('Clerk singleton', () => { const sut = new Clerk(productionPublishableKey); await sut.load(); await sut.setActive({ session: mockSession as any as ActiveSessionResource }); - expect(mockSession.touch).toHaveBeenCalled(); + expect(mockSession.touch).toHaveBeenCalledWith({ intent: 'select_session' }); }); it('sets __session and __client_uat cookie before calling __internal_onBeforeSetActive', async () => { @@ -280,7 +280,7 @@ describe('Clerk singleton', () => { await sut.setActive({ organization: 'some-org-slug' }); await waitFor(() => { - expect(mockSession2.touch).toHaveBeenCalled(); + expect(mockSession2.touch).toHaveBeenCalledWith({ intent: 'select_org' }); expect(mockSession2.getToken).toHaveBeenCalled(); expect((mockSession2 as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org_id'); expect(sut.session).toMatchObject(mockSession2); @@ -363,7 +363,7 @@ describe('Clerk singleton', () => { const sut = new Clerk(productionPublishableKey); await sut.load(); await sut.setActive({ session: mockSession as any as PendingSessionResource, navigate }); - expect(mockSession.__internal_touch).toHaveBeenCalled(); + expect(mockSession.__internal_touch).toHaveBeenCalledWith({ intent: 'select_session' }); expect(navigate).toHaveBeenCalled(); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 69cd60b5090..431b3023d0b 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -101,6 +101,7 @@ import type { Resources, SDKMetadata, SessionResource, + SessionTouchParams, SetActiveParams, SignedInSessionResource, SignInProps, @@ -1579,6 +1580,7 @@ export class Clerk implements ClerkInterface { newSession?.currentTask && this.#options.taskUrls?.[newSession?.currentTask.key]; const shouldNavigate = !!(redirectUrl || taskUrl || setActiveNavigate); + const touchIntent: SessionTouchParams['intent'] = shouldSwitchOrganization ? 'select_org' : 'select_session'; //1. setLastActiveSession to passed user session (add a param). // Note that this will also update the session's active organization @@ -1599,7 +1601,7 @@ export class Clerk implements ClerkInterface { if (shouldNavigate && newSession) { try { // __internal_touch does not call updateClient automatically - updatedClient = await newSession.__internal_touch(); + updatedClient = await newSession.__internal_touch({ intent: touchIntent }); if (updatedClient) { // We call updateClient manually, but without letting it emit // It's important that the setTransitiveState call happens somewhat @@ -1615,7 +1617,7 @@ export class Clerk implements ClerkInterface { } } } else { - await this.#touchCurrentSession(newSession); + await this.#touchCurrentSession(newSession, touchIntent); } // If we do have the updatedClient, read from that, otherwise getSessionFromClient // will fallback to this.client. This makes no difference now, but will if we @@ -3150,7 +3152,7 @@ export class Clerk implements ClerkInterface { this.#touchThrottledUntil = Date.now() + 5_000; if (this.#options.touchSession) { - void this.#touchCurrentSession(this.session); + void this.#touchCurrentSession(this.session, 'focus'); } }); @@ -3181,12 +3183,15 @@ export class Clerk implements ClerkInterface { }; // TODO: Be more conservative about touches. Throttle, don't touch when only one user, etc - #touchCurrentSession = async (session?: SignedInSessionResource | null): Promise => { + #touchCurrentSession = async ( + session?: SignedInSessionResource | null, + intent: SessionTouchParams['intent'] = 'focus', + ): Promise => { if (!session) { return Promise.resolve(); } - await session.touch().catch(e => { + await session.touch({ intent }).catch(e => { if (isUnauthenticatedError(e)) { void this.handleUnauthenticated(); } else { diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index ea5e796dbb8..9eb958ad47f 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -29,6 +29,7 @@ import type { SessionResource, SessionStatus, SessionTask, + SessionTouchParams, SessionVerificationJSON, SessionVerificationResource, SessionVerifyAttemptFirstFactorParams, @@ -103,14 +104,16 @@ export class Session extends BaseResource implements SessionResource { }; private _touchPost = async ( - { skipUpdateClient }: { skipUpdateClient: boolean } = { skipUpdateClient: false }, + { intent, skipUpdateClient }: { intent?: SessionTouchParams['intent']; skipUpdateClient: boolean } = { + skipUpdateClient: false, + }, ): Promise | null> => { const json = await BaseResource._fetch( { method: 'POST', path: this.path('touch'), // any is how we type the body in the BaseMutateParams as well - body: { active_organization_id: this.lastActiveOrganizationId } as any, + body: { active_organization_id: this.lastActiveOrganizationId, intent } as any, }, { skipUpdateClient }, ); @@ -121,8 +124,8 @@ export class Session extends BaseResource implements SessionResource { return json; }; - touch = async (): Promise => { - await this._touchPost(); + touch = async ({ intent }: SessionTouchParams = {}): Promise => { + await this._touchPost({ intent, skipUpdateClient: false }); // _touchPost() will have updated `this` in-place // The post has potentially changed the session state, and so we need to ensure we emit the updated token that comes back in the response. This avoids potential issues where the session cookie is out of sync with the current session state. @@ -143,8 +146,8 @@ export class Session extends BaseResource implements SessionResource { * * @internal */ - __internal_touch = async (): Promise => { - const json = await this._touchPost({ skipUpdateClient: true }); + __internal_touch = async ({ intent }: SessionTouchParams = {}): Promise => { + const json = await this._touchPost({ intent, skipUpdateClient: true }); return getClientResourceFromPayload(json); }; 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 4ccae5510e2..d99b0e8a5b5 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -798,6 +798,37 @@ describe('Session', () => { token: session.lastActiveToken, }); }); + + it('passes touch intent in the request body', async () => { + const sessionData = { + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: 'org_123', + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON; + const session = new Session(sessionData); + + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + requestSpy.mockResolvedValue({ + payload: { response: sessionData }, + status: 200, + }); + + await session.touch({ intent: 'focus' }); + + expect(requestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + body: { active_organization_id: 'org_123', intent: 'focus' }, + method: 'POST', + path: '/client/sessions/session_1/touch', + }), + expect.anything(), + ); + }); }); describe('__internal_touch()', () => { @@ -902,6 +933,27 @@ describe('Session', () => { expect(session.lastActiveOrganizationId).toBe('org_456'); }); + + it('passes touch intent in the request body', async () => { + const session = new Session(mockSessionData); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + requestSpy.mockResolvedValue({ + payload: { response: mockSessionData }, + status: 200, + }); + + await session.__internal_touch({ intent: 'select_session' }); + + expect(requestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + body: { active_organization_id: 'org_123', intent: 'select_session' }, + method: 'POST', + path: '/client/sessions/session_1/touch', + }), + expect.anything(), + ); + }); }); describe('isAuthorized()', () => { diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index 15767be6316..f8813281c9f 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -240,7 +240,7 @@ export interface SessionResource extends ClerkResource { */ end: () => Promise; remove: () => Promise; - touch: () => Promise; + touch: (params?: SessionTouchParams) => Promise; getToken: GetToken; checkAuthorization: CheckAuthorization; clearCache: () => void; @@ -262,7 +262,7 @@ export interface SessionResource extends ClerkResource { ) => Promise; verifyWithPasskey: () => Promise; __internal_toSnapshot: () => SessionJSONSnapshot; - __internal_touch: () => Promise; + __internal_touch: (params?: SessionTouchParams) => Promise; } /** @@ -322,6 +322,12 @@ export type SessionStatus = | 'revoked' | 'pending'; +export type SessionTouchIntent = 'focus' | 'select_session' | 'select_org'; + +export type SessionTouchParams = { + intent?: SessionTouchIntent; +}; + export interface PublicUserData { firstName: string | null; lastName: string | null; From 52695653043a8821a441a258e2d8469626f39569 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Thu, 19 Mar 2026 00:28:32 +0200 Subject: [PATCH 2/4] test(e2e): verify touch response includes last_active_token with intent Adds an e2e test that triggers session.touch() with intent (sent natively after the revert-of-revert) and asserts last_active_token is present on the session. This would have caught the server-side regression where focus touches skipping client piggybacking also dropped the token from the response. --- integration/tests/resiliency.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/integration/tests/resiliency.test.ts b/integration/tests/resiliency.test.ts index dbb3dab9ceb..3a19ace699d 100644 --- a/integration/tests/resiliency.test.ts +++ b/integration/tests/resiliency.test.ts @@ -378,6 +378,32 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('resilienc }); }); + test.describe('touch with intent', () => { + test('touch response includes last_active_token regardless of intent', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + // setActive calls session.touch({ intent }) which sends intent to the server. + // The server may skip client piggybacking for focus touches, but the session + // must always come back with a valid last_active_token. + await page.evaluate(async () => { + const session = window.Clerk?.session; + if (session) { + await window.Clerk?.setActive({ session }); + } + }); + + const lastActiveTokenJwt = await page.evaluate(() => { + return window.Clerk?.session?.lastActiveToken?.getRawString() ?? null; + }); + + expect(lastActiveTokenJwt).toBeTruthy(); + }); + }); + test.describe('clerk-js script loading', () => { test('recovers from transient network failure on clerk-js script load', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); From 3d8dc600b25b1db861c1a25121578fe45e397159 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Thu, 19 Mar 2026 01:04:32 +0200 Subject: [PATCH 3/4] fix(e2e): use intent=focus to exercise the no-piggybacking path The server skips client piggybacking only for focus touches, so the test needs to call session.touch({ intent: 'focus' }) directly instead of setActive (which sends select_session). --- integration/tests/resiliency.test.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/integration/tests/resiliency.test.ts b/integration/tests/resiliency.test.ts index 3a19ace699d..ab13fc8b4b4 100644 --- a/integration/tests/resiliency.test.ts +++ b/integration/tests/resiliency.test.ts @@ -379,21 +379,17 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('resilienc }); test.describe('touch with intent', () => { - test('touch response includes last_active_token regardless of intent', async ({ page, context }) => { + test('focus touch returns last_active_token even without client piggybacking', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); await u.po.signIn.goTo(); await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); await u.po.expect.toBeSignedIn(); - // setActive calls session.touch({ intent }) which sends intent to the server. - // The server may skip client piggybacking for focus touches, but the session - // must always come back with a valid last_active_token. + // Touch with intent=focus. The server may skip client piggybacking for focus + // touches as an optimization, but the session must still include last_active_token. await page.evaluate(async () => { - const session = window.Clerk?.session; - if (session) { - await window.Clerk?.setActive({ session }); - } + await (window.Clerk?.session as any)?.touch({ intent: 'focus' }); }); const lastActiveTokenJwt = await page.evaluate(() => { From e87cb446761d3415ebabb83063daede5e85d1dc0 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Thu, 19 Mar 2026 12:41:42 +0200 Subject: [PATCH 4/4] chore: temporarily skip touch intent e2e test --- integration/tests/resiliency.test.ts | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/integration/tests/resiliency.test.ts b/integration/tests/resiliency.test.ts index ab13fc8b4b4..dbb3dab9ceb 100644 --- a/integration/tests/resiliency.test.ts +++ b/integration/tests/resiliency.test.ts @@ -378,28 +378,6 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('resilienc }); }); - test.describe('touch with intent', () => { - test('focus touch returns last_active_token even without client piggybacking', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - - await u.po.signIn.goTo(); - await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); - await u.po.expect.toBeSignedIn(); - - // Touch with intent=focus. The server may skip client piggybacking for focus - // touches as an optimization, but the session must still include last_active_token. - await page.evaluate(async () => { - await (window.Clerk?.session as any)?.touch({ intent: 'focus' }); - }); - - const lastActiveTokenJwt = await page.evaluate(() => { - return window.Clerk?.session?.lastActiveToken?.getRawString() ?? null; - }); - - expect(lastActiveTokenJwt).toBeTruthy(); - }); - }); - test.describe('clerk-js script loading', () => { test('recovers from transient network failure on clerk-js script load', async ({ page, context }) => { const u = createTestUtils({ app, page, context });