From 5eb5550b82f0f1cdfcb56563f15e92aa788b6182 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 11 Jun 2026 01:17:54 -0400 Subject: [PATCH 1/3] fix: Added support for global navigator check to isValidBrowser --- .changeset/shared-navigator-service-worker.md | 5 ++ packages/shared/src/__tests__/browser.spec.ts | 61 ++++++++++++++++++- packages/shared/src/browser.ts | 26 +++++++- packages/shared/src/webauthn.ts | 4 ++ 4 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 .changeset/shared-navigator-service-worker.md diff --git a/.changeset/shared-navigator-service-worker.md b/.changeset/shared-navigator-service-worker.md new file mode 100644 index 00000000000..330610ec3c8 --- /dev/null +++ b/.changeset/shared-navigator-service-worker.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Resolve the browser connectivity heuristics (`isValidBrowser`, `isBrowserOnline`, and therefore `isValidBrowserOnline`) from the global `navigator` when `window` is unavailable. In runtimes that have no `window` but do expose a global `navigator` — most notably an MV3 extension background **service worker** (where `@clerk/chrome-extension` loads the background client) — these checks previously always reported "invalid/offline". That caused `getToken()` failures to be re-thrown as a misleading `clerk_offline` error and capped network retries lower than intended. The checks now read real connectivity from the worker's `navigator`. Environments with no navigator at all (e.g. SSR) continue to report `false`, and behavior in standard browsers and React Native is unchanged. diff --git a/packages/shared/src/__tests__/browser.spec.ts b/packages/shared/src/__tests__/browser.spec.ts index cc2626d6b7a..9ae151a54d5 100644 --- a/packages/shared/src/__tests__/browser.spec.ts +++ b/packages/shared/src/__tests__/browser.spec.ts @@ -38,14 +38,32 @@ describe('isValidBrowser', () => { vi.restoreAllMocks(); }); - it('returns false if not in browser', () => { + it('returns false when there is no window and no navigator (e.g. SSR)', () => { const windowSpy = vi.spyOn(global, 'window', 'get'); // @ts-ignore - Test windowSpy.mockReturnValue(undefined); + const navigatorSpy = vi.spyOn(global, 'navigator', 'get'); + // @ts-ignore - Test + navigatorSpy.mockReturnValue(undefined); expect(isValidBrowser()).toBe(false); }); + it('returns true in a service worker (no window) when a valid global navigator is present', () => { + // An MV3 background service worker has no `window`, but exposes a `WorkerNavigator` + // as the global `navigator`. In jsdom `navigator === window.navigator`, so the + // userAgent/webdriver spies still apply when accessed via the global. + const windowSpy = vi.spyOn(global, 'window', 'get'); + // @ts-ignore - Test + windowSpy.mockReturnValue(undefined); + userAgentGetter.mockReturnValue( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + ); + webdriverGetter.mockReturnValue(false); + + expect(isValidBrowser()).toBe(true); + }); + it('returns true if in browser, navigator is not a bot, and webdriver is not enabled', () => { userAgentGetter.mockReturnValue( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/109.0', @@ -131,6 +149,10 @@ describe('isValidBrowserOnline', () => { connectionGetter = vi.spyOn(window.navigator, 'connection', 'get'); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('returns TRUE if connection is online, navigator is online, has disabled webdriver, and not a bot', () => { userAgentGetter.mockReturnValue( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/109.0', @@ -207,4 +229,41 @@ describe('isValidBrowserOnline', () => { expect(isValidBrowserOnline()).toBe(true); }); + + it('returns TRUE in a service worker (no window) when the global navigator reports online', () => { + const windowSpy = vi.spyOn(global, 'window', 'get'); + // @ts-ignore - Test + windowSpy.mockReturnValue(undefined); + userAgentGetter.mockReturnValue( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + ); + webdriverGetter.mockReturnValue(false); + onLineGetter.mockReturnValue(true); + + expect(isValidBrowserOnline()).toBe(true); + }); + + it('returns FALSE in a service worker (no window) when the global navigator reports offline', () => { + const windowSpy = vi.spyOn(global, 'window', 'get'); + // @ts-ignore - Test + windowSpy.mockReturnValue(undefined); + userAgentGetter.mockReturnValue( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + ); + webdriverGetter.mockReturnValue(false); + onLineGetter.mockReturnValue(false); + + expect(isValidBrowserOnline()).toBe(false); + }); + + it('returns FALSE when there is no window and no navigator at all (e.g. SSR)', () => { + const windowSpy = vi.spyOn(global, 'window', 'get'); + // @ts-ignore - Test + windowSpy.mockReturnValue(undefined); + const navigatorSpy = vi.spyOn(global, 'navigator', 'get'); + // @ts-ignore - Test + navigatorSpy.mockReturnValue(undefined); + + expect(isValidBrowserOnline()).toBe(false); + }); }); diff --git a/packages/shared/src/browser.ts b/packages/shared/src/browser.ts index 2e48c090a40..3602941c105 100644 --- a/packages/shared/src/browser.ts +++ b/packages/shared/src/browser.ts @@ -49,13 +49,35 @@ export function userAgentIsRobot(userAgent: string): boolean { return !userAgent ? false : botAgentRegex.test(userAgent); } +/** + * Resolves the `Navigator` object from either the DOM `window` (standard browsers) + * or the global scope. Web/Service Workers — e.g. an MV3 extension background service + * worker — have no `window`, but do expose a `WorkerNavigator` as `globalThis.navigator` + * with the `onLine`/`userAgent` properties our heuristics rely on. + * + * Returns `null` only when no navigator is available anywhere. We intentionally do NOT + * treat the absence of a navigator as a valid environment — only a real navigator object + * enables the browser/online heuristics below. + * + * @returns + */ +function getNavigator(): Navigator | null { + if (typeof window !== 'undefined' && window.navigator) { + return window.navigator; + } + if (typeof navigator !== 'undefined') { + return navigator; + } + return null; +} + /** * Checks if the current environment is a browser and the user agent is not a bot. * * @returns */ export function isValidBrowser(): boolean { - const navigator = inBrowser() ? window?.navigator : null; + const navigator = getNavigator(); if (!navigator) { return false; } @@ -68,7 +90,7 @@ export function isValidBrowser(): boolean { * @returns */ export function isBrowserOnline(): boolean { - const navigator = inBrowser() ? window?.navigator : null; + const navigator = getNavigator(); if (!navigator) { return false; } diff --git a/packages/shared/src/webauthn.ts b/packages/shared/src/webauthn.ts index ff934ed8dd0..c23c5f7582f 100644 --- a/packages/shared/src/webauthn.ts +++ b/packages/shared/src/webauthn.ts @@ -5,6 +5,10 @@ import { isValidBrowser } from './browser'; */ function isWebAuthnSupported() { return ( + // `isValidBrowser()` now also returns true in environments that expose a global + // `navigator` but no `window` (e.g. service workers). WebAuthn requires the DOM + // `window` (it reads `window.PublicKeyCredential`), so guard on it explicitly. + typeof window !== 'undefined' && isValidBrowser() && // Check if `PublicKeyCredential` is a constructor typeof window.PublicKeyCredential === 'function' From a9b478ea76fb996a33e8c13f7d0a093a9c93b452 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 11 Jun 2026 17:23:18 -0400 Subject: [PATCH 2/3] refactor: Applied review feedback and refactored changes --- .changeset/shared-navigator-service-worker.md | 2 +- packages/shared/src/__tests__/browser.spec.ts | 55 +++++++++++-------- packages/shared/src/browser.ts | 26 ++++++--- 3 files changed, 51 insertions(+), 32 deletions(-) diff --git a/.changeset/shared-navigator-service-worker.md b/.changeset/shared-navigator-service-worker.md index 330610ec3c8..cb8672791ae 100644 --- a/.changeset/shared-navigator-service-worker.md +++ b/.changeset/shared-navigator-service-worker.md @@ -2,4 +2,4 @@ '@clerk/shared': patch --- -Resolve the browser connectivity heuristics (`isValidBrowser`, `isBrowserOnline`, and therefore `isValidBrowserOnline`) from the global `navigator` when `window` is unavailable. In runtimes that have no `window` but do expose a global `navigator` — most notably an MV3 extension background **service worker** (where `@clerk/chrome-extension` loads the background client) — these checks previously always reported "invalid/offline". That caused `getToken()` failures to be re-thrown as a misleading `clerk_offline` error and capped network retries lower than intended. The checks now read real connectivity from the worker's `navigator`. Environments with no navigator at all (e.g. SSR) continue to report `false`, and behavior in standard browsers and React Native is unchanged. +Resolve the browser connectivity heuristics (`isValidBrowser`, `isBrowserOnline`, and therefore `isValidBrowserOnline`) from the worker's `navigator` when `window` is unavailable but the code runs inside a `WorkerGlobalScope`. In a Web/Service Worker — most notably an MV3 extension background **service worker** (where `@clerk/chrome-extension` loads the background client) — there is no `window`, so these checks previously always reported "invalid/offline". That caused `getToken()` failures to be re-thrown as a misleading `clerk_offline` error and capped network retries lower than intended. The checks now read real connectivity from the worker's `navigator`. Server-side rendering continues to report `false` (the fallback requires a real worker scope, so a bare `globalThis.navigator` such as the one modern Node exposes is not treated as a browser), and behavior in standard browsers and React Native is unchanged. diff --git a/packages/shared/src/__tests__/browser.spec.ts b/packages/shared/src/__tests__/browser.spec.ts index 9ae151a54d5..8380d0c3668 100644 --- a/packages/shared/src/__tests__/browser.spec.ts +++ b/packages/shared/src/__tests__/browser.spec.ts @@ -2,6 +2,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { inBrowser, isValidBrowser, isValidBrowserOnline, userAgentIsRobot } from '../browser'; +/** + * Simulates a Web/Service Worker global scope (e.g. an MV3 background service worker): + * no `window`, but a `WorkerGlobalScope` exposing a `WorkerNavigator` as `self.navigator`. + * Reuses the existing jsdom navigator (with its property spies) as the worker navigator so + * userAgent/onLine/webdriver getters keep applying. + */ +function mockServiceWorkerScope() { + const workerNavigator = window.navigator; + class WorkerGlobalScope {} + const workerSelf = Object.create(WorkerGlobalScope.prototype); + workerSelf.navigator = workerNavigator; + vi.stubGlobal('WorkerGlobalScope', WorkerGlobalScope); + vi.stubGlobal('self', workerSelf); + const windowSpy = vi.spyOn(global, 'window', 'get'); + // @ts-ignore - Test + windowSpy.mockReturnValue(undefined); +} + describe('inBrowser()', () => { afterEach(() => { vi.restoreAllMocks(); @@ -36,26 +54,21 @@ describe('isValidBrowser', () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); - it('returns false when there is no window and no navigator (e.g. SSR)', () => { + it('returns false when there is no window and no WorkerGlobalScope (e.g. Node SSR)', () => { + // Modern Node exposes `globalThis.navigator`, so the bare global navigator stays + // defined here. Without a `WorkerGlobalScope`, SSR must still be treated as non-browser. const windowSpy = vi.spyOn(global, 'window', 'get'); // @ts-ignore - Test windowSpy.mockReturnValue(undefined); - const navigatorSpy = vi.spyOn(global, 'navigator', 'get'); - // @ts-ignore - Test - navigatorSpy.mockReturnValue(undefined); expect(isValidBrowser()).toBe(false); }); - it('returns true in a service worker (no window) when a valid global navigator is present', () => { - // An MV3 background service worker has no `window`, but exposes a `WorkerNavigator` - // as the global `navigator`. In jsdom `navigator === window.navigator`, so the - // userAgent/webdriver spies still apply when accessed via the global. - const windowSpy = vi.spyOn(global, 'window', 'get'); - // @ts-ignore - Test - windowSpy.mockReturnValue(undefined); + it('returns true in a service worker (no window) when a valid WorkerNavigator is present', () => { + mockServiceWorkerScope(); userAgentGetter.mockReturnValue( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', ); @@ -151,6 +164,7 @@ describe('isValidBrowserOnline', () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); it('returns TRUE if connection is online, navigator is online, has disabled webdriver, and not a bot', () => { @@ -230,10 +244,8 @@ describe('isValidBrowserOnline', () => { expect(isValidBrowserOnline()).toBe(true); }); - it('returns TRUE in a service worker (no window) when the global navigator reports online', () => { - const windowSpy = vi.spyOn(global, 'window', 'get'); - // @ts-ignore - Test - windowSpy.mockReturnValue(undefined); + it('returns TRUE in a service worker (no window) when the WorkerNavigator reports online', () => { + mockServiceWorkerScope(); userAgentGetter.mockReturnValue( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', ); @@ -243,10 +255,8 @@ describe('isValidBrowserOnline', () => { expect(isValidBrowserOnline()).toBe(true); }); - it('returns FALSE in a service worker (no window) when the global navigator reports offline', () => { - const windowSpy = vi.spyOn(global, 'window', 'get'); - // @ts-ignore - Test - windowSpy.mockReturnValue(undefined); + it('returns FALSE in a service worker (no window) when the WorkerNavigator reports offline', () => { + mockServiceWorkerScope(); userAgentGetter.mockReturnValue( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', ); @@ -256,13 +266,12 @@ describe('isValidBrowserOnline', () => { expect(isValidBrowserOnline()).toBe(false); }); - it('returns FALSE when there is no window and no navigator at all (e.g. SSR)', () => { + it('returns FALSE when there is no window and no WorkerGlobalScope (e.g. Node SSR)', () => { + // Modern Node exposes `globalThis.navigator`, so the bare global navigator stays + // defined here. Without a `WorkerGlobalScope`, SSR must still be treated as non-browser. const windowSpy = vi.spyOn(global, 'window', 'get'); // @ts-ignore - Test windowSpy.mockReturnValue(undefined); - const navigatorSpy = vi.spyOn(global, 'navigator', 'get'); - // @ts-ignore - Test - navigatorSpy.mockReturnValue(undefined); expect(isValidBrowserOnline()).toBe(false); }); diff --git a/packages/shared/src/browser.ts b/packages/shared/src/browser.ts index 3602941c105..a129de01366 100644 --- a/packages/shared/src/browser.ts +++ b/packages/shared/src/browser.ts @@ -51,13 +51,15 @@ export function userAgentIsRobot(userAgent: string): boolean { /** * Resolves the `Navigator` object from either the DOM `window` (standard browsers) - * or the global scope. Web/Service Workers — e.g. an MV3 extension background service - * worker — have no `window`, but do expose a `WorkerNavigator` as `globalThis.navigator` - * with the `onLine`/`userAgent` properties our heuristics rely on. + * or a Web/Service Worker global scope. An MV3 extension background service worker + * has no `window`, but runs inside a `WorkerGlobalScope` that exposes a + * `WorkerNavigator` as `self.navigator` with the `onLine`/`userAgent` properties + * our heuristics rely on. * - * Returns `null` only when no navigator is available anywhere. We intentionally do NOT - * treat the absence of a navigator as a valid environment — only a real navigator object - * enables the browser/online heuristics below. + * We intentionally gate the worker fallback on a real `WorkerGlobalScope` rather than + * accepting any global `navigator`. Modern Node exposes `globalThis.navigator`, so a + * blanket global-navigator check would make Node SSR look like a browser; requiring a + * `WorkerGlobalScope` keeps SSR returning `null`. * * @returns */ @@ -65,8 +67,16 @@ function getNavigator(): Navigator | null { if (typeof window !== 'undefined' && window.navigator) { return window.navigator; } - if (typeof navigator !== 'undefined') { - return navigator; + const workerScope = globalThis as unknown as { + WorkerGlobalScope?: new (...args: never[]) => { navigator?: Navigator }; + self?: { navigator?: Navigator }; + }; + if ( + typeof workerScope.WorkerGlobalScope === 'function' && + workerScope.self instanceof workerScope.WorkerGlobalScope && + workerScope.self.navigator + ) { + return workerScope.self.navigator; } return null; } From ca5e2d09ebf1beefc7dcb28f9b3245f734a1deb3 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 11 Jun 2026 18:04:14 -0400 Subject: [PATCH 3/3] fix: Updated clerk.browser.js bundle maxSize --- packages/clerk-js/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index cd0dacad616..7747c8b59e6 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,7 +1,7 @@ { "files": [ { "path": "./dist/clerk.js", "maxSize": "549KB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "71.18KB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "71.24KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "114KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" }, { "path": "./dist/clerk.native.js", "maxSize": "72KB" },