From 816773d60e421ea4d1e41d0abbced6d00b70ed24 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 22 Jul 2026 17:56:20 +0300 Subject: [PATCH] feat(js): send tab state with session token requests --- .changeset/tokens-tab-state.md | 5 + .../clerk-js/src/core/resources/Session.ts | 22 +++++ .../core/resources/__tests__/Session.test.ts | 91 ++++++++++++++++++- 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 .changeset/tokens-tab-state.md diff --git a/.changeset/tokens-tab-state.md b/.changeset/tokens-tab-state.md new file mode 100644 index 00000000000..e21e32e7649 --- /dev/null +++ b/.changeset/tokens-tab-state.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +When running in a browser context, session token requests now include a `tab_state` parameter (`focused`, `visible`, or `hidden`) so the backend can distinguish foreground from background token refreshes. The parameter is omitted in environments without a document, such as extension service workers. diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 419f41b028c..fb05d88abdb 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -54,6 +54,26 @@ import { normalizeOrgId, pickFreshestJwt, tokenOrgId, tokenSid } from '../tokenF import { BaseResource, getClientResourceFromPayload, PublicUserData, Token, User } from './internal'; import { SessionVerification } from './SessionVerification'; +const getTabState = (): 'focused' | 'visible' | 'hidden' | undefined => { + try { + if (typeof document === 'undefined' || typeof document.hasFocus !== 'function') { + return undefined; + } + + if (document.hasFocus()) { + return 'focused'; + } + + if (document.visibilityState === 'visible') { + return 'visible'; + } + + return 'hidden'; + } catch { + return undefined; + } +}; + export class Session extends BaseResource implements SessionResource { pathRoot = '/client/sessions'; @@ -485,10 +505,12 @@ export class Session extends BaseResource implements SessionResource { const path = template ? `${this.path()}/tokens/${template}` : `${this.path()}/tokens`; // TODO: update template endpoint to accept organizationId const sessionMinterEnabled = Session.clerk?.__internal_environment?.authConfig?.sessionMinter; + const tabState = template ? undefined : getTabState(); const params: Record = template ? {} : { organizationId: organizationId ?? null, + ...(tabState ? { tabState } : {}), ...(sessionMinterEnabled && this.lastActiveToken ? { token: this.lastActiveToken.getRawString() } : {}), ...(sessionMinterEnabled && skipCache ? { forceOrigin: 'true' } : {}), }; 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 4a3268a967d..ab1320abea4 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -2,7 +2,7 @@ import { ClerkAPIResponseError, ClerkOfflineError } from '@clerk/shared/error'; import type { InstanceType, OrganizationJSON, SessionJSON } from '@clerk/shared/types'; import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; -import { clerkMock, createUser, mockJwt, mockNetworkFailedFetch } from '@/test/core-fixtures'; +import { clerkMock, createUser, mockFetch, mockJwt, mockNetworkFailedFetch } from '@/test/core-fixtures'; import { TokenId } from '@/utils/tokenId'; import { eventBus } from '../../events'; @@ -1844,6 +1844,95 @@ describe('Session', () => { }); }); + describe('sends tab_state in /tokens request body', () => { + let originalHasFocusDescriptor: PropertyDescriptor | undefined; + let originalVisibilityStateDescriptor: PropertyDescriptor | undefined; + + const createSession = () => + new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + const setDocumentState = (hasFocus: boolean, visibilityState: DocumentVisibilityState) => { + Object.defineProperty(document, 'hasFocus', { value: () => hasFocus, configurable: true }); + Object.defineProperty(document, 'visibilityState', { value: visibilityState, configurable: true }); + }; + + beforeEach(() => { + originalHasFocusDescriptor = Object.getOwnPropertyDescriptor(document, 'hasFocus'); + originalVisibilityStateDescriptor = Object.getOwnPropertyDescriptor(document, 'visibilityState'); + BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; + mockFetch(true, 200, { object: 'token', jwt: mockJwt }); + }); + + afterEach(() => { + BaseResource.clerk = null as any; + vi.unstubAllGlobals(); + if (originalHasFocusDescriptor) { + Object.defineProperty(document, 'hasFocus', originalHasFocusDescriptor); + } else { + Reflect.deleteProperty(document, 'hasFocus'); + } + if (originalVisibilityStateDescriptor) { + Object.defineProperty(document, 'visibilityState', originalVisibilityStateDescriptor); + } else { + Reflect.deleteProperty(document, 'visibilityState'); + } + }); + + it.each([ + ['focused', true, 'hidden'], + ['visible', false, 'visible'], + ['hidden', false, 'hidden'], + ])('serializes tab_state=%s', async (tabState, hasFocus, visibilityState) => { + setDocumentState(hasFocus, visibilityState as DocumentVisibilityState); + + await createSession().getToken({ skipCache: true }); + + const [, request] = (global.fetch as Mock).mock.calls[0]; + expect(request.body).toBe(`organization_id=&tab_state=${tabState}`); + }); + + it('omits tab_state when document is undefined', async () => { + vi.stubGlobal('document', undefined); + + await createSession().getToken({ skipCache: true }); + + const [, request] = (global.fetch as Mock).mock.calls[0]; + expect(request.body).toBe('organization_id='); + }); + + it('omits tab_state when document.hasFocus is not a function', async () => { + Object.defineProperty(document, 'hasFocus', { value: undefined, configurable: true }); + + await createSession().getToken({ skipCache: true }); + + const [, request] = (global.fetch as Mock).mock.calls[0]; + expect(request.body).toBe('organization_id='); + }); + + it('omits tab_state when document.hasFocus throws', async () => { + Object.defineProperty(document, 'hasFocus', { + value: () => { + throw new Error('focus unavailable'); + }, + configurable: true, + }); + + await createSession().getToken({ skipCache: true }); + + const [, request] = (global.fetch as Mock).mock.calls[0]; + expect(request.body).toBe('organization_id='); + }); + }); + describe('origin outage mode fallback', () => { let dispatchSpy: ReturnType; let fetchSpy: ReturnType;