Skip to content

Commit 3678635

Browse files
committed
TSDoc and tests
1 parent 0195e03 commit 3678635

File tree

5 files changed

+311
-2
lines changed

5 files changed

+311
-2
lines changed

packages/clerk-js/src/core/auth/AuthCookieService.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ export class AuthCookieService {
4848
private devBrowser: DevBrowser;
4949
private poller: SessionCookiePoller | null = null;
5050
private sessionCookie: SessionCookieHandler;
51+
/**
52+
* Shared lock for coordinating token refresh operations across tabs
53+
*/
5154
private tokenRefreshLock: SafeLockReturn;
5255

5356
public static async create(

packages/clerk-js/src/core/auth/SessionCookiePoller.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,24 @@ import { SafeLock } from './safeLock';
66
export const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken';
77
const INTERVAL_IN_MS = 5 * 1_000;
88

9+
/**
10+
* Polls for session token refresh at regular intervals with cross-tab coordination.
11+
*
12+
* @example
13+
* ```typescript
14+
* // Create a shared lock for coordination with focus handlers
15+
* const sharedLock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY);
16+
*
17+
* // Poller uses the shared lock
18+
* const poller = new SessionCookiePoller(sharedLock);
19+
* poller.startPollingForSessionToken(() => refreshToken());
20+
*
21+
* // Focus handler can use the same lock to prevent races
22+
* window.addEventListener('focus', () => {
23+
* sharedLock.acquireLockAndRun(() => refreshToken());
24+
* });
25+
* ```
26+
*/
927
export class SessionCookiePoller {
1028
private lock: SafeLockReturn;
1129
private workerTimers = createWorkerTimers();
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import type { SafeLockReturn } from '../safeLock';
4+
import { SessionCookiePoller } from '../SessionCookiePoller';
5+
6+
describe('SessionCookiePoller', () => {
7+
beforeEach(() => {
8+
vi.useFakeTimers();
9+
});
10+
11+
afterEach(() => {
12+
vi.useRealTimers();
13+
vi.restoreAllMocks();
14+
});
15+
16+
describe('shared lock coordination', () => {
17+
it('accepts an external lock for coordination with other components', () => {
18+
const sharedLock: SafeLockReturn = {
19+
acquireLockAndRun: vi.fn().mockResolvedValue(undefined),
20+
};
21+
22+
const poller = new SessionCookiePoller(sharedLock);
23+
const callback = vi.fn().mockResolvedValue(undefined);
24+
25+
poller.startPollingForSessionToken(callback);
26+
27+
// Verify the shared lock is used
28+
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(callback);
29+
30+
poller.stopPollingForSessionToken();
31+
});
32+
33+
it('creates internal lock when none provided (backward compatible)', () => {
34+
// Should not throw when no lock is provided
35+
const poller = new SessionCookiePoller();
36+
expect(poller).toBeInstanceOf(SessionCookiePoller);
37+
});
38+
39+
it('enables focus handler and poller to share the same lock', () => {
40+
// This test demonstrates the shared lock pattern used in AuthCookieService
41+
const sharedLock: SafeLockReturn = {
42+
acquireLockAndRun: vi.fn().mockImplementation(async (cb: () => Promise<unknown>) => {
43+
return cb();
44+
}),
45+
};
46+
47+
const poller = new SessionCookiePoller(sharedLock);
48+
const pollerCallback = vi.fn().mockResolvedValue('poller-result');
49+
50+
// Poller uses the shared lock
51+
poller.startPollingForSessionToken(pollerCallback);
52+
53+
// Simulate focus handler also using the shared lock (like AuthCookieService does)
54+
const focusCallback = vi.fn().mockResolvedValue('focus-result');
55+
void sharedLock.acquireLockAndRun(focusCallback);
56+
57+
// Both should use the same lock instance
58+
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(2);
59+
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(pollerCallback);
60+
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(focusCallback);
61+
62+
poller.stopPollingForSessionToken();
63+
});
64+
});
65+
66+
describe('startPollingForSessionToken', () => {
67+
it('executes callback immediately on start', () => {
68+
const sharedLock: SafeLockReturn = {
69+
acquireLockAndRun: vi.fn().mockResolvedValue(undefined),
70+
};
71+
72+
const poller = new SessionCookiePoller(sharedLock);
73+
const callback = vi.fn().mockResolvedValue(undefined);
74+
75+
poller.startPollingForSessionToken(callback);
76+
77+
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(callback);
78+
79+
poller.stopPollingForSessionToken();
80+
});
81+
82+
it('prevents multiple concurrent polling sessions', () => {
83+
const sharedLock: SafeLockReturn = {
84+
acquireLockAndRun: vi.fn().mockResolvedValue(undefined),
85+
};
86+
87+
const poller = new SessionCookiePoller(sharedLock);
88+
const callback = vi.fn().mockResolvedValue(undefined);
89+
90+
poller.startPollingForSessionToken(callback);
91+
poller.startPollingForSessionToken(callback); // Second call should be ignored
92+
93+
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(1);
94+
95+
poller.stopPollingForSessionToken();
96+
});
97+
});
98+
99+
describe('stopPollingForSessionToken', () => {
100+
it('allows restart after stop', async () => {
101+
const sharedLock: SafeLockReturn = {
102+
acquireLockAndRun: vi.fn().mockResolvedValue(undefined),
103+
};
104+
105+
const poller = new SessionCookiePoller(sharedLock);
106+
const callback = vi.fn().mockResolvedValue(undefined);
107+
108+
// Start and stop
109+
poller.startPollingForSessionToken(callback);
110+
poller.stopPollingForSessionToken();
111+
112+
// Clear mock to check restart
113+
vi.mocked(sharedLock.acquireLockAndRun).mockClear();
114+
115+
// Should be able to start again
116+
poller.startPollingForSessionToken(callback);
117+
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(1);
118+
119+
poller.stopPollingForSessionToken();
120+
});
121+
});
122+
123+
describe('polling interval', () => {
124+
it('schedules next poll after callback completes', async () => {
125+
const sharedLock: SafeLockReturn = {
126+
acquireLockAndRun: vi.fn().mockResolvedValue(undefined),
127+
};
128+
129+
const poller = new SessionCookiePoller(sharedLock);
130+
const callback = vi.fn().mockResolvedValue(undefined);
131+
132+
poller.startPollingForSessionToken(callback);
133+
134+
// Initial call
135+
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(1);
136+
137+
// Wait for first interval (5 seconds)
138+
await vi.advanceTimersByTimeAsync(5000);
139+
140+
// Should have scheduled another call
141+
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(2);
142+
143+
poller.stopPollingForSessionToken();
144+
});
145+
});
146+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
import type { SafeLockReturn } from '../safeLock';
4+
import { SafeLock } from '../safeLock';
5+
6+
describe('SafeLock', () => {
7+
describe('interface contract', () => {
8+
it('returns SafeLockReturn interface with acquireLockAndRun method', () => {
9+
const lock = SafeLock('test-interface');
10+
11+
expect(lock).toHaveProperty('acquireLockAndRun');
12+
expect(typeof lock.acquireLockAndRun).toBe('function');
13+
});
14+
15+
it('SafeLockReturn type allows creating mock implementations', () => {
16+
// This test verifies the type interface works correctly for mocking
17+
const mockLock: SafeLockReturn = {
18+
acquireLockAndRun: vi.fn().mockResolvedValue('mock-result'),
19+
};
20+
21+
expect(mockLock.acquireLockAndRun).toBeDefined();
22+
});
23+
});
24+
25+
describe('Web Locks API path', () => {
26+
it('uses Web Locks API when available in secure context', async () => {
27+
// Skip if Web Locks not available (like in jsdom without polyfill)
28+
if (!('locks' in navigator) || !navigator.locks) {
29+
return;
30+
}
31+
32+
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
33+
const lock = SafeLock('test-weblocks-' + Date.now());
34+
const callback = vi.fn().mockResolvedValue('web-locks-result');
35+
36+
const result = await lock.acquireLockAndRun(callback);
37+
38+
expect(callback).toHaveBeenCalled();
39+
expect(result).toBe('web-locks-result');
40+
// Verify cleanup happened
41+
expect(clearTimeoutSpy).toHaveBeenCalled();
42+
43+
clearTimeoutSpy.mockRestore();
44+
});
45+
});
46+
47+
describe('shared lock pattern', () => {
48+
it('allows multiple components to share a lock via SafeLockReturn interface', async () => {
49+
// This demonstrates how AuthCookieService shares a lock between poller and focus handler
50+
const executionLog: string[] = [];
51+
52+
const sharedLock: SafeLockReturn = {
53+
acquireLockAndRun: vi.fn().mockImplementation(async (cb: () => Promise<unknown>) => {
54+
executionLog.push('lock-acquired');
55+
const result = await cb();
56+
executionLog.push('lock-released');
57+
return result;
58+
}),
59+
};
60+
61+
// Simulate poller using the lock
62+
await sharedLock.acquireLockAndRun(() => {
63+
executionLog.push('poller-callback');
64+
return Promise.resolve('poller-done');
65+
});
66+
67+
// Simulate focus handler using the same lock
68+
await sharedLock.acquireLockAndRun(() => {
69+
executionLog.push('focus-callback');
70+
return Promise.resolve('focus-done');
71+
});
72+
73+
expect(executionLog).toEqual([
74+
'lock-acquired',
75+
'poller-callback',
76+
'lock-released',
77+
'lock-acquired',
78+
'focus-callback',
79+
'lock-released',
80+
]);
81+
});
82+
83+
it('mock lock can simulate sequential execution', async () => {
84+
const results: string[] = [];
85+
86+
// Create a mock that simulates sequential lock behavior
87+
const sharedLock: SafeLockReturn = {
88+
acquireLockAndRun: vi.fn().mockImplementation(async (cb: () => Promise<unknown>) => {
89+
const result = await cb();
90+
results.push(result as string);
91+
return result;
92+
}),
93+
};
94+
95+
// Both "tabs" try to refresh
96+
const promise1 = sharedLock.acquireLockAndRun(() => Promise.resolve('tab1-result'));
97+
const promise2 = sharedLock.acquireLockAndRun(() => Promise.resolve('tab2-result'));
98+
99+
await Promise.all([promise1, promise2]);
100+
101+
expect(results).toContain('tab1-result');
102+
expect(results).toContain('tab2-result');
103+
expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(2);
104+
});
105+
});
106+
});

packages/clerk-js/src/core/auth/safeLock.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,45 @@
11
import Lock from 'browser-tabs-lock';
22

3+
/**
4+
* Return type for SafeLock providing cross-tab lock coordination.
5+
*/
36
export interface SafeLockReturn {
7+
/**
8+
* Acquires a cross-tab lock and executes the callback while holding it.
9+
* Other tabs attempting to acquire the same lock will wait until this callback completes.
10+
*
11+
* @param cb - Async callback to execute while holding the lock
12+
* @returns The callback's return value, or `false` if lock acquisition times out
13+
*/
414
acquireLockAndRun: (cb: () => Promise<unknown>) => Promise<unknown>;
515
}
616

17+
/**
18+
* Creates a cross-tab lock mechanism for coordinating exclusive operations across browser tabs.
19+
*
20+
* This is used to prevent multiple tabs from performing the same operation simultaneously,
21+
* such as refreshing session tokens. When one tab holds the lock, other tabs will wait
22+
* until the lock is released before proceeding.
23+
*
24+
* @param key - Shared identifier for the lock
25+
* @returns SafeLockReturn with acquireLockAndRun method
26+
*
27+
* @example
28+
* ```typescript
29+
* const tokenLock = SafeLock('clerk.lock.refreshToken');
30+
*
31+
* // In Tab 1:
32+
* await tokenLock.acquireLockAndRun(async () => {
33+
* await refreshToken(); // Only one tab executes this at a time
34+
* });
35+
*
36+
* // Tab 2 will wait for Tab 1 to finish before executing its callback
37+
* ```
38+
*/
739
export function SafeLock(key: string): SafeLockReturn {
840
const lock = new Lock();
941

10-
// TODO: Figure out how to fix this linting error
42+
// Release any held locks when the tab is closing to prevent deadlocks
1143
// eslint-disable-next-line @typescript-eslint/no-misused-promises
1244
window.addEventListener('beforeunload', async () => {
1345
await lock.releaseLock(key);
@@ -17,13 +49,15 @@ export function SafeLock(key: string): SafeLockReturn {
1749
if ('locks' in navigator && isSecureContext) {
1850
const controller = new AbortController();
1951
const lockTimeout = setTimeout(() => controller.abort(), 4999);
52+
2053
return await navigator.locks
2154
.request(key, { signal: controller.signal }, async () => {
2255
clearTimeout(lockTimeout);
2356
return await cb();
2457
})
2558
.catch(() => {
26-
// browser-tabs-lock never seems to throw, so we are mirroring the behavior here
59+
// Lock request was aborted (timeout) or failed
60+
// Return false to indicate lock was not acquired (matches browser-tabs-lock behavior)
2761
return false;
2862
});
2963
}
@@ -35,6 +69,8 @@ export function SafeLock(key: string): SafeLockReturn {
3569
await lock.releaseLock(key);
3670
}
3771
}
72+
73+
return false;
3874
};
3975

4076
return { acquireLockAndRun };

0 commit comments

Comments
 (0)