diff --git a/plans/versioned-localstorage-session-tangents.md b/plans/versioned-localstorage-session-tangents.md index 0c75551cae..631a793bf6 100644 --- a/plans/versioned-localstorage-session-tangents.md +++ b/plans/versioned-localstorage-session-tangents.md @@ -77,31 +77,21 @@ No action. This whole-root localStorage read-modify-write race predates the vers Compatibility reference: [MDN `Navigator.locks`](https://developer.mozilla.org/docs/Web/API/Navigator/locks). -## 3. Enforce the session lock invariant +## 3. Keep lock ownership inside persistence ### Problem -`writeSession()` performs the conditional session read-modify-write but does not acquire the persisted-storage lock itself. Current session persistence callsites run it inside `runWithCredentialLock()`, which aliases the root storage lock, but this is a convention rather than an enforced API invariant. +`writeSession()` previously relied on every caller to enter the persisted-storage lock before dispatching a session action. The lock ownership was transitive and invisible at the write API, so a future direct caller could silently bypass serialization. -The lock cannot simply be added inside `writeSession()` while callers retain the outer lock because Web Locks are not reentrant. A nested request for the same exclusive lock would deadlock. +### Resolution -A future caller could accidentally do this: +`writeSession()` now owns the persisted-storage lock on web. Its lock callback contains exactly the shared-state commit: -```ts -await persisted.writeSession({ - nextSession, - credentialMutations, -}) -``` +1. read the authoritative root from localStorage; +2. conditionally merge the session mutation; +3. write the updated root; and +4. broadcast the committed update. -without first entering the root lock. +Session callsites no longer acquire the lock. Network requests, reducer work, expiry rescue, and local bundle reconciliation remain outside it. Native retains its existing serialized AsyncStorage queue inside `writeSession()`. -### Possible direction - -Potential enforcement options include: - -1. Add a development assertion tracking whether the current realm is inside `runWithPersistedStorageLock()`. -2. Expose the unlocked commit only through a capability passed to the lock callback. -3. Move lock ownership into a higher-level session transaction API that performs the authoritative read, reconciliation, and write together. - -Any enforcement should preserve the existing requirement that network refreshes happen outside the lock. +This makes every call to `writeSession()` safe by construction, avoids non-reentrant nested Web Locks, and keeps the lock scoped to the operation that actually needs cross-tab exclusion. diff --git a/plans/versioned-localstorage-sessions.md b/plans/versioned-localstorage-sessions.md index 9ade8979c0..aa98016560 100644 --- a/plans/versioned-localstorage-sessions.md +++ b/plans/versioned-localstorage-sessions.md @@ -134,7 +134,7 @@ Tab A sends refresh token A to the PDS Tab A receives successor generation B ``` -Before committing the response, Tab A acquires the credential lock and synchronously rereads localStorage. The source of truth may have changed while its network request was in flight. +Before committing the response, `writeSession()` acquires the persisted-storage lock and synchronously rereads localStorage. The source of truth may have changed while the network request was in flight. ### Case 1: nothing else changed @@ -276,16 +276,14 @@ Tab A plans: write version 9 Tab B plans: write version 9 ``` -Network refreshes do not run while holding a Web Lock. A refresh captures the generation it uses, performs the network request, and acquires the lock only to reconcile and commit its result: +Network refreshes do not run while holding a Web Lock. A refresh captures the generation it uses and performs the network request first. The resulting session mutation is then passed to `writeSession()`, which owns the lock around reconciliation and persistence: -```ts -const baseRefreshJti = getRefreshJti(session.refreshJwt) -const refreshed = await refreshSession() - -await navigator.locks.request('bsky-persisted-storage', async () => { - const latest = readAccountFromLocalStorage(did) - // Commit only if latest is active and still has baseRefreshJti. -}) +```text +Capture refresh generation A +Perform network refresh A -> B +Call writeSession with base A and result B +writeSession acquires the persisted-storage lock +writeSession rereads, conditionally merges, and persists ``` This avoids holding a cross-tab lock over network I/O. Multiple refresh requests may be in flight concurrently; server-side convergence and the generation-specific conditional commit make their results safe. diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts index 2caea8a672..30c9dff802 100644 --- a/src/state/persisted/index.ts +++ b/src/state/persisted/index.ts @@ -16,10 +16,6 @@ import {type PersistedApi} from './types' import {normalizeData} from './util' export type {SessionCredentialMutation} from './session-merge' -export { - runWithPersistedStorageLock as runWithCredentialLock, - runWithPersistedStorageLock, -} from './storage-lock' export type {PersistedAccount, Schema} from '#/state/persisted/schema' export {defaults} from '#/state/persisted/schema' @@ -78,6 +74,7 @@ export function write( } write satisfies PersistedApi['write'] +/** Queue a conditional session merge with other root storage writes. */ export function writeSession({ nextSession, credentialMutations, diff --git a/src/state/persisted/index.web.ts b/src/state/persisted/index.web.ts index 508d9db0eb..124c019b14 100644 --- a/src/state/persisted/index.web.ts +++ b/src/state/persisted/index.web.ts @@ -17,10 +17,6 @@ import {type PersistedApi} from './types' import {normalizeData} from './util' export type {SessionCredentialMutation} from './session-merge' -export { - runWithPersistedStorageLock as runWithCredentialLock, - runWithPersistedStorageLock, -} from './storage-lock' export type {PersistedAccount, Schema} from '#/state/persisted/schema' export {defaults} from '#/state/persisted/schema' @@ -112,8 +108,8 @@ export function write( } write satisfies PersistedApi['write'] -// eslint-disable-next-line @typescript-eslint/require-await -export async function writeSession({ +/** Commit a conditional session merge while holding the root storage lock. */ +export function writeSession({ nextSession, credentialMutations, currentAccountDid, @@ -122,19 +118,23 @@ export async function writeSession({ credentialMutations: SessionCredentialMutation[] currentAccountDid?: string }): Promise { - const stored = readFromStorage() ?? _state - const session = applySessionUpdate({ - storedSession: stored.session, - nextSession, - credentialMutations, - currentAccountDid, - }) - const updated = normalizeData({...stored, session}) - writeToStorage(updated) - _state = updated + return runWithPersistedStorageLock({ + operation: () => { + const stored = readFromStorage() ?? _state + const session = applySessionUpdate({ + storedSession: stored.session, + nextSession, + credentialMutations, + currentAccountDid, + }) + const updated = normalizeData({...stored, session}) + writeToStorage(updated) + _state = updated - broadcastUpdate({key: 'session'}) - return session + broadcastUpdate({key: 'session'}) + return session + }, + }) } writeSession satisfies PersistedApi['writeSession'] diff --git a/src/state/persisted/storage-lock.ts b/src/state/persisted/storage-lock.ts index 35201a7aab..1fb4ef3e65 100644 --- a/src/state/persisted/storage-lock.ts +++ b/src/state/persisted/storage-lock.ts @@ -1,5 +1,3 @@ -import {type PersistedApi} from './types' - export function runWithPersistedStorageLock({ operation, }: { @@ -15,4 +13,3 @@ export function runWithPersistedStorageLock({ ) } } -runWithPersistedStorageLock satisfies PersistedApi['runWithPersistedStorageLock'] diff --git a/src/state/persisted/storage-lock.web.ts b/src/state/persisted/storage-lock.web.ts index 8023caa7a1..922f9f81c0 100644 --- a/src/state/persisted/storage-lock.web.ts +++ b/src/state/persisted/storage-lock.web.ts @@ -1,5 +1,3 @@ -import {type PersistedApi} from './types' - const PERSISTED_STORAGE_LOCK = 'bsky-persisted-storage' export function runWithPersistedStorageLock({ @@ -22,8 +20,6 @@ export function runWithPersistedStorageLock({ return lockManager.request(PERSISTED_STORAGE_LOCK, operation) } -runWithPersistedStorageLock satisfies PersistedApi['runWithPersistedStorageLock'] - function getLockManager(): LockManager | undefined { if (typeof navigator === 'undefined' || !('locks' in navigator)) { return undefined diff --git a/src/state/persisted/types.ts b/src/state/persisted/types.ts index 5f455fef16..13859ed23e 100644 --- a/src/state/persisted/types.ts +++ b/src/state/persisted/types.ts @@ -21,9 +21,6 @@ export type PersistedApi = { /** Omit to preserve the current account read from persisted storage. */ currentAccountDid?: string }): Promise - runWithPersistedStorageLock(args: { - operation: () => T | Promise - }): Promise onUpdate( key: K, cb: (v: Schema[K]) => void, diff --git a/src/state/session/__tests__/provider-abort-test.tsx b/src/state/session/__tests__/provider-abort-test.tsx index 21d6cdde7d..3de2bb7c66 100644 --- a/src/state/session/__tests__/provider-abort-test.tsx +++ b/src/state/session/__tests__/provider-abort-test.tsx @@ -17,8 +17,6 @@ jest.mock('#/state/persisted', () => { readLatest: () => defaults.session, writeSession: ({nextSession}: {nextSession: typeof defaults.session}) => Promise.resolve(nextSession), - runWithCredentialLock: ({operation}: {operation: () => unknown}) => - Promise.resolve(operation()), onUpdate: () => () => {}, } }) diff --git a/src/state/session/__tests__/provider-clients-test.tsx b/src/state/session/__tests__/provider-clients-test.tsx index 0b526a750e..efadc5b559 100644 --- a/src/state/session/__tests__/provider-clients-test.tsx +++ b/src/state/session/__tests__/provider-clients-test.tsx @@ -21,8 +21,6 @@ jest.mock('#/state/persisted', () => { readLatest: () => defaults.session, writeSession: ({nextSession}: {nextSession: typeof defaults.session}) => Promise.resolve(nextSession), - runWithCredentialLock: ({operation}: {operation: () => unknown}) => - Promise.resolve(operation()), onUpdate: () => () => {}, } }) diff --git a/src/state/session/__tests__/provider-refresh-session-test.tsx b/src/state/session/__tests__/provider-refresh-session-test.tsx index 9733082463..08300212f0 100644 --- a/src/state/session/__tests__/provider-refresh-session-test.tsx +++ b/src/state/session/__tests__/provider-refresh-session-test.tsx @@ -20,8 +20,6 @@ jest.mock('#/state/persisted', () => { readLatest: () => defaults.session, writeSession: ({nextSession}: {nextSession: typeof defaults.session}) => Promise.resolve(nextSession), - runWithCredentialLock: ({operation}: {operation: () => unknown}) => - Promise.resolve(operation()), onUpdate: () => () => {}, } }) diff --git a/src/state/session/__tests__/provider-session-events-test.tsx b/src/state/session/__tests__/provider-session-events-test.tsx index 4bf0c99e25..e78c296d31 100644 --- a/src/state/session/__tests__/provider-session-events-test.tsx +++ b/src/state/session/__tests__/provider-session-events-test.tsx @@ -49,8 +49,6 @@ jest.mock('#/state/persisted', () => ({ mockPersisted.latest = committed return Promise.resolve(committed) }, - runWithCredentialLock: ({operation}: {operation: () => unknown}) => - Promise.resolve(operation()), onUpdate: (_key: 'session', callback: (value: Schema['session']) => void) => { mockPersistedListeners.push(callback) return () => {} diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 5fd574160e..5419fe8f45 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -161,7 +161,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const onSessionChangeRef = useRef(null) const onSessionChange = useCallback( - ( + async ( bundle: SessionBundle, accountDid: string, sessionEvent: AtpSessionEvent, @@ -172,164 +172,159 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ? bundle.session.session.refreshJwt : sessionData?.refreshJwt - return persistedSession.runWithCredentialLock({ - operation: async () => { - /* - * Only the live bundle may reset the expiry-rescue bookkeeping: a stale - * bundle's late update would otherwise clear the failed-generation set - * that bounds the rescue loop. (Its dispatch below is separately dropped - * by the reducer's identity guard.) - */ - if ( - sessionEvent === 'update' && - sessionData && - (store.getState().currentBundleState.bundle as unknown as - SessionBundle | PublicSessionBundle) === bundle - ) { - failedExpiryTokensRef.current.get(accountDid)?.clear() - } + /* + * Only the live bundle may reset the expiry-rescue bookkeeping: a stale + * bundle's late update would otherwise clear the failed-generation set + * that bounds the rescue loop. (Its dispatch below is separately dropped + * by the reducer's identity guard.) + */ + if ( + sessionEvent === 'update' && + sessionData && + (store.getState().currentBundleState.bundle as unknown as + SessionBundle | PublicSessionBundle) === bundle + ) { + failedExpiryTokensRef.current.get(accountDid)?.clear() + } - /* - * PasswordSession invokes its hooks before updating its live getter. Use - * the delivered payload so a refresh persists the newly rotated tokens. - * - * A refresh payload carries no didDoc unless the server sends one, so the - * stored account's `pdsUrl` is threaded in as the fallback. Without it the - * refresh would persist `pdsUrl: undefined` and the next cold start would - * route pre-refresh requests to the entryway instead of the PDS. - */ - const refreshedAccount = - sessionEvent === 'update' && sessionData - ? sessionDataToSessionAccount( - sessionData, - sessionData.service, - store.getState().accounts.find(a => a.did === accountDid) - ?.pdsUrl, - ) - : undefined - - /* - * A stale tab may expire a token after another tab has already rotated it. - * Prefer a newer persisted or reducer generation over logging every tab - * out. Failed generations are recorded and bounded to guarantee that a - * repeatedly expiring session eventually falls through to logout. - */ - if (sessionEvent === 'expired') { - const current = store.getState() - const currentBundle = current.currentBundleState - .bundle as unknown as SessionBundle | PublicSessionBundle - const dyingRefreshJwt = sessionData?.refreshJwt - // Stale bundle events are handled by the reducer's identity guard. - if ( - currentBundle === bundle && - current.currentBundleState.did === accountDid && - dyingRefreshJwt - ) { - let failedSet = failedExpiryTokensRef.current.get(accountDid) - if (!failedSet) { - failedSet = new Set() - failedExpiryTokensRef.current.set(accountDid, failedSet) - } - failedSet.add(dyingRefreshJwt) - - const persistedCandidate = persistedSession - .readLatest('session') - .accounts.find(a => a.did === accountDid) - const reducerCandidate = current.accounts.find( - a => a.did === accountDid, - ) - const candidate = pickExpiryRescueCandidate({ - dyingRefreshJwt, - candidates: [persistedCandidate, reducerCandidate], - failedRefreshJwts: failedSet, - }) - - if (candidate) { - const rebuilt = createSessionBundleFromStoredAccount( - candidate, - onSessionChangeRef.current!, - ) - if (rebuilt) { - await store.dispatch({ - type: 'replaced-current-bundle', - newBundle: rebuilt.bundle, - newAccount: rebuilt.account, - }) - return - } - } - } - } - - // Only the current bundle may report that its session was dropped. - if ( - sessionEvent === 'expired' && - store.getState().currentBundleState.bundle === bundle - ) { - emitSessionDropped() - } - - const credentialMutations: SessionCredentialMutation[] = [] - if (sessionEvent === 'update' && refreshedAccount) { - credentialMutations.push({ - type: 'refresh', - accountDid, - baseRefreshJwt, - resultRefreshJwt: refreshedAccount.refreshJwt, - }) - } else if (sessionEvent === 'expired') { - credentialMutations.push({ - type: 'expire', - accountDid, - baseRefreshJwt, - }) - } - - // Bundle identity prevents stale sessions from changing the active account. - const committedSession = await store.dispatch( - { - type: 'received-session-event', - bundle, - refreshedAccount, - accountDid, - sessionEvent, - }, - credentialMutations, - ) - - if (sessionEvent === 'update' && committedSession) { - const committedAccount = committedSession.accounts.find( - account => account.did === accountDid, + /* + * PasswordSession invokes its hooks before updating its live getter. Use + * the delivered payload so a refresh persists the newly rotated tokens. + * + * A refresh payload carries no didDoc unless the server sends one, so the + * stored account's `pdsUrl` is threaded in as the fallback. Without it the + * refresh would persist `pdsUrl: undefined` and the next cold start would + * route pre-refresh requests to the entryway instead of the PDS. + */ + const refreshedAccount = + sessionEvent === 'update' && sessionData + ? sessionDataToSessionAccount( + sessionData, + sessionData.service, + store.getState().accounts.find(a => a.did === accountDid)?.pdsUrl, ) - if ( - committedAccount?.refreshJwt && - committedAccount.refreshJwt !== refreshedAccount?.refreshJwt && - store.getState().currentBundleState.bundle === bundle - ) { - const rebuilt = createSessionBundleFromStoredAccount( - committedAccount, - onSessionChangeRef.current!, - ) - if (rebuilt) { - await store.dispatch({ - type: 'replaced-current-bundle', - newBundle: rebuilt.bundle, - newAccount: rebuilt.account, - }) - } - } else if ( - !committedAccount?.refreshJwt && - store.getState().currentBundleState.bundle === bundle - ) { + : undefined + + /* + * A stale tab may expire a token after another tab has already rotated it. + * Prefer a newer persisted or reducer generation over logging every tab + * out. Failed generations are recorded and bounded to guarantee that a + * repeatedly expiring session eventually falls through to logout. + */ + if (sessionEvent === 'expired') { + const current = store.getState() + const currentBundle = current.currentBundleState.bundle as unknown as + SessionBundle | PublicSessionBundle + const dyingRefreshJwt = sessionData?.refreshJwt + // Stale bundle events are handled by the reducer's identity guard. + if ( + currentBundle === bundle && + current.currentBundleState.did === accountDid && + dyingRefreshJwt + ) { + let failedSet = failedExpiryTokensRef.current.get(accountDid) + if (!failedSet) { + failedSet = new Set() + failedExpiryTokensRef.current.set(accountDid, failedSet) + } + failedSet.add(dyingRefreshJwt) + + const persistedCandidate = persistedSession + .readLatest('session') + .accounts.find(a => a.did === accountDid) + const reducerCandidate = current.accounts.find( + a => a.did === accountDid, + ) + const candidate = pickExpiryRescueCandidate({ + dyingRefreshJwt, + candidates: [persistedCandidate, reducerCandidate], + failedRefreshJwts: failedSet, + }) + + if (candidate) { + const rebuilt = createSessionBundleFromStoredAccount( + candidate, + onSessionChangeRef.current!, + ) + if (rebuilt) { await store.dispatch({ - type: 'synced-accounts', - syncedAccounts: committedSession.accounts, - syncedCurrentDid: committedSession.currentAccount?.did, + type: 'replaced-current-bundle', + newBundle: rebuilt.bundle, + newAccount: rebuilt.account, }) + return } } + } + } + + // Only the current bundle may report that its session was dropped. + if ( + sessionEvent === 'expired' && + store.getState().currentBundleState.bundle === bundle + ) { + emitSessionDropped() + } + + const credentialMutations: SessionCredentialMutation[] = [] + if (sessionEvent === 'update' && refreshedAccount) { + credentialMutations.push({ + type: 'refresh', + accountDid, + baseRefreshJwt, + resultRefreshJwt: refreshedAccount.refreshJwt, + }) + } else if (sessionEvent === 'expired') { + credentialMutations.push({ + type: 'expire', + accountDid, + baseRefreshJwt, + }) + } + + // Bundle identity prevents stale sessions from changing the active account. + const committedSession = await store.dispatch( + { + type: 'received-session-event', + bundle, + refreshedAccount, + accountDid, + sessionEvent, }, - }) + credentialMutations, + ) + + if (sessionEvent === 'update' && committedSession) { + const committedAccount = committedSession.accounts.find( + account => account.did === accountDid, + ) + if ( + committedAccount?.refreshJwt && + committedAccount.refreshJwt !== refreshedAccount?.refreshJwt && + store.getState().currentBundleState.bundle === bundle + ) { + const rebuilt = createSessionBundleFromStoredAccount( + committedAccount, + onSessionChangeRef.current!, + ) + if (rebuilt) { + await store.dispatch({ + type: 'replaced-current-bundle', + newBundle: rebuilt.bundle, + newAccount: rebuilt.account, + }) + } + } else if ( + !committedAccount?.refreshJwt && + store.getState().currentBundleState.bundle === bundle + ) { + await store.dispatch({ + type: 'synced-accounts', + syncedAccounts: committedSession.accounts, + syncedCurrentDid: committedSession.currentAccount?.did, + }) + } + } }, [store], ) @@ -358,23 +353,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) { disposeBundle(bundle) return } - await persistedSession.runWithCredentialLock({ - operation: () => - store.dispatch( - { - type: 'switched-to-account', - newBundle: bundle, - newAccount: account, - }, - [ - { - type: 'login', - accountDid: account.did, - resultRefreshJwt: account.refreshJwt, - }, - ], - ), - }) + await store.dispatch( + { + type: 'switched-to-account', + newBundle: bundle, + newAccount: account, + }, + [ + { + type: 'login', + accountDid: account.did, + resultRefreshJwt: account.refreshJwt, + }, + ], + ) ax.metric('account:create:success', metrics, { session: utils.accountToSessionMetadata(account), }) @@ -401,23 +393,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) { disposeBundle(bundle) return } - await persistedSession.runWithCredentialLock({ - operation: () => - store.dispatch( - { - type: 'switched-to-account', - newBundle: bundle, - newAccount: account, - }, - [ - { - type: 'login', - accountDid: account.did, - resultRefreshJwt: account.refreshJwt, - }, - ], - ), - }) + await store.dispatch( + { + type: 'switched-to-account', + newBundle: bundle, + newAccount: account, + }, + [ + { + type: 'login', + accountDid: account.did, + resultRefreshJwt: account.refreshJwt, + }, + ], + ) ax.metric( 'account:loggedIn', {logContext, withPassword: true}, @@ -441,13 +430,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const prevState = store.getState() const accountDid = prevState.currentBundleState.did if (accountDid) { - void persistedSession - .runWithCredentialLock({ - operation: () => - store.dispatch({type: 'logged-out-current-account', accountDid}, [ - {type: 'logout', accountDid}, - ]), - }) + void store + .dispatch({type: 'logged-out-current-account', accountDid}, [ + {type: 'logout', accountDid}, + ]) .catch(() => {}) } ax.metric( @@ -489,17 +475,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) { .accounts.map(account => account.did), ]), ] - void persistedSession - .runWithCredentialLock({ - operation: () => - store.dispatch( - {type: 'logged-out-every-account'}, - accountDids.map(accountDid => ({ - type: 'logout' as const, - accountDid, - })), - ), - }) + void store + .dispatch( + {type: 'logged-out-every-account'}, + accountDids.map(accountDid => ({ + type: 'logout' as const, + accountDid, + })), + ) .catch(() => {}) ax.metric( 'account:loggedOut', @@ -556,24 +539,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) { disposeBundle(bundle) return } - const committedSession = await persistedSession.runWithCredentialLock({ - operation: () => - store.dispatch( - { - type: 'switched-to-account', - newBundle: bundle, - newAccount: account, - }, - [ - { - type: 'refresh', - accountDid: account.did, - baseRefreshJwt: latestStoredAccount.refreshJwt, - resultRefreshJwt: account.refreshJwt, - }, - ], - ), - }) + const committedSession = await store.dispatch( + { + type: 'switched-to-account', + newBundle: bundle, + newAccount: account, + }, + [ + { + type: 'refresh', + accountDid: account.did, + baseRefreshJwt: latestStoredAccount.refreshJwt, + resultRefreshJwt: account.refreshJwt, + }, + ], + ) const committedAccount = committedSession?.accounts.find( candidate => candidate.did === account.did, ) @@ -625,21 +605,18 @@ export function Provider({children}: React.PropsWithChildren<{}>) { /* getSession targets the PDS; only the persisted account fields are patched. */ const data = await bundle.pdsClient.call(com.atproto.server.getSession, {}) if (signal.aborted) return - await persistedSession.runWithCredentialLock({ - operation: () => - store.dispatch({ - type: 'partial-refresh-session', - /* - * Read the did off the response rather than the session: the bundle may - * have been disposed while the request was in flight, and the live - * getters throw in that state. - */ - accountDid: data.did, - patch: { - emailConfirmed: data.emailConfirmed, - emailAuthFactor: data.emailAuthFactor, - }, - }), + await store.dispatch({ + type: 'partial-refresh-session', + /* + * Read the did off the response rather than the session: the bundle may + * have been disposed while the request was in flight, and the live + * getters throw in that state. + */ + accountDid: data.did, + patch: { + emailConfirmed: data.emailConfirmed, + emailAuthFactor: data.emailAuthFactor, + }, }) }, [store, cancelPendingTask]) @@ -711,17 +688,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) { account: redactAccount(account), }) cancelPendingTask() - void persistedSession - .runWithCredentialLock({ - operation: () => - store.dispatch( - { - type: 'removed-account', - accountDid: account.did, - }, - [{type: 'remove', accountDid: account.did}], - ), - }) + void store + .dispatch( + { + type: 'removed-account', + accountDid: account.did, + }, + [{type: 'remove', accountDid: account.did}], + ) .catch(() => {}) addSessionDebugLog({ type: 'method:end',