From 816773d60e421ea4d1e41d0abbced6d00b70ed24 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 22 Jul 2026 17:56:20 +0300 Subject: [PATCH 1/3] 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; From 08d2c3cdf74648a00391b71014f150e0d83816cf Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Thu, 23 Jul 2026 16:31:50 +0300 Subject: [PATCH 2/3] feat(js): bias proactive token refresh toward the focused tab Since the proactive refresh landed, the tab that last minted re-arms its own refresh timer, so refresh duty sticks to one tab regardless of focus and a hidden tab can hold it indefinitely, under-reporting focused activity. Attach the refresh timer only in focused tabs and poll at 1.5s while focused (5s otherwise) so duty migrates to the tab the user is actually in. --- .changeset/focused-refresh-bias.md | 5 + .../src/core/auth/SessionCookiePoller.ts | 6 +- .../__tests__/SessionCookiePoller.test.ts | 80 ++++++++++++ .../clerk-js/src/core/resources/Session.ts | 28 ++++- .../core/resources/__tests__/Session.test.ts | 115 ++++++++++++++++++ packages/clerk-js/src/utils/isTabFocused.ts | 15 +++ 6 files changed, 244 insertions(+), 5 deletions(-) create mode 100644 .changeset/focused-refresh-bias.md create mode 100644 packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts create mode 100644 packages/clerk-js/src/utils/isTabFocused.ts diff --git a/.changeset/focused-refresh-bias.md b/.changeset/focused-refresh-bias.md new file mode 100644 index 00000000000..068e062a20a --- /dev/null +++ b/.changeset/focused-refresh-bias.md @@ -0,0 +1,5 @@ +--- +"@clerk/clerk-js": patch +--- + +Bias proactive session token refresh ownership toward the focused browser tab and poll more often while focused in multi-tab apps. diff --git a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts index ed9f1f04c76..2e7c64a2401 100644 --- a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts +++ b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts @@ -1,10 +1,13 @@ import { createWorkerTimers } from '@clerk/shared/workerTimers'; +import { isTabFocused } from '@/utils/isTabFocused'; + import { SafeLock } from './safeLock'; const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken'; export const POLLER_INTERVAL_IN_MS = 5 * 1_000; +export const FOCUSED_POLLER_INTERVAL_IN_MS = 1_500; export class SessionCookiePoller { private lock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY); @@ -21,7 +24,8 @@ export class SessionCookiePoller { const run = async () => { this.initiated = true; await this.lock.acquireLockAndRun(cb); - this.timerId = this.workerTimers.setTimeout(run, POLLER_INTERVAL_IN_MS); + const interval = isTabFocused() === true ? FOCUSED_POLLER_INTERVAL_IN_MS : POLLER_INTERVAL_IN_MS; + this.timerId = this.workerTimers.setTimeout(run, interval); }; void run(); diff --git a/packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts b/packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts new file mode 100644 index 00000000000..4e3d6b57a32 --- /dev/null +++ b/packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../safeLock', () => ({ + SafeLock: () => ({ + acquireLockAndRun: (cb: () => Promise) => cb(), + }), +})); + +import { FOCUSED_POLLER_INTERVAL_IN_MS, POLLER_INTERVAL_IN_MS, SessionCookiePoller } from '../SessionCookiePoller'; + +const originalDocument = globalThis.document; +const originalDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document'); +const originalHasFocusDescriptor = Object.getOwnPropertyDescriptor(originalDocument, 'hasFocus'); + +const setDocumentHasFocus = (value: boolean) => { + Object.defineProperty(globalThis.document, 'hasFocus', { configurable: true, value: () => value }); +}; + +const setDocumentHasFocusValue = (value: unknown) => { + Object.defineProperty(globalThis.document, 'hasFocus', { configurable: true, value }); +}; + +const setDocument = (value: unknown) => { + Object.defineProperty(globalThis, 'document', { configurable: true, value }); +}; + +const restoreDocument = () => { + if (originalDocumentDescriptor) { + Object.defineProperty(globalThis, 'document', originalDocumentDescriptor); + } else { + Reflect.deleteProperty(globalThis, 'document'); + } + + if (originalHasFocusDescriptor) { + Object.defineProperty(originalDocument, 'hasFocus', originalHasFocusDescriptor); + } else { + Reflect.deleteProperty(originalDocument, 'hasFocus'); + } +}; + +describe('SessionCookiePoller', () => { + let poller: SessionCookiePoller | undefined; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + poller?.stopPollingForSessionToken(); + poller = undefined; + restoreDocument(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it.each([ + ['focused', () => setDocumentHasFocus(true), FOCUSED_POLLER_INTERVAL_IN_MS], + ['unfocused', () => setDocumentHasFocus(false), POLLER_INTERVAL_IN_MS], + ['non-callable document.hasFocus', () => setDocumentHasFocusValue(undefined), POLLER_INTERVAL_IN_MS], + [ + 'throwing document.hasFocus', + () => + setDocumentHasFocusValue(() => { + throw new Error('broken document'); + }), + POLLER_INTERVAL_IN_MS, + ], + ['missing document', () => setDocument(undefined), POLLER_INTERVAL_IN_MS], + ])('schedules the next tick at the expected interval when the tab is %s', async (_state, setup, expected) => { + setup(); + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + poller = new SessionCookiePoller(); + + poller.startPollingForSessionToken(() => Promise.resolve()); + await Promise.resolve(); + await Promise.resolve(); + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), expected); + }); +}); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index fb05d88abdb..1fe5306d9cf 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -44,6 +44,7 @@ import { isWebAuthnSupported as isWebAuthnSupportedOnWindow } from '@clerk/share import { unixEpochToDate } from '@/utils/date'; import { debugLogger } from '@/utils/debug'; +import { isTabFocused } from '@/utils/isTabFocused'; import { TokenId } from '@/utils/tokenId'; import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../errors'; @@ -238,8 +239,17 @@ export class Session extends BaseResource implements SessionResource { SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(token), - onRefresh: () => - this.#refreshTokenInBackground(undefined, this.lastActiveOrganizationId, tokenId, shouldDispatchTokenUpdate), + ...(isTabFocused() !== false + ? { + onRefresh: () => + this.#refreshTokenInBackground( + undefined, + this.lastActiveOrganizationId, + tokenId, + shouldDispatchTokenUpdate, + ), + } + : {}), }); } }; @@ -580,7 +590,12 @@ export class Session extends BaseResource implements SessionResource { SessionTokenCache.set({ tokenId, tokenResolver, - onRefresh: () => this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), + ...(isTabFocused() !== false + ? { + onRefresh: () => + this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), + } + : {}), }); return tokenResolver.then(token => { @@ -646,7 +661,12 @@ export class Session extends BaseResource implements SessionResource { SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(token), - onRefresh: () => this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), + ...(isTabFocused() !== false + ? { + onRefresh: () => + this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), + } + : {}), }); this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); }) 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 ab1320abea4..07b4550967b 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -18,6 +18,36 @@ const baseFapiClientOptions = { instanceType: 'development' as InstanceType, }; +const originalDocument = globalThis.document; +const originalDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document'); +const originalHasFocusDescriptor = Object.getOwnPropertyDescriptor(originalDocument, 'hasFocus'); + +const setDocumentHasFocus = (value: boolean) => { + Object.defineProperty(globalThis.document, 'hasFocus', { configurable: true, value: () => value }); +}; + +const setDocumentHasFocusValue = (value: unknown) => { + Object.defineProperty(globalThis.document, 'hasFocus', { configurable: true, value }); +}; + +const setDocument = (value: unknown) => { + Object.defineProperty(globalThis, 'document', { configurable: true, value }); +}; + +const restoreDocument = () => { + if (originalDocumentDescriptor) { + Object.defineProperty(globalThis, 'document', originalDocumentDescriptor); + } else { + Reflect.deleteProperty(globalThis, 'document'); + } + + if (originalHasFocusDescriptor) { + Object.defineProperty(originalDocument, 'hasFocus', originalHasFocusDescriptor); + } else { + Reflect.deleteProperty(originalDocument, 'hasFocus'); + } +}; + describe('Session', () => { beforeEach(() => { // Mock Date.now() to make the test tokens appear valid @@ -29,6 +59,7 @@ describe('Session', () => { afterEach(() => { SessionTokenCache.clear(); + restoreDocument(); vi.useRealTimers(); }); @@ -45,6 +76,83 @@ describe('Session', () => { BaseResource.clerk = null as any; }); + describe('focus-biased proactive refresh', () => { + const createSession = (lastActiveToken?: SessionJSON['last_active_token']) => + new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: lastActiveToken, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + it('registers proactive refresh after a focused network mint', async () => { + setDocumentHasFocus(true); + + const session = createSession(); + await session.getToken(); + + const tokenId = TokenId.build('session_1', undefined, null); + expect(SessionTokenCache.get({ tokenId })?.entry.onRefresh).toEqual(expect.any(Function)); + }); + + it('does not register proactive refresh after an unfocused network mint', async () => { + setDocumentHasFocus(false); + + const session = createSession(); + await session.getToken(); + + const tokenId = TokenId.build('session_1', undefined, null); + expect(SessionTokenCache.get({ tokenId })?.entry.onRefresh).toBeUndefined(); + }); + + it('does not register proactive refresh for an unfocused hydrated token', async () => { + setDocumentHasFocus(false); + + createSession({ object: 'token', jwt: mockJwt }); + await Promise.resolve(); + + const tokenId = TokenId.build('session_1', undefined, null); + expect(SessionTokenCache.get({ tokenId })?.entry.onRefresh).toBeUndefined(); + }); + + it('registers proactive refresh when document.hasFocus is not callable', async () => { + setDocumentHasFocusValue(undefined); + + const session = createSession(); + await session.getToken(); + + const tokenId = TokenId.build('session_1', undefined, null); + expect(SessionTokenCache.get({ tokenId })?.entry.onRefresh).toEqual(expect.any(Function)); + }); + + it('registers proactive refresh when document.hasFocus throws', async () => { + setDocumentHasFocusValue(() => { + throw new Error('broken document'); + }); + + const session = createSession(); + await session.getToken(); + + const tokenId = TokenId.build('session_1', undefined, null); + expect(SessionTokenCache.get({ tokenId })?.entry.onRefresh).toEqual(expect.any(Function)); + }); + + it('registers proactive refresh when document is missing', async () => { + setDocument(undefined); + + const session = createSession(); + await session.getToken(); + + const tokenId = TokenId.build('session_1', undefined, null); + expect(SessionTokenCache.get({ tokenId })?.entry.onRefresh).toEqual(expect.any(Function)); + }); + }); + it('dispatches token:update event on getToken without active organization', async () => { const session = new Session({ status: 'active', @@ -543,6 +651,10 @@ describe('Session', () => { }); describe('timer-based proactive refresh', () => { + beforeEach(() => { + setDocumentHasFocus(true); + }); + it('triggers background refresh via timer before leeway period', async () => { BaseResource.clerk = clerkMock(); const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; @@ -687,6 +799,9 @@ describe('Session', () => { const freshToken = await session.getToken(); expect(freshToken).toEqual(newMockJwt); expect(requestSpy).not.toHaveBeenCalled(); + expect( + SessionTokenCache.get({ tokenId: TokenId.build('session_1', undefined, null) })?.entry.onRefresh, + ).toEqual(expect.any(Function)); }); it('does not emit token:update with an empty token when background refresh fires while offline', async () => { diff --git a/packages/clerk-js/src/utils/isTabFocused.ts b/packages/clerk-js/src/utils/isTabFocused.ts new file mode 100644 index 00000000000..9fa3ed373b0 --- /dev/null +++ b/packages/clerk-js/src/utils/isTabFocused.ts @@ -0,0 +1,15 @@ +export function isTabFocused(): boolean | undefined { + if (typeof document === 'undefined') { + return undefined; + } + + try { + if (typeof document.hasFocus !== 'function') { + return undefined; + } + + return document.hasFocus(); + } catch { + return undefined; + } +} From 090f789a77052e7f7803edd1a6e2e209e034166f Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Thu, 23 Jul 2026 16:44:04 +0300 Subject: [PATCH 3/3] refactor(js): consolidate tab state helpers and test document stubs Post-review cleanup, no behavior change: getTabState moves next to isTabFocused so the document guard exists once, the three conditional onRefresh spreads collapse into a focusedRefresh helper, and the three copies of the document descriptor stub/restore machinery in tests become one shared helper that also covers visibilityState. --- .../__tests__/SessionCookiePoller.test.ts | 32 +-------- .../clerk-js/src/core/resources/Session.ts | 55 ++++---------- .../core/resources/__tests__/Session.test.ts | 72 ++++--------------- .../clerk-js/src/test/document-helpers.ts | 48 +++++++++++++ packages/clerk-js/src/utils/isTabFocused.ts | 16 +++++ 5 files changed, 91 insertions(+), 132 deletions(-) create mode 100644 packages/clerk-js/src/test/document-helpers.ts diff --git a/packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts b/packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts index 4e3d6b57a32..806b9a77ace 100644 --- a/packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts +++ b/packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { restoreDocument, setDocument, setDocumentHasFocus, setDocumentHasFocusValue } from '@/test/document-helpers'; + vi.mock('../safeLock', () => ({ SafeLock: () => ({ acquireLockAndRun: (cb: () => Promise) => cb(), @@ -8,36 +10,6 @@ vi.mock('../safeLock', () => ({ import { FOCUSED_POLLER_INTERVAL_IN_MS, POLLER_INTERVAL_IN_MS, SessionCookiePoller } from '../SessionCookiePoller'; -const originalDocument = globalThis.document; -const originalDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document'); -const originalHasFocusDescriptor = Object.getOwnPropertyDescriptor(originalDocument, 'hasFocus'); - -const setDocumentHasFocus = (value: boolean) => { - Object.defineProperty(globalThis.document, 'hasFocus', { configurable: true, value: () => value }); -}; - -const setDocumentHasFocusValue = (value: unknown) => { - Object.defineProperty(globalThis.document, 'hasFocus', { configurable: true, value }); -}; - -const setDocument = (value: unknown) => { - Object.defineProperty(globalThis, 'document', { configurable: true, value }); -}; - -const restoreDocument = () => { - if (originalDocumentDescriptor) { - Object.defineProperty(globalThis, 'document', originalDocumentDescriptor); - } else { - Reflect.deleteProperty(globalThis, 'document'); - } - - if (originalHasFocusDescriptor) { - Object.defineProperty(originalDocument, 'hasFocus', originalHasFocusDescriptor); - } else { - Reflect.deleteProperty(originalDocument, 'hasFocus'); - } -}; - describe('SessionCookiePoller', () => { let poller: SessionCookiePoller | undefined; diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 1fe5306d9cf..1ae3efb157b 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -44,7 +44,7 @@ import { isWebAuthnSupported as isWebAuthnSupportedOnWindow } from '@clerk/share import { unixEpochToDate } from '@/utils/date'; import { debugLogger } from '@/utils/debug'; -import { isTabFocused } from '@/utils/isTabFocused'; +import { getTabState, isTabFocused } from '@/utils/isTabFocused'; import { TokenId } from '@/utils/tokenId'; import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../errors'; @@ -55,25 +55,8 @@ 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; - } -}; +const focusedRefresh = (onRefresh: () => void): { onRefresh?: () => void } => + isTabFocused() === false ? {} : { onRefresh }; export class Session extends BaseResource implements SessionResource { pathRoot = '/client/sessions'; @@ -239,17 +222,9 @@ export class Session extends BaseResource implements SessionResource { SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(token), - ...(isTabFocused() !== false - ? { - onRefresh: () => - this.#refreshTokenInBackground( - undefined, - this.lastActiveOrganizationId, - tokenId, - shouldDispatchTokenUpdate, - ), - } - : {}), + ...focusedRefresh(() => + this.#refreshTokenInBackground(undefined, this.lastActiveOrganizationId, tokenId, shouldDispatchTokenUpdate), + ), }); } }; @@ -590,12 +565,9 @@ export class Session extends BaseResource implements SessionResource { SessionTokenCache.set({ tokenId, tokenResolver, - ...(isTabFocused() !== false - ? { - onRefresh: () => - this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), - } - : {}), + ...focusedRefresh(() => + this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), + ), }); return tokenResolver.then(token => { @@ -661,12 +633,9 @@ export class Session extends BaseResource implements SessionResource { SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(token), - ...(isTabFocused() !== false - ? { - onRefresh: () => - this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), - } - : {}), + ...focusedRefresh(() => + this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), + ), }); this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); }) 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 07b4550967b..b87e4e4041c 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -3,6 +3,13 @@ import type { InstanceType, OrganizationJSON, SessionJSON } from '@clerk/shared/ import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { clerkMock, createUser, mockFetch, mockJwt, mockNetworkFailedFetch } from '@/test/core-fixtures'; +import { + restoreDocument, + setDocument, + setDocumentHasFocus, + setDocumentHasFocusValue, + setDocumentVisibilityState, +} from '@/test/document-helpers'; import { TokenId } from '@/utils/tokenId'; import { eventBus } from '../../events'; @@ -18,36 +25,6 @@ const baseFapiClientOptions = { instanceType: 'development' as InstanceType, }; -const originalDocument = globalThis.document; -const originalDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document'); -const originalHasFocusDescriptor = Object.getOwnPropertyDescriptor(originalDocument, 'hasFocus'); - -const setDocumentHasFocus = (value: boolean) => { - Object.defineProperty(globalThis.document, 'hasFocus', { configurable: true, value: () => value }); -}; - -const setDocumentHasFocusValue = (value: unknown) => { - Object.defineProperty(globalThis.document, 'hasFocus', { configurable: true, value }); -}; - -const setDocument = (value: unknown) => { - Object.defineProperty(globalThis, 'document', { configurable: true, value }); -}; - -const restoreDocument = () => { - if (originalDocumentDescriptor) { - Object.defineProperty(globalThis, 'document', originalDocumentDescriptor); - } else { - Reflect.deleteProperty(globalThis, 'document'); - } - - if (originalHasFocusDescriptor) { - Object.defineProperty(originalDocument, 'hasFocus', originalHasFocusDescriptor); - } else { - Reflect.deleteProperty(originalDocument, 'hasFocus'); - } -}; - describe('Session', () => { beforeEach(() => { // Mock Date.now() to make the test tokens appear valid @@ -1960,9 +1937,6 @@ describe('Session', () => { }); describe('sends tab_state in /tokens request body', () => { - let originalHasFocusDescriptor: PropertyDescriptor | undefined; - let originalVisibilityStateDescriptor: PropertyDescriptor | undefined; - const createSession = () => new Session({ status: 'active', @@ -1975,31 +1949,13 @@ describe('Session', () => { 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([ @@ -2007,7 +1963,8 @@ describe('Session', () => { ['visible', false, 'visible'], ['hidden', false, 'hidden'], ])('serializes tab_state=%s', async (tabState, hasFocus, visibilityState) => { - setDocumentState(hasFocus, visibilityState as DocumentVisibilityState); + setDocumentHasFocus(hasFocus); + setDocumentVisibilityState(visibilityState as DocumentVisibilityState); await createSession().getToken({ skipCache: true }); @@ -2016,7 +1973,7 @@ describe('Session', () => { }); it('omits tab_state when document is undefined', async () => { - vi.stubGlobal('document', undefined); + setDocument(undefined); await createSession().getToken({ skipCache: true }); @@ -2025,7 +1982,7 @@ describe('Session', () => { }); it('omits tab_state when document.hasFocus is not a function', async () => { - Object.defineProperty(document, 'hasFocus', { value: undefined, configurable: true }); + setDocumentHasFocusValue(undefined); await createSession().getToken({ skipCache: true }); @@ -2034,11 +1991,8 @@ describe('Session', () => { }); it('omits tab_state when document.hasFocus throws', async () => { - Object.defineProperty(document, 'hasFocus', { - value: () => { - throw new Error('focus unavailable'); - }, - configurable: true, + setDocumentHasFocusValue(() => { + throw new Error('focus unavailable'); }); await createSession().getToken({ skipCache: true }); diff --git a/packages/clerk-js/src/test/document-helpers.ts b/packages/clerk-js/src/test/document-helpers.ts new file mode 100644 index 00000000000..d3762fe03be --- /dev/null +++ b/packages/clerk-js/src/test/document-helpers.ts @@ -0,0 +1,48 @@ +const originalDocument = globalThis.document; +const originalDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document'); +const originalHasFocusDescriptor = originalDocument + ? Object.getOwnPropertyDescriptor(originalDocument, 'hasFocus') + : undefined; +const originalVisibilityStateDescriptor = originalDocument + ? Object.getOwnPropertyDescriptor(originalDocument, 'visibilityState') + : undefined; + +export const setDocumentHasFocus = (value: boolean) => { + Object.defineProperty(globalThis.document, 'hasFocus', { configurable: true, value: () => value }); +}; + +export const setDocumentHasFocusValue = (value: unknown) => { + Object.defineProperty(globalThis.document, 'hasFocus', { configurable: true, value }); +}; + +export const setDocumentVisibilityState = (value: DocumentVisibilityState) => { + Object.defineProperty(globalThis.document, 'visibilityState', { configurable: true, value }); +}; + +export const setDocument = (value: unknown) => { + Object.defineProperty(globalThis, 'document', { configurable: true, value }); +}; + +export const restoreDocument = () => { + if (originalDocumentDescriptor) { + Object.defineProperty(globalThis, 'document', originalDocumentDescriptor); + } else { + Reflect.deleteProperty(globalThis, 'document'); + } + + if (!originalDocument) { + return; + } + + if (originalHasFocusDescriptor) { + Object.defineProperty(originalDocument, 'hasFocus', originalHasFocusDescriptor); + } else { + Reflect.deleteProperty(originalDocument, 'hasFocus'); + } + + if (originalVisibilityStateDescriptor) { + Object.defineProperty(originalDocument, 'visibilityState', originalVisibilityStateDescriptor); + } else { + Reflect.deleteProperty(originalDocument, 'visibilityState'); + } +}; diff --git a/packages/clerk-js/src/utils/isTabFocused.ts b/packages/clerk-js/src/utils/isTabFocused.ts index 9fa3ed373b0..f6794547308 100644 --- a/packages/clerk-js/src/utils/isTabFocused.ts +++ b/packages/clerk-js/src/utils/isTabFocused.ts @@ -13,3 +13,19 @@ export function isTabFocused(): boolean | undefined { return undefined; } } + +export const getTabState = (): 'focused' | 'visible' | 'hidden' | undefined => { + const focused = isTabFocused(); + if (focused === undefined) { + return undefined; + } + if (focused) { + return 'focused'; + } + + try { + return document.visibilityState === 'visible' ? 'visible' : 'hidden'; + } catch { + return undefined; + } +};