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/.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/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..806b9a77ace --- /dev/null +++ b/packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts @@ -0,0 +1,52 @@ +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(), + }), +})); + +import { FOCUSED_POLLER_INTERVAL_IN_MS, POLLER_INTERVAL_IN_MS, SessionCookiePoller } from '../SessionCookiePoller'; + +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 419f41b028c..1ae3efb157b 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 { getTabState, isTabFocused } from '@/utils/isTabFocused'; import { TokenId } from '@/utils/tokenId'; import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../errors'; @@ -54,6 +55,9 @@ import { normalizeOrgId, pickFreshestJwt, tokenOrgId, tokenSid } from '../tokenF import { BaseResource, getClientResourceFromPayload, PublicUserData, Token, User } from './internal'; import { SessionVerification } from './SessionVerification'; +const focusedRefresh = (onRefresh: () => void): { onRefresh?: () => void } => + isTabFocused() === false ? {} : { onRefresh }; + export class Session extends BaseResource implements SessionResource { pathRoot = '/client/sessions'; @@ -218,8 +222,9 @@ export class Session extends BaseResource implements SessionResource { SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(token), - onRefresh: () => + ...focusedRefresh(() => this.#refreshTokenInBackground(undefined, this.lastActiveOrganizationId, tokenId, shouldDispatchTokenUpdate), + ), }); } }; @@ -485,10 +490,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' } : {}), }; @@ -558,7 +565,9 @@ export class Session extends BaseResource implements SessionResource { SessionTokenCache.set({ tokenId, tokenResolver, - onRefresh: () => this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), + ...focusedRefresh(() => + this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), + ), }); return tokenResolver.then(token => { @@ -624,7 +633,9 @@ export class Session extends BaseResource implements SessionResource { SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(token), - 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 4a3268a967d..b87e4e4041c 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,14 @@ 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 { + restoreDocument, + setDocument, + setDocumentHasFocus, + setDocumentHasFocusValue, + setDocumentVisibilityState, +} from '@/test/document-helpers'; import { TokenId } from '@/utils/tokenId'; import { eventBus } from '../../events'; @@ -29,6 +36,7 @@ describe('Session', () => { afterEach(() => { SessionTokenCache.clear(); + restoreDocument(); vi.useRealTimers(); }); @@ -45,6 +53,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 +628,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 +776,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 () => { @@ -1844,6 +1936,72 @@ describe('Session', () => { }); }); + describe('sends tab_state in /tokens request body', () => { + 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); + + beforeEach(() => { + BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; + mockFetch(true, 200, { object: 'token', jwt: mockJwt }); + }); + + afterEach(() => { + BaseResource.clerk = null as any; + }); + + it.each([ + ['focused', true, 'hidden'], + ['visible', false, 'visible'], + ['hidden', false, 'hidden'], + ])('serializes tab_state=%s', async (tabState, hasFocus, visibilityState) => { + setDocumentHasFocus(hasFocus); + setDocumentVisibilityState(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 () => { + setDocument(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 () => { + setDocumentHasFocusValue(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 throws', async () => { + setDocumentHasFocusValue(() => { + throw new Error('focus unavailable'); + }); + + 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; 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 new file mode 100644 index 00000000000..f6794547308 --- /dev/null +++ b/packages/clerk-js/src/utils/isTabFocused.ts @@ -0,0 +1,31 @@ +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; + } +} + +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; + } +};