diff --git a/.changeset/swallow-focus-session-touch-errors.md b/.changeset/swallow-focus-session-touch-errors.md new file mode 100644 index 00000000000..c79f655e328 --- /dev/null +++ b/.changeset/swallow-focus-session-touch-errors.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': patch +--- + +Ignore errors from the background session touch that runs when the page regains focus. + +These errors were previously both unhandled and uncaught, now they are just intentionally unhandled. The page is usually in a good enough state to recover gracefully, but the uncaught errors led to noise in the browser console and error tracking tools which we now avoid. diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 18451d7bc03..0607dc183c4 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -13,6 +13,7 @@ import { waitFor } from '@testing-library/react'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test, vi } from 'vitest'; import { mockJwt } from '@/test/core-fixtures'; +import { restoreDocument, setDocumentVisibilityState } from '@/test/document-helpers'; import { mockNativeRuntime } from '../../test/utils'; import { Clerk } from '../clerk'; @@ -124,6 +125,7 @@ describe('Clerk singleton', () => { }; Object.defineProperty(global.window, 'addEventListener', { + configurable: true, value: mockAddEventListener, }); @@ -4066,4 +4068,117 @@ describe('Clerk singleton', () => { expect(mockClerkUICtor).toHaveBeenCalled(); }); }); + + describe('page focus session touch', () => { + const windowListeners = new Map void>>(); + + const firePageFocus = () => { + for (const listener of windowListeners.get('focus') ?? []) { + listener(new Event('focus')); + } + }; + + const mockSession = { + id: 'sess_1', + status: 'active', + user: {}, + touch: vi.fn(() => Promise.resolve()), + getToken: vi.fn(), + lastActiveToken: { getRawString: () => 'mocked-token' }, + }; + + beforeEach(() => { + windowListeners.clear(); + const recordWindowListener = (type: string, callback: (e: any) => void) => { + const listeners = windowListeners.get(type) ?? []; + listeners.push(callback); + windowListeners.set(type, listeners); + + if (type === 'message') { + callback({ + origin: 'https://' + productionPublishableKey, + data: { + browserToken: 'hey', + }, + }); + } + }; + Object.defineProperty(global.window, 'addEventListener', { + configurable: true, + value: recordWindowListener, + }); + + mockSession.touch.mockReset(); + mockSession.touch.mockResolvedValue(undefined); + setDocumentVisibilityState('visible'); + mockEnvironmentFetch.mockReturnValue( + Promise.resolve({ + authConfig: { singleSessionMode: true }, + userSettings: mockUserSettings, + displayConfig: mockDisplayConfig, + isSingleSession: () => true, + isProduction: () => false, + isDevelopmentOrStaging: () => true, + }), + ); + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [mockSession], + lastActiveSessionId: mockSession.id, + }), + ); + }); + + afterEach(() => { + restoreDocument(); + }); + + it('does not surface a failed focus touch as an unhandled rejection', async () => { + const unhandled: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandledRejection); + + try { + mockSession.touch.mockRejectedValue( + new Error( + 'ClerkJS: Network error at "https://clerk.example.com/v1/client/sessions/sess_1/touch" - TypeError: NetworkError when attempting to fetch resource. Please try again.', + ), + ); + + const sut = new Clerk(productionPublishableKey); + await sut.load(); + firePageFocus(); + + expect(mockSession.touch).toHaveBeenCalledWith({ intent: 'focus' }); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandledRejection); + } + }); + + it('still handles an unauthenticated focus touch', async () => { + const sut = new Clerk(productionPublishableKey); + await sut.load(); + const handleUnauthenticated = vi.spyOn(sut, 'handleUnauthenticated').mockResolvedValue(undefined); + mockSession.touch.mockRejectedValue({ status: 401 }); + + firePageFocus(); + + await waitFor(() => { + expect(handleUnauthenticated).toHaveBeenCalled(); + }); + }); + + it('does not touch the session on focus when touchSession is false', async () => { + const sut = new Clerk(productionPublishableKey); + await sut.load({ touchSession: false }); + + firePageFocus(); + + expect(mockSession.touch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index aa2cb555cd7..081c9665f59 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -3654,7 +3654,17 @@ export class Clerk implements ClerkInterface { this.#touchThrottledUntil = Date.now() + 5_000; if (this.#options.touchSession) { - void this.#touchCurrentSession(this.session, 'focus'); + // Even if touch fails, we're still in a generally good state that can recover. + // There are some caveats and edge cases, like if you reload the tab after a failed + // touch in a multi-tab scenario, you might get the last active user/org that was + // recorded by the other tab, but that's not catastrophic. + // We were previously not swallowing errors here, which led to unnecessary uncaught + // error logs in the browser console and noise in error tracking tools. + // This is a POST and does not currently retry, we could reconsider that if we wanted + // to, but probably only leads to unnecessary complexity for little gain. + this.#touchCurrentSession(this.session, 'focus').catch(error => { + debugLogger.warn('Session touch on page focus failed', { error }, 'clerk'); + }); } });