From aacae7fe5f681580d8f954fbf63551ab7a2721de Mon Sep 17 00:00:00 2001 From: brkalow Date: Tue, 17 Mar 2026 13:05:02 -0500 Subject: [PATCH 1/2] feat(clerk-js): send touch intent with session updates Pass touch intent through session touch requests so focus refreshes, session switches, and org switches can trigger the matching FAPI behavior. Made-with: Cursor --- .../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 +++- 5 files changed, 84 insertions(+), 18 deletions(-) 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 a92435b97148ee208fcd511a7104844c955cd940 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 17 Mar 2026 20:52:58 +0200 Subject: [PATCH 2/2] add changeset for touch intent --- .changeset/warm-touch-intent.md | 6 ++++++ 1 file changed, 6 insertions(+) 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.