Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/focused-refresh-bias.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/tokens-tab-state.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion packages/clerk-js/src/core/auth/SessionCookiePoller.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<unknown>) => 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);
});
});
17 changes: 14 additions & 3 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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),
),
});
}
};
Expand Down Expand Up @@ -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<string, string | null> = template
? {}
: {
organizationId: organizationId ?? null,
...(tabState ? { tabState } : {}),
...(sessionMinterEnabled && this.lastActiveToken ? { token: this.lastActiveToken.getRawString() } : {}),
...(sessionMinterEnabled && skipCache ? { forceOrigin: 'true' } : {}),
};
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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);
})
Expand Down
160 changes: 159 additions & 1 deletion packages/clerk-js/src/core/resources/__tests__/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -29,6 +36,7 @@ describe('Session', () => {

afterEach(() => {
SessionTokenCache.clear();
restoreDocument();
vi.useRealTimers();
});

Expand All @@ -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',
Expand Down Expand Up @@ -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<any>;
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<typeof vi.spyOn>;
let fetchSpy: ReturnType<typeof vi.spyOn>;
Expand Down
Loading
Loading