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 >;