From 19def8ec45ac58702fe59a4cf0f089fa32a6192e Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Fri, 16 Oct 2026 08:28:25 -0400 Subject: [PATCH 1/9] fix(js): wait up to 10s for the Device Intelligence result by default --- .changeset/protect-token-timeout-10s.md | 5 +++++ .../src/core/__tests__/protectSession.test.ts | 12 ++++++------ packages/clerk-js/src/core/protectSession.ts | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 .changeset/protect-token-timeout-10s.md diff --git a/.changeset/protect-token-timeout-10s.md b/.changeset/protect-token-timeout-10s.md new file mode 100644 index 00000000000..d7d0d1d1c0a --- /dev/null +++ b/.changeset/protect-token-timeout-10s.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Wait up to 10 seconds, instead of 5, for the Device Intelligence check to finish before sending a sign-in or sign-up without its result. This applies when an instance hasn't configured its own limit. Users on slow connections are less likely to have sign-ups blocked by protections that require a Device Intelligence result. diff --git a/packages/clerk-js/src/core/__tests__/protectSession.test.ts b/packages/clerk-js/src/core/__tests__/protectSession.test.ts index bb8056d59e9..3bc132be2b2 100644 --- a/packages/clerk-js/src/core/__tests__/protectSession.test.ts +++ b/packages/clerk-js/src/core/__tests__/protectSession.test.ts @@ -686,7 +686,7 @@ describe('ProtectSession deadlines', () => { const startedAt = Date.now(); await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'timeout' }); - // Misread, this would fall back to the 5s default and be two orders of magnitude out. + // Misread, this would fall back to the 10s default and be two orders of magnitude out. expect(Date.now() - startedAt).toBeLessThan(1_000); }); @@ -733,11 +733,11 @@ describe('ProtectSession deadlines', () => { // `tokenTimeoutMs` is server config with no upper bound of its own, so the ceiling is ours. expect(clampTimeout(120_000)).toBe(10_000); expect(clampTimeout(1_500)).toBe(1_500); - expect(clampTimeout(undefined)).toBe(5_000); - expect(clampTimeout(0)).toBe(5_000); - expect(clampTimeout(-1)).toBe(5_000); - expect(clampTimeout('soon')).toBe(5_000); - expect(clampTimeout(Number.POSITIVE_INFINITY)).toBe(5_000); + expect(clampTimeout(undefined)).toBe(10_000); + expect(clampTimeout(0)).toBe(10_000); + expect(clampTimeout(-1)).toBe(10_000); + expect(clampTimeout('soon')).toBe(10_000); + expect(clampTimeout(Number.POSITIVE_INFINITY)).toBe(10_000); }); }); diff --git a/packages/clerk-js/src/core/protectSession.ts b/packages/clerk-js/src/core/protectSession.ts index ee36a82b0e9..25c261e0cba 100644 --- a/packages/clerk-js/src/core/protectSession.ts +++ b/packages/clerk-js/src/core/protectSession.ts @@ -31,7 +31,7 @@ const INLINE_TOKEN_GLOBAL = '__clerk_specter'; /** A token this close to expiry is treated as absent, so a fresh run starts before it lapses. */ const TOKEN_REFRESH_MARGIN_MS = 60 * 1_000; /** Acquisition deadline when the instance does not configure one. */ -const DEFAULT_TOKEN_TIMEOUT_MS = 5 * 1_000; +const DEFAULT_TOKEN_TIMEOUT_MS = 10 * 1_000; /** Ceiling on the instance-configured deadline, so no server value can stall a sign-in. */ const MAX_TOKEN_TIMEOUT_MS = 10 * 1_000; /** A stored token claiming more remaining life than this was not minted by us. */ From 922566b5aeb1b3bc125a996ee95eff0f0d281983 Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Fri, 16 Oct 2026 08:36:49 -0400 Subject: [PATCH 2/9] fix(js): let a tab without the lock wait for the shared token until its deadline --- .../src/core/__tests__/protectSession.test.ts | 108 ++++++++++++++++++ packages/clerk-js/src/core/protectSession.ts | 18 ++- 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/protectSession.test.ts b/packages/clerk-js/src/core/__tests__/protectSession.test.ts index 3bc132be2b2..a36ea32b15c 100644 --- a/packages/clerk-js/src/core/__tests__/protectSession.test.ts +++ b/packages/clerk-js/src/core/__tests__/protectSession.test.ts @@ -741,6 +741,114 @@ describe('ProtectSession deadlines', () => { }); }); +describe('ProtectSession default deadline', () => { + const defaultLoader = () => loader({ token_timeout_ms: undefined }); + + const track = (params: Promise | undefined) => { + const state = { settled: false }; + void params?.then(() => { + state.settled = true; + }); + return state; + }; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + delete (navigator as unknown as Record).locks; + }); + + it('accepts a token that arrives after 7s', async () => { + const { session: created, elements } = session([defaultLoader()]); + created?.start(); + const params = created?.getRequestParams(); + + await vi.advanceTimersByTimeAsync(7_000); + serveInline(elements[0], { cid: created?.placeholders().cid }); + + await expect(params).resolves.toMatchObject({ __clerk_protect_status: 'ok' }); + }); + + it('gives up at 10s when the loader never loads', async () => { + const { session: created } = session([defaultLoader()]); + created?.start(); + const params = created?.getRequestParams(); + const state = track(params); + + await vi.advanceTimersByTimeAsync(9_999); + expect(state.settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await expect(params).resolves.toMatchObject({ __clerk_protect_status: 'timeout' }); + }); + + it('holds a sign-in made mid-acquisition only for what is left of it', async () => { + const { session: created } = session([defaultLoader()]); + created?.start(); + await vi.advanceTimersByTimeAsync(6_000); + + const params = created?.getRequestParams(); + const state = track(params); + + await vi.advanceTimersByTimeAsync(3_999); + expect(state.settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await expect(params).resolves.toMatchObject({ __clerk_protect_status: 'timeout' }); + }); + + it('never holds one sign-in past the deadline when the lock is granted late', async () => { + Object.defineProperty(navigator, 'locks', { + value: { + request: vi.fn((_key: string, _options: unknown, callback: () => Promise) => + new Promise(resolve => setTimeout(resolve, 4_900)).then(() => callback()), + ), + }, + configurable: true, + }); + + const { session: created } = session([defaultLoader()]); + created?.start(); + const params = created?.getRequestParams(); + const state = track(params); + + await vi.advanceTimersByTimeAsync(9_999); + expect(state.settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await expect(params).resolves.toMatchObject({ __clerk_protect_status: 'timeout' }); + }); + + it('keeps waiting for a leader tab after SafeLock gives up, until its own deadline', async () => { + Object.defineProperty(navigator, 'locks', { + value: { + // Never granted; rejects on SafeLock's abort the way the Web Locks API does. + request: vi.fn( + (_key: string, options: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))); + }), + ), + }, + configurable: true, + }); + + const { session: created, elements } = session([defaultLoader()]); + created?.start(); + const params = created?.getRequestParams(); + + await vi.advanceTimersByTimeAsync(7_000); + localStorage.setItem('__clerk_protect_st', storedEntry({ token: 'v1.leader.mac', rid: 'c'.repeat(26) })); + await vi.advanceTimersByTimeAsync(100); + + await expect(params).resolves.toMatchObject({ + __clerk_protect_token: 'v1.leader.mac', + __clerk_protect_status: 'ok', + }); + expect(elements).toHaveLength(0); + }); +}); + describe('ProtectSession cross-tab single flight', () => { /** * A serialising Web Locks stand-in. jsdom has no `navigator.locks`, and the `browser-tabs-lock` diff --git a/packages/clerk-js/src/core/protectSession.ts b/packages/clerk-js/src/core/protectSession.ts index 25c261e0cba..c3abbd749fb 100644 --- a/packages/clerk-js/src/core/protectSession.ts +++ b/packages/clerk-js/src/core/protectSession.ts @@ -34,6 +34,8 @@ const TOKEN_REFRESH_MARGIN_MS = 60 * 1_000; const DEFAULT_TOKEN_TIMEOUT_MS = 10 * 1_000; /** Ceiling on the instance-configured deadline, so no server value can stall a sign-in. */ const MAX_TOKEN_TIMEOUT_MS = 10 * 1_000; +/** How often a tab that could not take the lock re-reads the store while another tab mints. */ +const STORE_POLL_INTERVAL_MS = 100; /** A stored token claiming more remaining life than this was not minted by us. */ const MAX_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1_000; /** Longest token we will hand back, so a planted store entry cannot bloat a sign-in body. */ @@ -666,6 +668,7 @@ export class ProtectSession { return 'ok'; } + const deadline = Date.now() + this.#timeoutMs; const result = await this.#lock.acquireLockAndRun(async () => { // Double-checked inside the lock. Without this re-read every tab runs the loader in turn, // which is the exact failure this design exists to prevent. @@ -680,8 +683,19 @@ export class ProtectSession { } // The lock was never held — `SafeLock` gives up waiting after ~5s so a wedged leader delays - // nobody. Whatever a leader did manage to write is still ours to use. - return this.#freshStoredToken() ? 'ok' : 'timeout'; + // nobody. Whatever a leader manages to write before our own deadline is still ours to use. + return (await this.#awaitStoredToken(deadline)) ? 'ok' : 'timeout'; + } + + async #awaitStoredToken(deadline: number): Promise { + while (!this.#freshStoredToken()) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + return false; + } + await new Promise(resolve => setTimeout(resolve, Math.min(STORE_POLL_INTERVAL_MS, remaining))); + } + return true; } /** Runs the loader and takes the token it was served with. Never rejects. */ From acd06bf500b08046eefdccec0d00d0d85fdcfbff Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Fri, 23 Oct 2026 15:48:03 -0400 Subject: [PATCH 3/9] fix(js): bound token follower waits with a timer --- .changeset/protect-token-timeout-10s.md | 2 + .../src/core/__tests__/protectSession.test.ts | 39 +++++++++++ packages/clerk-js/src/core/protectSession.ts | 68 ++++++++++--------- 3 files changed, 78 insertions(+), 31 deletions(-) diff --git a/.changeset/protect-token-timeout-10s.md b/.changeset/protect-token-timeout-10s.md index d7d0d1d1c0a..02cdc21f354 100644 --- a/.changeset/protect-token-timeout-10s.md +++ b/.changeset/protect-token-timeout-10s.md @@ -3,3 +3,5 @@ --- Wait up to 10 seconds, instead of 5, for the Device Intelligence check to finish before sending a sign-in or sign-up without its result. This applies when an instance hasn't configured its own limit. Users on slow connections are less likely to have sign-ups blocked by protections that require a Device Intelligence result. + +Keep the wait bounded when the device clock changes so later attempts can recover after another tab's lock is released. diff --git a/packages/clerk-js/src/core/__tests__/protectSession.test.ts b/packages/clerk-js/src/core/__tests__/protectSession.test.ts index a36ea32b15c..5b15d618898 100644 --- a/packages/clerk-js/src/core/__tests__/protectSession.test.ts +++ b/packages/clerk-js/src/core/__tests__/protectSession.test.ts @@ -847,6 +847,45 @@ describe('ProtectSession default deadline', () => { }); expect(elements).toHaveLength(0); }); + + it.each([ + [2_000, -3_600_000], + [6_000, -3_600_000], + [2_000, 3_600_000], + [6_000, 3_600_000], + ])('settles follower polling after a clock change at %s ms by %s ms', async (changeAt, correction) => { + let available = false; + const request = vi.fn((_key: string, options: { signal: AbortSignal }, callback: () => Promise) => { + if (available) { + return callback(); + } + return new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))); + }); + }); + Object.defineProperty(navigator, 'locks', { configurable: true, value: { request } }); + const { session: created, elements } = session([defaultLoader()]); + created?.start(); + const params = created?.getRequestParams(); + const state = track(params); + + await vi.advanceTimersByTimeAsync(changeAt); + vi.setSystemTime(Date.now() + correction); + await vi.advanceTimersByTimeAsync(9_999 - changeAt); + expect(state.settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await expect(params).resolves.toMatchObject({ __clerk_protect_status: 'timeout' }); + expect(vi.getTimerCount()).toBe(0); + + available = true; + await vi.advanceTimersByTimeAsync(30_000); + const retry = created?.getRequestParams(); + await vi.advanceTimersByTimeAsync(0); + expect(request).toHaveBeenCalledTimes(2); + expect(elements).toHaveLength(1); + serveInline(elements[0], { cid: created?.placeholders().cid }); + await expect(retry).resolves.toMatchObject({ __clerk_protect_status: 'ok' }); + }); }); describe('ProtectSession cross-tab single flight', () => { diff --git a/packages/clerk-js/src/core/protectSession.ts b/packages/clerk-js/src/core/protectSession.ts index c3abbd749fb..10b599fa8cb 100644 --- a/packages/clerk-js/src/core/protectSession.ts +++ b/packages/clerk-js/src/core/protectSession.ts @@ -406,16 +406,17 @@ function isRetryableStatus(status: number): boolean { } function sleep(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.resolve(); + } return new Promise(resolve => { - const timer = setTimeout(resolve, ms); - signal.addEventListener( - 'abort', - () => { - clearTimeout(timer); - resolve(); - }, - { once: true }, - ); + const settle = () => { + clearTimeout(timer); + signal.removeEventListener('abort', settle); + resolve(); + }; + const timer = setTimeout(settle, ms); + signal.addEventListener('abort', settle, { once: true }); }); } @@ -624,7 +625,8 @@ export class ProtectSession { if (readStoredToken(this.#tokenStorageKey, TOKEN_SEND_MARGIN_MS, this.#floor)) { return; } - if (Date.now() - this.#lastAttemptAt < this.#backoffMs()) { + const elapsed = Date.now() - this.#lastAttemptAt; + if (elapsed >= 0 && elapsed < this.#backoffMs()) { return; } this.#startAcquisition(); @@ -668,34 +670,38 @@ export class ProtectSession { return 'ok'; } - const deadline = Date.now() + this.#timeoutMs; - const result = await this.#lock.acquireLockAndRun(async () => { - // Double-checked inside the lock. Without this re-read every tab runs the loader in turn, - // which is the exact failure this design exists to prevent. - if (this.#freshStoredToken()) { - return 'ok' satisfies ProtectStatus; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.#timeoutMs); + try { + const result = await this.#lock.acquireLockAndRun(async () => { + // Double-checked inside the lock. Without this re-read every tab runs the loader in turn, + // which is the exact failure this design exists to prevent. + if (this.#freshStoredToken()) { + return 'ok' satisfies ProtectStatus; + } + return await this.#mint(); + }); + + if (typeof result === 'string') { + return result as ProtectStatus; } - return await this.#mint(); - }); - if (typeof result === 'string') { - return result as ProtectStatus; + // The lock was never held — `SafeLock` gives up waiting after ~5s so a wedged leader delays + // nobody. The timer also bounds follower polling when the system clock changes. + return (await this.#awaitStoredToken(controller.signal)) ? 'ok' : 'timeout'; + } finally { + clearTimeout(timer); } - - // The lock was never held — `SafeLock` gives up waiting after ~5s so a wedged leader delays - // nobody. Whatever a leader manages to write before our own deadline is still ours to use. - return (await this.#awaitStoredToken(deadline)) ? 'ok' : 'timeout'; } - async #awaitStoredToken(deadline: number): Promise { - while (!this.#freshStoredToken()) { - const remaining = deadline - Date.now(); - if (remaining <= 0) { - return false; + async #awaitStoredToken(signal: AbortSignal): Promise { + while (!signal.aborted) { + if (this.#freshStoredToken()) { + return true; } - await new Promise(resolve => setTimeout(resolve, Math.min(STORE_POLL_INTERVAL_MS, remaining))); + await sleep(STORE_POLL_INTERVAL_MS, signal); } - return true; + return false; } /** Runs the loader and takes the token it was served with. Never rejects. */ From 5cab82f139302649f55140ddc0b1a552a7c396ff Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Fri, 23 Oct 2026 16:16:38 -0400 Subject: [PATCH 4/9] chore(js): remove added token polling comments --- packages/clerk-js/src/core/__tests__/protectSession.test.ts | 1 - packages/clerk-js/src/core/protectSession.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/protectSession.test.ts b/packages/clerk-js/src/core/__tests__/protectSession.test.ts index 5b15d618898..4154641cd79 100644 --- a/packages/clerk-js/src/core/__tests__/protectSession.test.ts +++ b/packages/clerk-js/src/core/__tests__/protectSession.test.ts @@ -822,7 +822,6 @@ describe('ProtectSession default deadline', () => { it('keeps waiting for a leader tab after SafeLock gives up, until its own deadline', async () => { Object.defineProperty(navigator, 'locks', { value: { - // Never granted; rejects on SafeLock's abort the way the Web Locks API does. request: vi.fn( (_key: string, options: { signal: AbortSignal }) => new Promise((_resolve, reject) => { diff --git a/packages/clerk-js/src/core/protectSession.ts b/packages/clerk-js/src/core/protectSession.ts index 10b599fa8cb..ca9d75f59c8 100644 --- a/packages/clerk-js/src/core/protectSession.ts +++ b/packages/clerk-js/src/core/protectSession.ts @@ -34,7 +34,6 @@ const TOKEN_REFRESH_MARGIN_MS = 60 * 1_000; const DEFAULT_TOKEN_TIMEOUT_MS = 10 * 1_000; /** Ceiling on the instance-configured deadline, so no server value can stall a sign-in. */ const MAX_TOKEN_TIMEOUT_MS = 10 * 1_000; -/** How often a tab that could not take the lock re-reads the store while another tab mints. */ const STORE_POLL_INTERVAL_MS = 100; /** A stored token claiming more remaining life than this was not minted by us. */ const MAX_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1_000; From e4cc86827e8124b0fe5be783fa8f723664cfe710 Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Fri, 23 Oct 2026 16:30:52 -0400 Subject: [PATCH 5/9] docs(shared): align the Device Intelligence timeout default --- .changeset/protect-token-timeout-10s.md | 3 +++ packages/shared/src/types/protectConfig.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.changeset/protect-token-timeout-10s.md b/.changeset/protect-token-timeout-10s.md index 02cdc21f354..6d514b66cc2 100644 --- a/.changeset/protect-token-timeout-10s.md +++ b/.changeset/protect-token-timeout-10s.md @@ -1,7 +1,10 @@ --- '@clerk/clerk-js': patch +'@clerk/shared': patch --- Wait up to 10 seconds, instead of 5, for the Device Intelligence check to finish before sending a sign-in or sign-up without its result. This applies when an instance hasn't configured its own limit. Users on slow connections are less likely to have sign-ups blocked by protections that require a Device Intelligence result. Keep the wait bounded when the device clock changes so later attempts can recover after another tab's lock is released. + +The shared loader type reference now documents the same 10-second default. diff --git a/packages/shared/src/types/protectConfig.ts b/packages/shared/src/types/protectConfig.ts index 45ea9c20f01..c6b1b637656 100644 --- a/packages/shared/src/types/protectConfig.ts +++ b/packages/shared/src/types/protectConfig.ts @@ -34,7 +34,7 @@ export interface ProtectLoader { token_url?: string; /** * How long to wait for the token before giving up and reporting a status instead. Defaults to - * 5000 and is capped at 10000 by the SDK, so this cannot stall a sign-in. + * 10000 and is capped at 10000 by the SDK, so this cannot stall a sign-in. */ token_timeout_ms?: number; /** From 24ba242fe13d47ece8a38fbfe7f431f051724358 Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Fri, 23 Oct 2026 17:11:08 -0400 Subject: [PATCH 6/9] test: stabilize SSO and multi-session fixtures --- integration/tests/session-tasks-sign-up.test.ts | 12 ++++++++++++ integration/tests/transitive-state.test.ts | 1 + 2 files changed, 13 insertions(+) diff --git a/integration/tests/session-tasks-sign-up.test.ts b/integration/tests/session-tasks-sign-up.test.ts index 1753a9efeed..3432235aa92 100644 --- a/integration/tests/session-tasks-sign-up.test.ts +++ b/integration/tests/session-tasks-sign-up.test.ts @@ -1,6 +1,7 @@ import { createClerkClient } from '@clerk/backend'; import { expect, test } from '@playwright/test'; +import { environmentConfig } from '../models/environment'; import { appConfigs } from '../presets'; import { resolveInstanceKeys } from '../presets/envs'; import type { FakeUser } from '../testUtils'; @@ -79,6 +80,17 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( test('with sso, navigate to task on after sign-up', async ({ page, context }) => { const u = await createTestUtils({ app, page, context }); + const providerKeys = await resolveInstanceKeys('oauth-provider'); + await createTestUtils({ + app: { + ...app, + env: environmentConfig() + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', providerKeys.pk) + .setEnvVariable('private', 'CLERK_SECRET_KEY', providerKeys.sk), + }, + page, + context, + }); await u.po.signUp.goTo(); await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); diff --git a/integration/tests/transitive-state.test.ts b/integration/tests/transitive-state.test.ts index 17d6566cd43..1026d593476 100644 --- a/integration/tests/transitive-state.test.ts +++ b/integration/tests/transitive-state.test.ts @@ -227,6 +227,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('transitiv await u.po.signIn.continue(); await u.po.signIn.setPassword(userB.password); await u.po.signIn.continue(); + await u.page.waitForFunction(userId => window.Clerk?.user?.id === userId, userBId); const pathA = '/transitive-state/sign-out'; const pathB = '/transitive-state/sign-out/sign-in'; From 083faf95b3afd18ad71ae368c029c81394142090 Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Mon, 26 Oct 2026 17:44:46 -0400 Subject: [PATCH 7/9] fix(js): skip follower polling when the browser refuses the token lock SafeLock returns false at once when navigator.locks refuses the request, and the follower then polled the shared store for the whole deadline although no tab was minting. Poll only when the lock wait actually ran out, and read the store once more after the deadline passes, as before. --- .../src/core/__tests__/protectSession.test.ts | 47 +++++++++++++++++++ packages/clerk-js/src/core/protectSession.ts | 20 ++++++-- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/protectSession.test.ts b/packages/clerk-js/src/core/__tests__/protectSession.test.ts index 4154641cd79..0ae8ee6dfeb 100644 --- a/packages/clerk-js/src/core/__tests__/protectSession.test.ts +++ b/packages/clerk-js/src/core/__tests__/protectSession.test.ts @@ -847,6 +847,53 @@ describe('ProtectSession default deadline', () => { expect(elements).toHaveLength(0); }); + it('answers at once when the browser refuses the cross-tab lock', async () => { + Object.defineProperty(navigator, 'locks', { + value: { request: vi.fn(() => Promise.reject(new DOMException('denied', 'SecurityError'))) }, + configurable: true, + }); + + const { session: created, elements } = session([defaultLoader()]); + created?.start(); + const params = created?.getRequestParams(); + const state = track(params); + + await vi.advanceTimersByTimeAsync(50); + expect(state.settled).toBe(true); + await expect(params).resolves.toMatchObject({ __clerk_protect_status: 'timeout' }); + expect(elements).toHaveLength(0); + }); + + it('stops at its own deadline when that comes before SafeLock gives up', async () => { + Object.defineProperty(navigator, 'locks', { + value: { + request: vi.fn( + (_key: string, options: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))); + }), + ), + }, + configurable: true, + }); + + const { session: created, elements } = session([loader({ token_timeout_ms: 3_000 })]); + created?.start(); + const params = created?.getRequestParams(); + const state = track(params); + + await vi.advanceTimersByTimeAsync(2_999); + expect(state.settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await expect(params).resolves.toMatchObject({ __clerk_protect_status: 'timeout' }); + + localStorage.setItem('__clerk_protect_st', storedEntry({ token: 'v1.leader.mac', rid: 'c'.repeat(26) })); + await vi.advanceTimersByTimeAsync(2_000); + expect(vi.getTimerCount()).toBe(0); + await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.leader.mac' }); + expect(elements).toHaveLength(0); + }); + it.each([ [2_000, -3_600_000], [6_000, -3_600_000], diff --git a/packages/clerk-js/src/core/protectSession.ts b/packages/clerk-js/src/core/protectSession.ts index ca9d75f59c8..30d910a9d2d 100644 --- a/packages/clerk-js/src/core/protectSession.ts +++ b/packages/clerk-js/src/core/protectSession.ts @@ -35,6 +35,7 @@ const DEFAULT_TOKEN_TIMEOUT_MS = 10 * 1_000; /** Ceiling on the instance-configured deadline, so no server value can stall a sign-in. */ const MAX_TOKEN_TIMEOUT_MS = 10 * 1_000; const STORE_POLL_INTERVAL_MS = 100; +const LOCK_REFUSAL_WINDOW_MS = 4_000; /** A stored token claiming more remaining life than this was not minted by us. */ const MAX_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1_000; /** Longest token we will hand back, so a planted store entry cannot bloat a sign-in body. */ @@ -671,6 +672,10 @@ export class ProtectSession { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.#timeoutMs); + let lockWaitedOut = false; + const lockTimer = setTimeout(() => { + lockWaitedOut = true; + }, LOCK_REFUSAL_WINDOW_MS); try { const result = await this.#lock.acquireLockAndRun(async () => { // Double-checked inside the lock. Without this re-read every tab runs the loader in turn, @@ -685,22 +690,29 @@ export class ProtectSession { return result as ProtectStatus; } - // The lock was never held — `SafeLock` gives up waiting after ~5s so a wedged leader delays - // nobody. The timer also bounds follower polling when the system clock changes. + // The lock was never held. Refused outright, nobody is minting for us and only what is stored + // counts; waited out after ~5s, a leader may still be minting, so watch the store until our + // deadline, which the timer bounds even when the system clock changes. + if (!lockWaitedOut) { + return this.#freshStoredToken() ? 'ok' : 'timeout'; + } return (await this.#awaitStoredToken(controller.signal)) ? 'ok' : 'timeout'; } finally { clearTimeout(timer); + clearTimeout(lockTimer); } } async #awaitStoredToken(signal: AbortSignal): Promise { - while (!signal.aborted) { + for (;;) { if (this.#freshStoredToken()) { return true; } + if (signal.aborted) { + return false; + } await sleep(STORE_POLL_INTERVAL_MS, signal); } - return false; } /** Runs the loader and takes the token it was served with. Never rejects. */ From 525d166a430a91e582845480e12a03016251d7c7 Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Mon, 26 Oct 2026 17:44:47 -0400 Subject: [PATCH 8/9] test(e2e): move the SSO and multi-session fixture fixes to their own PR --- integration/tests/session-tasks-sign-up.test.ts | 12 ------------ integration/tests/transitive-state.test.ts | 1 - 2 files changed, 13 deletions(-) diff --git a/integration/tests/session-tasks-sign-up.test.ts b/integration/tests/session-tasks-sign-up.test.ts index 3432235aa92..1753a9efeed 100644 --- a/integration/tests/session-tasks-sign-up.test.ts +++ b/integration/tests/session-tasks-sign-up.test.ts @@ -1,7 +1,6 @@ import { createClerkClient } from '@clerk/backend'; import { expect, test } from '@playwright/test'; -import { environmentConfig } from '../models/environment'; import { appConfigs } from '../presets'; import { resolveInstanceKeys } from '../presets/envs'; import type { FakeUser } from '../testUtils'; @@ -80,17 +79,6 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })( test('with sso, navigate to task on after sign-up', async ({ page, context }) => { const u = await createTestUtils({ app, page, context }); - const providerKeys = await resolveInstanceKeys('oauth-provider'); - await createTestUtils({ - app: { - ...app, - env: environmentConfig() - .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', providerKeys.pk) - .setEnvVariable('private', 'CLERK_SECRET_KEY', providerKeys.sk), - }, - page, - context, - }); await u.po.signUp.goTo(); await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); diff --git a/integration/tests/transitive-state.test.ts b/integration/tests/transitive-state.test.ts index 1026d593476..17d6566cd43 100644 --- a/integration/tests/transitive-state.test.ts +++ b/integration/tests/transitive-state.test.ts @@ -227,7 +227,6 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('transitiv await u.po.signIn.continue(); await u.po.signIn.setPassword(userB.password); await u.po.signIn.continue(); - await u.page.waitForFunction(userId => window.Clerk?.user?.id === userId, userBId); const pathA = '/transitive-state/sign-out'; const pathB = '/transitive-state/sign-out/sign-in'; From edc31aba07e7cdc26f88cfa43d6eacc62f6d2b59 Mon Sep 17 00:00:00 2001 From: Mike Wickett Date: Tue, 27 Oct 2026 17:15:04 -0400 Subject: [PATCH 9/9] chore(js): shorten the lock fallback comment --- packages/clerk-js/src/core/protectSession.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/clerk-js/src/core/protectSession.ts b/packages/clerk-js/src/core/protectSession.ts index 30d910a9d2d..2be0731c856 100644 --- a/packages/clerk-js/src/core/protectSession.ts +++ b/packages/clerk-js/src/core/protectSession.ts @@ -690,9 +690,7 @@ export class ProtectSession { return result as ProtectStatus; } - // The lock was never held. Refused outright, nobody is minting for us and only what is stored - // counts; waited out after ~5s, a leader may still be minting, so watch the store until our - // deadline, which the timer bounds even when the system clock changes. + // Refused: nobody is minting for us. Waited out: a leader may still be, so poll the store until our deadline. if (!lockWaitedOut) { return this.#freshStoredToken() ? 'ok' : 'timeout'; }