diff --git a/.changeset/breezy-dogs-greet.md b/.changeset/breezy-dogs-greet.md new file mode 100644 index 00000000000..2f6cb584661 --- /dev/null +++ b/.changeset/breezy-dogs-greet.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Pause session touch and token refresh while browser is offline, and resume it when the device comes back online. diff --git a/integration/tests/sign-out-smoke.test.ts b/integration/tests/sign-out-smoke.test.ts index 129947774c7..d2c57c819a5 100644 --- a/integration/tests/sign-out-smoke.test.ts +++ b/integration/tests/sign-out-smoke.test.ts @@ -22,6 +22,18 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('sign out test('sign out through all open tabs at once', async ({ page, context }) => { const mainTab = createTestUtils({ app, page, context }); + await mainTab.page.addInitScript(() => { + /** + * Playwright may define connection incorrectly, we are overriding to null + */ + if ( + navigator.onLine && + // @ts-expect-error Cannot find `connection` + (navigator?.connection?.rtt === 0 || navigator?.downlink?.rtt === 0) + ) { + Object.defineProperty(Object.getPrototypeOf(navigator), 'connection', { value: null }); + } + }); await mainTab.po.signIn.goTo(); await mainTab.po.signIn.setIdentifier(fakeUser.email); await mainTab.po.signIn.continue(); diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index 530d0cc4c4b..4ed81e7fc75 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -1,8 +1,10 @@ +import { isBrowserOnline } from '@clerk/shared/browser'; import { createCookieHandler } from '@clerk/shared/cookie'; import { setDevBrowserJWTInURL } from '@clerk/shared/devBrowser'; import { is4xxError, isClerkAPIResponseError, isNetworkError } from '@clerk/shared/error'; import type { Clerk, InstanceType } from '@clerk/types'; +import { createOfflineScheduler } from '../../utils/offlineScheduler'; import { clerkCoreErrorTokenRefreshFailed, clerkMissingDevBrowserJwt } from '../errors'; import { eventBus, events } from '../events'; import type { FapiClient } from '../fapiClient'; @@ -39,6 +41,7 @@ export class AuthCookieService { private sessionCookie: SessionCookieHandler; private activeOrgCookie: ReturnType; private devBrowser: DevBrowser; + private sessionRefreshOfflineScheduler = createOfflineScheduler(); public static async create(clerk: Clerk, fapiClient: FapiClient, instanceType: InstanceType) { const cookieSuffix = await getCookieSuffix(clerk.publishableKey); @@ -124,7 +127,8 @@ export class AuthCookieService { // be done with a microtask. Promises schedule microtasks, and so by using `updateCookieImmediately: true`, we ensure that the cookie // is updated as part of the scheduled microtask. Our existing event-based mechanism to update the cookie schedules a task, and so the cookie // is updated too late and not guaranteed to be fresh before the refetch occurs. - void this.refreshSessionToken({ updateCookieImmediately: true }); + // While online `.schedule()` executes synchronously and immediately, ensuring the above mechanism will not break. + this.sessionRefreshOfflineScheduler.schedule(() => this.refreshSessionToken({ updateCookieImmediately: true })); } }); } @@ -138,6 +142,10 @@ export class AuthCookieService { return; } + if (!isBrowserOnline()) { + return; + } + try { const token = await this.clerk.session.getToken(); if (updateCookieImmediately) { diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 2bd3ccd9b63..6129c8b47d7 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -99,6 +99,7 @@ import { } from '../utils'; import { assertNoLegacyProp } from '../utils/assertNoLegacyProp'; import { memoizeListenerCallback } from '../utils/memoizeStateListenerCallback'; +import { createOfflineScheduler } from '../utils/offlineScheduler'; import { RedirectUrls } from '../utils/redirectUrls'; import { AuthCookieService } from './auth/AuthCookieService'; import { CaptchaHeartbeat } from './auth/CaptchaHeartbeat'; @@ -190,6 +191,7 @@ export class Clerk implements ClerkInterface { #options: ClerkOptions = {}; #pageLifecycle: ReturnType | null = null; #touchThrottledUntil = 0; + #sessionTouchOfflineScheduler = createOfflineScheduler(); public __internal_getCachedResources: | (() => Promise<{ client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null }>) @@ -1859,7 +1861,7 @@ export class Clerk implements ClerkInterface { this.#pageLifecycle = createPageLifecycle(); this.#broadcastChannel = new LocalStorageBroadcastChannel('clerk'); - this.#setupListeners(); + this.#setupBrowserListeners(); const isInAccountsHostedPages = isDevAccountPortalOrigin(window?.location.hostname); const shouldTouchEnv = this.#instanceType === 'development' && !isInAccountsHostedPages; @@ -1994,20 +1996,26 @@ export class Clerk implements ClerkInterface { return session || null; }; - #setupListeners = (): void => { + #setupBrowserListeners = (): void => { if (!inClientSide()) { return; } this.#pageLifecycle?.onPageFocus(() => { - if (this.session) { + if (!this.session) { + return; + } + + const performTouch = () => { if (this.#touchThrottledUntil > Date.now()) { return; } this.#touchThrottledUntil = Date.now() + 5_000; - void this.#touchLastActiveSession(this.session); - } + return this.#touchLastActiveSession(this.session); + }; + + this.#sessionTouchOfflineScheduler.schedule(performTouch); }); this.#broadcastChannel?.addEventListener('message', ({ data }) => { diff --git a/packages/clerk-js/src/utils/offlineScheduler.ts b/packages/clerk-js/src/utils/offlineScheduler.ts new file mode 100644 index 00000000000..27bf6885ac7 --- /dev/null +++ b/packages/clerk-js/src/utils/offlineScheduler.ts @@ -0,0 +1,36 @@ +import { isBrowserOnline } from '@clerk/shared/browser'; + +/** + * While online callbacks passed to `.schedule` will execute immediately. + * While offline callbacks passed to `.schedule` are de-duped and only the first one will be scheduled for execution when online. + */ +export const createOfflineScheduler = () => { + let scheduled = false; + + const schedule = (cb: () => void) => { + if (scheduled) { + return; + } + if (isBrowserOnline()) { + cb(); + return; + } + scheduled = true; + const controller = new AbortController(); + window.addEventListener( + 'online', + () => { + void cb(); + scheduled = false; + controller.abort(); + }, + { + signal: controller.signal, + }, + ); + }; + + return { + schedule, + }; +}; diff --git a/packages/clerk-js/src/utils/pageLifecycle.ts b/packages/clerk-js/src/utils/pageLifecycle.ts index 52c51396b6e..663c586e474 100644 --- a/packages/clerk-js/src/utils/pageLifecycle.ts +++ b/packages/clerk-js/src/utils/pageLifecycle.ts @@ -1,8 +1,5 @@ import { inBrowser } from '@clerk/shared/browser'; - -const noop = () => { - // -}; +import { noop } from '@clerk/shared/utils'; /** * Abstracts native browser event listener registration.