Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 2 additions & 10 deletions packages/core/auth-js/src/lib/locks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,18 +191,10 @@ export async function navigatorLock<R>(
} catch (e) {
// Always clear the acquire timeout once the request settles, so it cannot
// fire later and incorrectly abort/log after a rejection.
if (acquireTimeout > 0) {
clearTimeout(acquireTimeoutTimer)
}
clearTimeout(acquireTimeoutTimer)

// DOMException does not extend Error in Node.js, so use structural check
if (
e !== null &&
typeof e === 'object' &&
'name' in e &&
e.name === 'AbortError' &&
acquireTimeout > 0
) {
if (e !== null && typeof e === 'object' && 'name' in e && e.name === 'AbortError') {
if (abortController.signal.aborted) {
// OUR timeout fired — the lock is genuinely orphaned. Steal it.
//
Expand Down
27 changes: 27 additions & 0 deletions packages/core/auth-js/test/lib/locks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,33 @@ describe('navigatorLock', () => {
// Should only be called once (no steal retry for negative timeout)
expect(globalThis.navigator.locks.request).toHaveBeenCalledTimes(1)
})

it('should convert stolen-lock AbortError to typed error when acquireTimeout is 0', async () => {
// acquireTimeout=0 uses { ifAvailable: true } with no abort signal, so a
// steal by another request rejects with a raw AbortError. It should be
// converted to NavigatorLockAcquireTimeoutError instead of leaking.
const mockFn = jest.fn(async () => 'result')
; (globalThis.navigator.locks.request as jest.Mock).mockImplementation(
(_name: string, options: any, callback: (lock: any) => Promise<any>) => {
expect(options.ifAvailable).toBe(true)
// Invoke callback so fn() runs (we held the lock)
callback({ name: 'test' })
// Outer promise rejects independently — another request stole the lock
return Promise.reject(
new DOMException("Lock broken by another request with the 'steal' option.", 'AbortError')
)
}
)

await expect(navigatorLock('test', 0, mockFn)).rejects.toMatchObject({
isAcquireTimeout: true,
})

// fn() ran exactly once (while we held the lock) — no steal-back re-execution
expect(mockFn).toHaveBeenCalledTimes(1)
// Must NOT have tried to steal back (no second call to navigator.locks.request)
expect(globalThis.navigator.locks.request).toHaveBeenCalledTimes(1)
})
})

describe('processLock', () => {
Expand Down