From 5eb5550b82f0f1cdfcb56563f15e92aa788b6182 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 11 Jun 2026 01:17:54 -0400 Subject: [PATCH] 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'