From a4cddf1f10281cbe6f37df1e2dda1f514b2a15a0 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Mon, 23 Mar 2026 16:42:16 +0200 Subject: [PATCH] feat(clerk-js): add experimental SWR initialization for faster page loads Clerk initialization blocks on /client (slow server roundtrip). This adds an opt-in experimental.swr flag that lets clerk-js initialize immediately from a cached client snapshot, validated by a fast token check, then silently swap to fresh /client data in the background. The flow: read cached client from localStorage, check if the __session cookie JWT is still valid (SSR apps always have one from middleware refresh). If valid, emit ready instantly with zero network. If expired, call getToken (edge-routed, fast) to validate session liveness. If revoked (4xx), discard cache and fall through to normal flow. If transient error, emit degraded with cached data. Cache is scoped by publishable key, versioned for schema safety, strips JWTs and signIn/signUp state before saving, and clears on sign-out (including cross-tab via BroadcastChannel). --- integration/presets/envs.ts | 6 + integration/templates/react-vite/src/main.tsx | 1 + integration/tests/swr.test.ts | 90 ++++ .../src/core/__tests__/clerk.swr.test.ts | 507 ++++++++++++++++++ .../core/__tests__/swr-client-cache.test.ts | 146 +++++ packages/clerk-js/src/core/clerk.ts | 164 +++++- .../clerk-js/src/core/swr-client-cache.ts | 82 +++ packages/shared/src/types/clerk.ts | 11 + 8 files changed, 1002 insertions(+), 5 deletions(-) create mode 100644 integration/tests/swr.test.ts create mode 100644 packages/clerk-js/src/core/__tests__/clerk.swr.test.ts create mode 100644 packages/clerk-js/src/core/__tests__/swr-client-cache.test.ts create mode 100644 packages/clerk-js/src/core/swr-client-cache.ts diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index 5c87b72647c..3b1d3992175 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -112,6 +112,11 @@ const withEmailCodes_destroy_client = withEmailCodes .clone() .setEnvVariable('public', 'EXPERIMENTAL_PERSIST_CLIENT', 'false'); +const withEmailCodes_swr = withEmailCodes + .clone() + .setId('withEmailCodes_swr') + .setEnvVariable('public', 'EXPERIMENTAL_SWR', 'true'); + const withSharedUIVariant = withEmailCodes .clone() .setId('withSharedUIVariant') @@ -257,6 +262,7 @@ export const envs = { withDynamicKeys, withEmailCodes, withEmailCodes_destroy_client, + withEmailCodes_swr, withEmailCodesProxy, withEmailCodesQuickstart, withEmailLinks, diff --git a/integration/templates/react-vite/src/main.tsx b/integration/templates/react-vite/src/main.tsx index b882cf75c81..fc88267813b 100644 --- a/integration/templates/react-vite/src/main.tsx +++ b/integration/templates/react-vite/src/main.tsx @@ -43,6 +43,7 @@ const Root = () => { persistClient: import.meta.env.VITE_EXPERIMENTAL_PERSIST_CLIENT ? import.meta.env.VITE_EXPERIMENTAL_PERSIST_CLIENT === 'true' : undefined, + swr: import.meta.env.VITE_EXPERIMENTAL_SWR ? import.meta.env.VITE_EXPERIMENTAL_SWR === 'true' : undefined, }} > diff --git a/integration/tests/swr.test.ts b/integration/tests/swr.test.ts new file mode 100644 index 00000000000..754c388d87a --- /dev/null +++ b/integration/tests/swr.test.ts @@ -0,0 +1,90 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../presets'; +import type { FakeUser } from '../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +// SafeLocalStorage prepends '__clerk_' to all keys +const SWR_CACHE_KEY_PREFIX = '__clerk_swr_client_'; + +async function hasSWRCache(page: Page): Promise { + return page.evaluate((prefix: string) => { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(prefix)) { + return true; + } + } + return false; + }, SWR_CACHE_KEY_PREFIX); +} + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes_swr] })('swr initialization @generic', ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + test('signed-in user loads from cache on second visit (page reload)', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + // First visit: sign in normally + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + expect(await hasSWRCache(page)).toBe(true); + + // Second visit: should load from cache + await page.reload(); + await u.po.clerk.toBeLoaded(); + await u.po.expect.toBeSignedIn(); + }); + + test('cache is cleared on sign-out', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + expect(await hasSWRCache(page)).toBe(true); + + await page.evaluate(async () => { + await window.Clerk.signOut(); + }); + await u.po.expect.toBeSignedOut(); + + expect(await hasSWRCache(page)).toBe(false); + }); + + test('revoked session falls back to normal flow', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + const sessionId = await page.evaluate(() => window.Clerk?.session?.id); + expect(sessionId).toBeTruthy(); + expect(await hasSWRCache(page)).toBe(true); + + // Revoke session server-side, then reload + await u.services.clerk.sessions.revokeSession(sessionId!); + await page.reload(); + await u.po.clerk.toBeLoaded(); + + await u.po.expect.toBeSignedOut(); + }); +}); diff --git a/packages/clerk-js/src/core/__tests__/clerk.swr.test.ts b/packages/clerk-js/src/core/__tests__/clerk.swr.test.ts new file mode 100644 index 00000000000..a7fb66fd6c8 --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/clerk.swr.test.ts @@ -0,0 +1,507 @@ +import type { ClientJSONSnapshot } from '@clerk/shared/types'; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { DevBrowser } from '../auth/devBrowser'; +import { Clerk } from '../clerk'; +import { eventBus, events } from '../events'; +import type { DisplayConfig } from '../resources/internal'; +import { Client, Environment } from '../resources/internal'; + +// --- Module mocks --- + +const mockClientFetch = vi.fn(); +const mockEnvironmentFetch = vi.fn(() => Promise.resolve({})); + +vi.mock('../resources/Client'); +vi.mock('../resources/Environment'); + +vi.mock('../auth/devBrowser', () => ({ + createDevBrowser: (): DevBrowser => ({ + clear: vi.fn(), + setup: vi.fn(), + getDevBrowser: vi.fn(() => 'deadbeef'), + setDevBrowser: vi.fn(), + removeDevBrowser: vi.fn(), + refreshCookies: vi.fn(), + }), +})); + +// Mock SWRClientCache and isTokenExpiringSoon +const mockSWRRead = vi.fn<() => ClientJSONSnapshot | null>().mockReturnValue(null); +const mockSWRSave = vi.fn(); +const mockSWRClear = vi.fn(); +const mockIsTokenExpiringSoon = vi.fn().mockReturnValue(true); + +vi.mock('../swr-client-cache', () => ({ + SWRClientCache: { + read: (...args: unknown[]) => mockSWRRead(...(args as [])), + save: (...args: unknown[]) => mockSWRSave(...(args as [])), + clear: (...args: unknown[]) => mockSWRClear(...(args as [])), + }, + isTokenExpiringSoon: (...args: unknown[]) => mockIsTokenExpiringSoon(...(args as [])), +})); + +Client.getOrCreateInstance = vi.fn().mockImplementation(() => { + return { fetch: mockClientFetch }; +}); +(Client as any).clearInstance = vi.fn(); + +Environment.getInstance = vi.fn().mockImplementation(() => { + return { fetch: mockEnvironmentFetch }; +}); + +// --- Helpers --- + +const productionPublishableKey = 'pk_live_Y2xlcmsuYWJjZWYuMTIzNDUucHJvZC5sY2xjbGVyay5jb20k'; + +const mockNavigate = vi.fn((to: string) => Promise.resolve(to)); +const mockedLoadOptions = { routerPush: mockNavigate, routerReplace: mockNavigate }; + +const mockDisplayConfig = { + signInUrl: 'http://test.host/sign-in', + signUpUrl: 'http://test.host/sign-up', + userProfileUrl: 'http://test.host/user-profile', + homeUrl: 'http://test.host/home', + createOrganizationUrl: 'http://test.host/create-organization', + organizationProfileUrl: 'http://test.host/organization-profile', +} as DisplayConfig; + +const mockUserSettings = { + signUp: { + captcha_enabled: false, + }, +}; + +function makeMockSession(overrides: Record = {}) { + return { + id: 'sess_123', + status: 'active', + user: { id: 'user_123', first_name: 'Alice' }, + remove: vi.fn(), + touch: vi.fn(() => Promise.resolve()), + __internal_touch: vi.fn(() => Promise.resolve()), + getToken: vi.fn(), + lastActiveToken: { getRawString: () => 'mocked-token' }, + ...overrides, + }; +} + +function makeCachedSnapshot(sessions?: ClientJSONSnapshot['sessions']): ClientJSONSnapshot { + return { + object: 'client' as const, + id: 'client_cached', + sessions: sessions ?? [ + { + object: 'session' as const, + id: 'sess_123', + status: 'active', + user: { object: 'user' as const, id: 'user_123', first_name: 'Alice' } as any, + last_active_token: null, + last_active_organization_id: null, + } as any, + ], + sign_in: null as any, + sign_up: null as any, + last_active_session_id: 'sess_123', + created_at: Date.now(), + updated_at: Date.now(), + } as ClientJSONSnapshot; +} + +// --- Test suite --- + +const oldWindowLocation = window.location; + +describe('Clerk SWR initialization', () => { + let mockWindowLocation: any; + let mockHref: ReturnType; + + afterAll(() => { + Object.defineProperty(global.window, 'location', { + value: oldWindowLocation, + }); + }); + + beforeEach(() => { + mockHref = vi.fn(); + mockWindowLocation = { + host: 'test.host', + hostname: 'test.host', + origin: 'http://test.host', + get href() { + return 'http://test.host'; + }, + set href(v: string) { + mockHref(v); + }, + }; + + Object.defineProperty(global.window, 'location', { value: mockWindowLocation }); + + if (typeof globalThis.document !== 'undefined') { + Object.defineProperty(global.window.document, 'hasFocus', { value: () => true, configurable: true }); + } + + Object.defineProperty(global.window, 'addEventListener', { + value: vi.fn(), + }); + + mockEnvironmentFetch.mockReturnValue( + Promise.resolve({ + userSettings: mockUserSettings, + displayConfig: mockDisplayConfig, + isSingleSession: () => false, + isProduction: () => true, + isDevelopmentOrStaging: () => false, + }), + ); + + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [], + }), + ); + + mockSWRRead.mockReturnValue(null); + mockSWRSave.mockReset(); + mockSWRClear.mockReset(); + mockIsTokenExpiringSoon.mockReturnValue(true); + + eventBus.off(events.TokenUpdate); + }); + + afterEach(() => { + mockNavigate.mockReset(); + vi.mocked(Client.getOrCreateInstance).mockClear(); + (Client as any).clearInstance.mockClear(); + }); + + // Test 1: SWR disabled (default) - normal flow, no cache read + it('does not read SWR cache when swr is disabled (default)', async () => { + mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [] })); + + const sut = new Clerk(productionPublishableKey); + await sut.load(mockedLoadOptions); + + expect(mockSWRRead).not.toHaveBeenCalled(); + }); + + // Test 2: SWR enabled, no cache - normal flow + it('proceeds with normal flow when SWR enabled but no cache exists', async () => { + mockSWRRead.mockReturnValue(null); + // The normal flow resolves to a client object. The SWR listener calls __internal_toSnapshot, + // so the mock must provide it. + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [], + __internal_toSnapshot: vi.fn(() => ({ object: 'client', id: 'client_1', sessions: [] })), + }), + ); + + const sut = new Clerk(productionPublishableKey); + await sut.load({ ...mockedLoadOptions, experimental: { swr: true } }); + + expect(mockSWRRead).toHaveBeenCalledWith(productionPublishableKey); + // Normal flow: Client.getOrCreateInstance().fetch() should have been called + expect(mockClientFetch).toHaveBeenCalled(); + }); + + // Test 3: SWR enabled, cache exists, cookie JWT valid - emit ready immediately + it('emits ready immediately when cache exists and cookie JWT is still valid', async () => { + const cachedSnapshot = makeCachedSnapshot(); + mockSWRRead.mockReturnValue(cachedSnapshot); + + // The cached client returned by Client.getOrCreateInstance(cachedSnapshot) + const mockSession = makeMockSession(); + const mockCachedClient = { + signedInSessions: [mockSession], + sessions: [mockSession], + lastActiveSessionId: 'sess_123', + fetch: mockClientFetch, + __internal_toSnapshot: vi.fn(() => cachedSnapshot), + }; + + vi.mocked(Client.getOrCreateInstance).mockImplementation((data?: any) => { + if (data) { + // Called with cached snapshot + return mockCachedClient as any; + } + // Called for background refresh + return { fetch: mockClientFetch } as any; + }); + + // Token is still valid + mockIsTokenExpiringSoon.mockReturnValue(false); + + // Background refresh resolves with updated client + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [mockSession], + __internal_toSnapshot: vi.fn(() => cachedSnapshot), + }), + ); + + const statusEvents: string[] = []; + const sut = new Clerk(productionPublishableKey); + sut.on('status', (status: string) => { + statusEvents.push(status); + }); + + await sut.load({ ...mockedLoadOptions, experimental: { swr: true } }); + + // Should have read the cache + expect(mockSWRRead).toHaveBeenCalledWith(productionPublishableKey); + // Token should not be expired + expect(mockIsTokenExpiringSoon).toHaveBeenCalled(); + // Session should be set from cache + expect(sut.session?.id).toBe('sess_123'); + // getToken should NOT have been called (token was still valid) + expect(mockSession.getToken).not.toHaveBeenCalled(); + // Status should have been set to ready + expect(statusEvents).toContain('ready'); + }); + + // Test 4: SWR enabled, cache exists, cookie JWT expired, getToken succeeds + it('emits ready after getToken succeeds when cookie JWT is expired', async () => { + const cachedSnapshot = makeCachedSnapshot(); + mockSWRRead.mockReturnValue(cachedSnapshot); + + const mockSession = makeMockSession({ + getToken: vi.fn().mockResolvedValue({ getRawString: () => 'fresh-token' }), + }); + + const mockCachedClient = { + signedInSessions: [mockSession], + sessions: [mockSession], + lastActiveSessionId: 'sess_123', + fetch: mockClientFetch, + __internal_toSnapshot: vi.fn(() => cachedSnapshot), + }; + + vi.mocked(Client.getOrCreateInstance).mockImplementation((data?: any) => { + if (data) { + return mockCachedClient as any; + } + return { fetch: mockClientFetch } as any; + }); + + // Token is expired + mockIsTokenExpiringSoon.mockReturnValue(true); + + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [mockSession], + __internal_toSnapshot: vi.fn(() => cachedSnapshot), + }), + ); + + const statusEvents: string[] = []; + const sut = new Clerk(productionPublishableKey); + sut.on('status', (status: string) => { + statusEvents.push(status); + }); + + await sut.load({ ...mockedLoadOptions, experimental: { swr: true } }); + + // getToken should have been called since the JWT was expired + expect(mockSession.getToken).toHaveBeenCalledWith({ skipCache: true }); + // Session should be set from cache + expect(sut.session?.id).toBe('sess_123'); + // Status should be ready + expect(statusEvents).toContain('ready'); + }); + + // Test 5: SWR enabled, cache exists, cookie JWT expired, getToken 401 - discard cache, normal flow + it('discards cache and proceeds to normal flow when getToken returns 401', async () => { + const cachedSnapshot = makeCachedSnapshot(); + mockSWRRead.mockReturnValue(cachedSnapshot); + + // Create a 4xx error + const error401 = new Error('Unauthorized'); + (error401 as any).status = 401; + // is4xxError checks for ClerkAPIResponseError with status 4xx + Object.defineProperty(error401, 'clerkError', { value: true }); + (error401 as any).errors = [{ code: 'session_not_found' }]; + + const mockSession = makeMockSession({ + getToken: vi.fn().mockRejectedValue(error401), + }); + + const mockCachedClient = { + signedInSessions: [mockSession], + sessions: [mockSession], + lastActiveSessionId: 'sess_123', + fetch: mockClientFetch, + __internal_toSnapshot: vi.fn(() => cachedSnapshot), + }; + + vi.mocked(Client.getOrCreateInstance).mockImplementation((data?: any) => { + if (data) { + return mockCachedClient as any; + } + return { fetch: mockClientFetch } as any; + }); + + // Token is expired + mockIsTokenExpiringSoon.mockReturnValue(true); + + // Normal flow fetch returns a signed-out client + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [], + __internal_toSnapshot: vi.fn(() => ({ object: 'client', id: 'client_1', sessions: [] })), + }), + ); + + const sut = new Clerk(productionPublishableKey); + await sut.load({ ...mockedLoadOptions, experimental: { swr: true } }); + + // Cache should have been cleared + expect(mockSWRClear).toHaveBeenCalledWith(productionPublishableKey); + // Client.clearInstance should have been called to discard cached client + expect((Client as any).clearInstance).toHaveBeenCalled(); + // Normal flow: Client.getOrCreateInstance().fetch() runs + expect(mockClientFetch).toHaveBeenCalled(); + }); + + // Test 6: SWR enabled, cache exists, cookie JWT expired, getToken network error - emit degraded + it('emits degraded with cached data when getToken has a transient network error', async () => { + const cachedSnapshot = makeCachedSnapshot(); + mockSWRRead.mockReturnValue(cachedSnapshot); + + // Network error (not a 4xx) + const networkError = new Error('Failed to fetch'); + + const mockSession = makeMockSession({ + getToken: vi.fn().mockRejectedValue(networkError), + }); + + const mockCachedClient = { + signedInSessions: [mockSession], + sessions: [mockSession], + lastActiveSessionId: 'sess_123', + fetch: mockClientFetch, + __internal_toSnapshot: vi.fn(() => cachedSnapshot), + }; + + vi.mocked(Client.getOrCreateInstance).mockImplementation((data?: any) => { + if (data) { + return mockCachedClient as any; + } + return { fetch: mockClientFetch } as any; + }); + + // Token is expired + mockIsTokenExpiringSoon.mockReturnValue(true); + + // Background refresh should also fail or never resolve in a network error scenario. + // Use a promise that never resolves to prevent the background refresh from + // overwriting the cached session. + mockClientFetch.mockReturnValue(new Promise(() => {})); + + const statusEvents: string[] = []; + const sut = new Clerk(productionPublishableKey); + sut.on('status', (status: string) => { + statusEvents.push(status); + }); + + await sut.load({ ...mockedLoadOptions, experimental: { swr: true } }); + + // Session should still be set from cache (degraded mode) + expect(sut.session?.id).toBe('sess_123'); + // Status should be degraded (transient error means token is unvalidated) + expect(statusEvents).toContain('degraded'); + // Cache should NOT have been cleared (session might still be valid) + expect(mockSWRClear).not.toHaveBeenCalledWith(productionPublishableKey); + }); + + // Test 7: SWR enabled, corrupted cache - proceed to normal flow + it('proceeds to normal flow when cache is corrupted', async () => { + const corruptedSnapshot = makeCachedSnapshot(); + mockSWRRead.mockReturnValue(corruptedSnapshot); + + // Simulate corruption: only the first call with data (SWR cache) throws. + // Subsequent calls (from createClientFromJwt in the normal flow) succeed normally. + let thrownOnce = false; + vi.mocked(Client.getOrCreateInstance).mockImplementation((data?: any) => { + if (data && !thrownOnce) { + thrownOnce = true; + throw new Error('Failed to parse cached client data'); + } + return { fetch: mockClientFetch } as any; + }); + + mockClientFetch.mockReturnValue( + Promise.resolve({ + signedInSessions: [], + __internal_toSnapshot: vi.fn(() => ({ object: 'client', id: 'client_1', sessions: [] })), + }), + ); + + const sut = new Clerk(productionPublishableKey); + await sut.load({ ...mockedLoadOptions, experimental: { swr: true } }); + + // Cache should be cleared on error + expect(mockSWRClear).toHaveBeenCalledWith(productionPublishableKey); + // Should fall through to normal flow + expect(mockClientFetch).toHaveBeenCalled(); + }); + + // Test 8: Session fallback - cached session gone from fresh /client, falls back to defaultSession + it('falls back to defaultSession when cached session is gone from fresh client', async () => { + const cachedSnapshot = makeCachedSnapshot(); + mockSWRRead.mockReturnValue(cachedSnapshot); + + const mockSession = makeMockSession({ id: 'sess_123' }); + + const mockCachedClient = { + signedInSessions: [mockSession], + sessions: [mockSession], + lastActiveSessionId: 'sess_123', + fetch: mockClientFetch, + __internal_toSnapshot: vi.fn(() => cachedSnapshot), + }; + + vi.mocked(Client.getOrCreateInstance).mockImplementation((data?: any) => { + if (data) { + return mockCachedClient as any; + } + return { fetch: mockClientFetch } as any; + }); + + // Token is still valid + mockIsTokenExpiringSoon.mockReturnValue(false); + + // Fresh /client returns a different session (original session is gone) + const freshSession = makeMockSession({ id: 'sess_456', user: { id: 'user_456', first_name: 'Bob' } }); + const freshClient = { + signedInSessions: [freshSession], + sessions: [freshSession], + lastActiveSessionId: 'sess_456', + __internal_toSnapshot: vi.fn(() => ({ + ...cachedSnapshot, + sessions: [{ ...cachedSnapshot.sessions[0], id: 'sess_456' }], + last_active_session_id: 'sess_456', + })), + }; + + mockClientFetch.mockReturnValue(Promise.resolve(freshClient)); + + const sut = new Clerk(productionPublishableKey); + await sut.load({ ...mockedLoadOptions, experimental: { swr: true } }); + + // Initially loaded with cached session + // After background refresh, session should be updated. + // The SWR background refresh detects the cached session is gone and + // falls back to defaultSession before calling updateClient. + + // Wait for background refresh to complete + await vi.waitFor(() => { + expect(mockClientFetch).toHaveBeenCalled(); + }); + + // The session should now be the fresh one (since the cached session was not in the fresh client, + // the SWR refresh falls back to defaultSession which picks the first signedInSession) + expect(sut.session?.id).toBe('sess_456'); + }); +}); diff --git a/packages/clerk-js/src/core/__tests__/swr-client-cache.test.ts b/packages/clerk-js/src/core/__tests__/swr-client-cache.test.ts new file mode 100644 index 00000000000..7f7df55c55a --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/swr-client-cache.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { isTokenExpiringSoon, SWRClientCache } from '../swr-client-cache'; + +// Mock SafeLocalStorage +const mockStorage = new Map(); +vi.mock('../../utils/localStorage', () => ({ + SafeLocalStorage: { + setItem: vi.fn((key: string, value: unknown, ttl?: number) => { + mockStorage.set(`__clerk_${key}`, JSON.stringify({ value, ...(ttl && { exp: Date.now() + ttl }) })); + }), + getItem: vi.fn((key: string, defaultValue: T) => { + const raw = mockStorage.get(`__clerk_${key}`); + if (!raw) return defaultValue; + const entry = JSON.parse(raw); + if (entry.exp && Date.now() > entry.exp) { + mockStorage.delete(`__clerk_${key}`); + return defaultValue; + } + return entry.value ?? defaultValue; + }), + removeItem: vi.fn((key: string) => { + mockStorage.delete(`__clerk_${key}`); + }), + }, +})); + +beforeEach(() => mockStorage.clear()); + +describe('SWRClientCache', () => { + const publishableKey = 'pk_test_abc123'; + + describe('save', () => { + it('saves a client snapshot without lastActiveToken or signIn/signUp', () => { + const snapshot = { + object: 'client' as const, + id: 'client_123', + sessions: [ + { + object: 'session' as const, + id: 'sess_123', + status: 'active', + last_active_token: { object: 'token', id: 'tok_1', jwt: 'eyJ...' }, + user: { object: 'user', id: 'user_123', first_name: 'Alice' }, + last_active_organization_id: 'org_123', + }, + ], + sign_in: { object: 'sign_in', id: 'si_123', status: 'needs_second_factor' }, + sign_up: { object: 'sign_up', id: 'su_123', status: 'missing_requirements' }, + last_active_session_id: 'sess_123', + }; + + SWRClientCache.save(snapshot as any, publishableKey); + + const saved = SWRClientCache.read(publishableKey); + expect(saved).not.toBeNull(); + // JWT stripped + expect(saved!.sessions[0].last_active_token).toBeNull(); + // signIn/signUp stripped + expect(saved!.sign_in).toBeNull(); + expect(saved!.sign_up).toBeNull(); + // Profile data preserved + expect(saved!.sessions[0].user.first_name).toBe('Alice'); + }); + + it('does not save synthetic JWT-derived clients (id: client_init)', () => { + const snapshot = { + object: 'client' as const, + id: 'client_init', + sessions: [], + }; + + SWRClientCache.save(snapshot as any, publishableKey); + expect(SWRClientCache.read(publishableKey)).toBeNull(); + }); + }); + + describe('read', () => { + it('returns null when no cache exists', () => { + expect(SWRClientCache.read(publishableKey)).toBeNull(); + }); + + it('scopes cache by publishable key', () => { + const snapshot = { + object: 'client', + id: 'client_1', + sessions: [{ object: 'session', id: 'sess_1', status: 'active', user: { object: 'user', id: 'user_1' } }], + sign_in: null, + sign_up: null, + last_active_session_id: 'sess_1', + }; + SWRClientCache.save(snapshot as any, publishableKey); + expect(SWRClientCache.read('pk_test_other')).toBeNull(); + expect(SWRClientCache.read(publishableKey)).not.toBeNull(); + }); + }); + + describe('clear', () => { + it('removes cached client', () => { + const snapshot = { + object: 'client', + id: 'client_1', + sessions: [{ object: 'session', id: 'sess_1', status: 'active', user: { object: 'user', id: 'user_1' } }], + sign_in: null, + sign_up: null, + last_active_session_id: 'sess_1', + }; + SWRClientCache.save(snapshot as any, publishableKey); + SWRClientCache.clear(publishableKey); + expect(SWRClientCache.read(publishableKey)).toBeNull(); + }); + }); +}); + +describe('isTokenExpiringSoon', () => { + function makeJwt(exp: number): string { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const payload = btoa(JSON.stringify({ exp })); + return `${header}.${payload}.fake-signature`; + } + + it('returns false for a token with plenty of time left', () => { + const exp = Math.floor(Date.now() / 1000) + 60; // 60s from now + expect(isTokenExpiringSoon(makeJwt(exp))).toBe(false); + }); + + it('returns true for a token expiring within 5 seconds', () => { + const exp = Math.floor(Date.now() / 1000) + 3; // 3s from now + expect(isTokenExpiringSoon(makeJwt(exp))).toBe(true); + }); + + it('returns true for an already expired token', () => { + const exp = Math.floor(Date.now() / 1000) - 10; // 10s ago + expect(isTokenExpiringSoon(makeJwt(exp))).toBe(true); + }); + + it('returns true for an unparseable token', () => { + expect(isTokenExpiringSoon('not-a-jwt')).toBe(true); + expect(isTokenExpiringSoon('')).toBe(true); + }); + + it('returns true for a token at exactly the 5s boundary', () => { + const exp = Math.floor(Date.now() / 1000) + 5; // exactly 5s from now + expect(isTokenExpiringSoon(makeJwt(exp))).toBe(true); + }); +}); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 431b3023d0b..b93441977c8 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -180,6 +180,7 @@ import { createCheckoutInstance } from './modules/checkout/instance'; import { Protect } from './protect'; import { BaseResource, Client, Environment, Organization, Waitlist } from './resources/internal'; import { State } from './state'; +import { isTokenExpiringSoon, SWRClientCache } from './swr-client-cache'; type SetActiveHook = (intent?: 'sign-out') => void | Promise; @@ -2975,6 +2976,135 @@ export class Clerk implements ClerkInterface { const isInAccountsHostedPages = isDevAccountPortalOrigin(window?.location.hostname); const shouldTouchEnv = this.#instanceType === 'development' && !isInAccountsHostedPages; + // SWR: attempt to initialize from cached client data + const swrEnabled = this.#options.experimental?.swr; + let swrStatus: 'ready' | 'degraded' | null = null; + + if (swrEnabled) { + const cachedSnapshot = SWRClientCache.read(this.#publishableKey); + + if (cachedSnapshot && cachedSnapshot.sessions?.length) { + try { + // Clear any existing Client instance to ensure the cached snapshot is used. + // getOrCreateInstance silently ignores the data param if an instance already exists. + Client.clearInstance(); + const cachedClient = Client.getOrCreateInstance(cachedSnapshot); + this.updateClient(cachedClient); + + if (this.session) { + // Check if the existing __session cookie has a valid (non-expired) JWT. + // SSR apps always have a fresh token (middleware refresh sets it). + // CSR apps have a valid token if the user returned within ~60s. + const existingJwt = this.#authService?.getSessionCookie(); + const tokenStillValid = existingJwt ? !isTokenExpiringSoon(existingJwt) : false; + + if (tokenStillValid) { + // Token is still valid - use cached client as-is, zero network wait. + // The poller will refresh the token before it expires. + swrStatus = 'ready'; + } else { + // Token expired or missing - call getToken to validate session and get fresh token. + // This is edge-routed and fast (~50-100ms). + const tokenResult = await this.session.getToken({ skipCache: true }).catch((err: unknown) => { + // Distinguish auth errors (session dead) from transient errors + if (is4xxError(err)) { + return null; // session revoked + } + // Transient error: session may still be alive, proceed as degraded + return 'transient_error' as const; + }); + + if (tokenResult === null) { + // Session revoked: discard cache, clear client, proceed to normal flow + SWRClientCache.clear(this.#publishableKey); + Client.clearInstance(); + this.client = undefined; + this.#updateAccessors(undefined); + } else if (tokenResult === 'transient_error') { + // Network/server issue, token unvalidated. Emit degraded with cached data. + swrStatus = 'degraded'; + } else { + // Token is fresh, session is alive. SWR success. + swrStatus = 'ready'; + } + } + } + } catch { + // Cache corrupted or other error, proceed to normal flow + SWRClientCache.clear(this.#publishableKey); + } + } + } + + if (swrStatus) { + // Handle FAPI-initiated redirects (email link verification, etc.) + // This MUST run before emitting ready, otherwise the redirect never happens. + if (await this.#redirectFAPIInitiatedFlow()) { + return; + } + + // Set client UAT cookie for development instances with custom domains + this.#authService?.setClientUatCookieForDevelopmentInstances(); + + // Restore cached environment before emitting ready so components + // that read displayConfig/authConfig have valid data + const envSnapshot = SafeLocalStorage.getItem( + CLERK_ENVIRONMENT_STORAGE_ENTRY, + null, + ); + if (envSnapshot) { + this.updateEnvironment(new Environment(envSnapshot)); + } + + // Emit loaded with the appropriate status + this.#publicEventBus.emit(clerkEvents.Status, swrStatus); + + // Continue fetching /env and /client in background (fire-and-forget) + // When they resolve, they silently update the state + const initEnvironmentPromise = Environment.getInstance() + .fetch({ touch: shouldTouchEnv }) + .then(res => this.updateEnvironment(res)) + .catch(() => { + // Fall back to cached env (same as existing behavior) + const environmentSnapshot = SafeLocalStorage.getItem( + CLERK_ENVIRONMENT_STORAGE_ENTRY, + null, + ); + if (environmentSnapshot) { + this.updateEnvironment(new Environment(environmentSnapshot)); + } + }); + + const refreshClient = Client.getOrCreateInstance() + .fetch() + .then(res => { + // If the cached session is no longer in the fresh client, select a new + // default session before calling updateClient. This handles the SWR + // stale-to-fresh swap without changing updateClient's semantics for + // all callers (a revoked session should sign out, not silently switch). + if (this.session) { + const stillExists = res.sessions?.some((s: { id: string }) => s.id === this.session?.id); + if (!stillExists) { + const fallback = this.#defaultSession(res); + this.#updateAccessors(fallback, { dangerouslySkipEmit: true }); + } + } + // updateClient triggers #emit which fires the SWR save listener + this.updateClient(res); + }) + .catch(() => { + // /client failed but we already have cached data, no action needed + }); + + // Don't await - let these run in the background + void allSettled([initEnvironmentPromise, refreshClient]); + + this.#runPostInitSetup(); + + return; // Skip the normal flow below + } + + // --- Normal flow (unchanged from here) --- let initializationDegradedCounter = 0; let retries = 0; @@ -3070,11 +3200,7 @@ export class Clerk implements ClerkInterface { } } - this.#captchaHeartbeat = new CaptchaHeartbeat(this); - void this.#captchaHeartbeat.start(); - this.#clearClerkQueryParams(); - this.#handleImpersonationFab(); - this.#handleKeylessPrompt(); + this.#runPostInitSetup(); this.#publicEventBus.emit(clerkEvents.Status, initializationDegradedCounter > 0 ? 'degraded' : 'ready'); }; @@ -3134,6 +3260,14 @@ export class Clerk implements ClerkInterface { return session || null; }; + #runPostInitSetup = () => { + this.#captchaHeartbeat = new CaptchaHeartbeat(this); + void this.#captchaHeartbeat.start(); + this.#clearClerkQueryParams(); + this.#handleImpersonationFab(); + this.#handleKeylessPrompt(); + }; + #setupBrowserListeners = (): void => { if (!inClientSide()) { return; @@ -3161,6 +3295,9 @@ export class Clerk implements ClerkInterface { */ this.#broadcastChannel?.addEventListener('message', (event: MessageEvent) => { if (event.data?.type === 'signout') { + if (this.#options.experimental?.swr) { + SWRClientCache.clear(this.#publishableKey); + } void this.handleUnauthenticated({ broadcast: false }); } }); @@ -3170,6 +3307,9 @@ export class Clerk implements ClerkInterface { */ eventBus.on(events.UserSignOut, () => { this.#broadcastChannel?.postMessage({ type: 'signout' }); + if (this.#options.experimental?.swr) { + SWRClientCache.clear(this.#publishableKey); + } }); eventBus.on(events.EnvironmentUpdate, () => { @@ -3180,6 +3320,20 @@ export class Clerk implements ClerkInterface { 24 * 60 * 60 * 1_000, ); }); + + // Cache client snapshot for SWR initialization (only when SWR is enabled) + if (this.#options.experimental?.swr) { + let lastSavedUpdatedAt: number | undefined; + this.addListener(({ client }) => { + if (client) { + const updatedAt = client.updatedAt?.getTime(); + if (updatedAt !== lastSavedUpdatedAt) { + lastSavedUpdatedAt = updatedAt; + SWRClientCache.save(client.__internal_toSnapshot(), this.#publishableKey); + } + } + }); + } }; // TODO: Be more conservative about touches. Throttle, don't touch when only one user, etc diff --git a/packages/clerk-js/src/core/swr-client-cache.ts b/packages/clerk-js/src/core/swr-client-cache.ts new file mode 100644 index 00000000000..0d2117ab801 --- /dev/null +++ b/packages/clerk-js/src/core/swr-client-cache.ts @@ -0,0 +1,82 @@ +import type { ClientJSONSnapshot } from '@clerk/shared/types'; + +import { decode } from '../utils/jwt'; +import { SafeLocalStorage } from '../utils/localStorage'; + +const CACHE_KEY_PREFIX = 'swr_client_'; +const CACHE_TTL_MS = 24 * 60 * 60 * 1_000; // 24 hours +const CACHE_VERSION = 1; + +interface CacheEnvelope { + v: number; + data: ClientJSONSnapshot; +} + +function cacheKey(publishableKey: string): string { + // Use last 8 chars of publishable key for scoping + const suffix = publishableKey.slice(-8); + return `${CACHE_KEY_PREFIX}${suffix}`; +} + +/** + * Strip sensitive and ephemeral data from a client snapshot before caching. + * - lastActiveToken (JWT): expired quickly, looks like a credential, not needed for cache + * - signIn/signUp: ephemeral auth flow state, dangerous when stale + */ +function sanitizeSnapshot(snapshot: ClientJSONSnapshot): ClientJSONSnapshot { + return { + ...snapshot, + sign_in: null as any, + sign_up: null as any, + sessions: snapshot.sessions.map((session: ClientJSONSnapshot['sessions'][number]) => ({ + ...session, + last_active_token: null, + })), + }; +} + +const TOKEN_SAFETY_MARGIN_S = 5; // match SessionCookiePoller interval + +/** + * Check if a raw JWT string is expiring within the safety margin. + * Parses the payload without verification (we only need the exp claim). + * Returns true if expired or expiring soon, false if still valid. + */ +export function isTokenExpiringSoon(jwt: string): boolean { + try { + const { claims } = decode(jwt); + const remainingSeconds = (claims.exp as number) - Math.floor(Date.now() / 1000); + return remainingSeconds <= TOKEN_SAFETY_MARGIN_S; + } catch { + return true; // unparseable = treat as expired + } +} + +export const SWRClientCache = { + save(snapshot: ClientJSONSnapshot, publishableKey: string): void { + // Don't cache synthetic JWT-derived clients + if (snapshot.id === 'client_init') { + return; + } + // Don't cache empty clients (signed out) + if (!snapshot.sessions?.length) { + return; + } + const sanitized = sanitizeSnapshot(snapshot); + SafeLocalStorage.setItem( + cacheKey(publishableKey), + { v: CACHE_VERSION, data: sanitized } satisfies CacheEnvelope, + CACHE_TTL_MS, + ); + }, + + read(publishableKey: string): ClientJSONSnapshot | null { + const entry = SafeLocalStorage.getItem(cacheKey(publishableKey), null); + if (!entry || entry.v !== CACHE_VERSION) return null; + return entry.data; + }, + + clear(publishableKey: string): void { + SafeLocalStorage.removeItem(cacheKey(publishableKey)); + }, +}; diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 670a1a21ba0..db76252e984 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1259,6 +1259,17 @@ export type ClerkOptions = ClerkOptionsNavigation & * directly with the provided Clerk instance. Used by React Native / Expo. */ runtimeEnvironment: 'headless'; + /** + * Enable stale-while-revalidate initialization. When enabled, Clerk will + * initialize immediately using cached client data from the previous page load, + * then silently swap to fresh data when the server response arrives. + * + * Requires a previous successful load to populate the cache. + * First-time visitors and signed-out users see the normal loading flow. + * + * @default false + */ + swr: boolean; }, Record >;