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/shared-navigator-service-worker.md
Original file line number Diff line number Diff line change
@@ -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.
61 changes: 60 additions & 1 deletion packages/shared/src/__tests__/browser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Comment on lines +41 to 50

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See: Suggestions in browser.ts

Suggested change
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 false when no window but a non-worker global navigator exists (Node SSR)', () => {
vi.spyOn(global, 'window', 'get').mockReturnValue(undefined as any);
vi.spyOn(globalThis, 'navigator', 'get').mockReturnValue({ userAgent: 'Node.js/25' } as Navigator);
expect(isValidBrowser()).toBe(false);
expect(isValidBrowserOnline()).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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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);
});
});
26 changes: 24 additions & 2 deletions packages/shared/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Comment on lines +64 to +73

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I threw this at Codex and it suggested narrowing the fallback to browser worker globals, not "anything with a global navigator."

This should preserves existing browser behavior, enable MV3/service-worker WorkerNavigator, and keep Node SSR false even though modern Node exposes globalThis.navigator.

Suggested change
function getNavigator(): Navigator | null {
if (typeof window !== 'undefined' && window.navigator) {
return window.navigator;
}
if (typeof navigator !== 'undefined') {
return navigator;
}
return null;
}
function inBrowserWorker(): boolean {
return typeof WorkerGlobalScope !== 'undefined' && globalThis instanceof WorkerGlobalScope;
}
function getNavigator(): Navigator | null {
if (typeof window !== 'undefined' && window.navigator) {
return window.navigator;
}
if (inBrowserWorker() && typeof globalThis.navigator !== 'undefined') {
return globalThis.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;
}
Expand All @@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/src/webauthn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading