diff --git a/plans/versioned-localstorage-sessions.md b/plans/versioned-localstorage-sessions.md index 961afc45a1..b1069aae5b 100644 --- a/plans/versioned-localstorage-sessions.md +++ b/plans/versioned-localstorage-sessions.md @@ -287,6 +287,8 @@ All credential-changing commits use the same per-account lock: Because all persisted values share one localStorage blob, every write also takes a root persisted-storage lock. The root lock prevents an unrelated preference write from racing the session read-modify-write; the per-account lock expresses credential ownership and gives account operations a consistent order. +Feature-detect the Web Locks API. If `navigator.locks.request` is unavailable, run the operation without a lock rather than failing startup or session operations. Generation-specific conditional commits still reject stale work in this fallback mode, but localStorage read-modify-write is not fully serialized across tabs. + If Tab A holds the locks, Tab B waits. Once Tab A writes and releases them, Tab B acquires them and rereads Tab A's new localStorage state before deciding what to commit. The complete refresh flow is: diff --git a/src/state/persisted/__tests__/session-lock.web-test.ts b/src/state/persisted/__tests__/session-lock.web-test.ts new file mode 100644 index 0000000000..6d9aafdc1d --- /dev/null +++ b/src/state/persisted/__tests__/session-lock.web-test.ts @@ -0,0 +1,42 @@ +import {afterEach, describe, expect, it, jest} from '@jest/globals' + +import {runWithSessionCredentialLock} from '../session-lock.web' + +const originalNavigatorDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'navigator', +) + +function setNavigator(value: unknown) { + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value, + }) +} + +afterEach(() => { + if (originalNavigatorDescriptor) { + Object.defineProperty(globalThis, 'navigator', originalNavigatorDescriptor) + } else { + Reflect.deleteProperty(globalThis, 'navigator') + } +}) + +describe('session credential locks on unsupported browsers', () => { + it.each([ + ['navigator is unavailable', undefined], + ['navigator.locks is unavailable', {}], + ['navigator.locks.request is unavailable', {locks: {}}], + ])('runs without a lock when %s', async (_, navigatorValue) => { + setNavigator(navigatorValue) + const operation = jest.fn(() => 'result') + + await expect( + runWithSessionCredentialLock({ + accountDids: ['did:plc:example'], + operation, + }), + ).resolves.toBe('result') + expect(operation).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/state/persisted/session-lock.web.ts b/src/state/persisted/session-lock.web.ts index 26ce495cf7..153a95321b 100644 --- a/src/state/persisted/session-lock.web.ts +++ b/src/state/persisted/session-lock.web.ts @@ -8,7 +8,7 @@ export function runWithSessionCredentialLock({ accountDids: string[] operation: () => T | Promise }): Promise { - const lockManager = navigator.locks + const lockManager = getLockManager() if (!lockManager) { try { return Promise.resolve(operation()) @@ -35,3 +35,11 @@ export function runWithSessionCredentialLock({ return run(0) } + +function getLockManager(): LockManager | undefined { + if (typeof navigator === 'undefined' || !('locks' in navigator)) { + return undefined + } + const lockManager = navigator.locks + return typeof lockManager?.request === 'function' ? lockManager : undefined +}