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;