Move session locking into persistence

This commit is contained in:
Eric Bailey
2026-08-31 14:58:38 -05:00
parent 5b823c3cf8
commit 732cf8cdd4
12 changed files with 259 additions and 318 deletions
@@ -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). 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 ### 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 1. read the authoritative root from localStorage;
await persisted.writeSession({ 2. conditionally merge the session mutation;
nextSession, 3. write the updated root; and
credentialMutations, 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 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.
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.
+8 -10
View File
@@ -134,7 +134,7 @@ Tab A sends refresh token A to the PDS
Tab A receives successor generation B 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 ### Case 1: nothing else changed
@@ -276,16 +276,14 @@ Tab A plans: write version 9
Tab B 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 ```text
const baseRefreshJti = getRefreshJti(session.refreshJwt) Capture refresh generation A
const refreshed = await refreshSession() Perform network refresh A -> B
Call writeSession with base A and result B
await navigator.locks.request('bsky-persisted-storage', async () => { writeSession acquires the persisted-storage lock
const latest = readAccountFromLocalStorage(did) writeSession rereads, conditionally merges, and persists
// Commit only if latest is active and still has baseRefreshJti.
})
``` ```
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. 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.
+1 -4
View File
@@ -16,10 +16,6 @@ import {type PersistedApi} from './types'
import {normalizeData} from './util' import {normalizeData} from './util'
export type {SessionCredentialMutation} from './session-merge' export type {SessionCredentialMutation} from './session-merge'
export {
runWithPersistedStorageLock as runWithCredentialLock,
runWithPersistedStorageLock,
} from './storage-lock'
export type {PersistedAccount, Schema} from '#/state/persisted/schema' export type {PersistedAccount, Schema} from '#/state/persisted/schema'
export {defaults} from '#/state/persisted/schema' export {defaults} from '#/state/persisted/schema'
@@ -78,6 +74,7 @@ export function write<K extends keyof Schema>(
} }
write satisfies PersistedApi['write'] write satisfies PersistedApi['write']
/** Queue a conditional session merge with other root storage writes. */
export function writeSession({ export function writeSession({
nextSession, nextSession,
credentialMutations, credentialMutations,
+18 -18
View File
@@ -17,10 +17,6 @@ import {type PersistedApi} from './types'
import {normalizeData} from './util' import {normalizeData} from './util'
export type {SessionCredentialMutation} from './session-merge' export type {SessionCredentialMutation} from './session-merge'
export {
runWithPersistedStorageLock as runWithCredentialLock,
runWithPersistedStorageLock,
} from './storage-lock'
export type {PersistedAccount, Schema} from '#/state/persisted/schema' export type {PersistedAccount, Schema} from '#/state/persisted/schema'
export {defaults} from '#/state/persisted/schema' export {defaults} from '#/state/persisted/schema'
@@ -112,8 +108,8 @@ export function write<K extends keyof Schema>(
} }
write satisfies PersistedApi['write'] write satisfies PersistedApi['write']
// eslint-disable-next-line @typescript-eslint/require-await /** Commit a conditional session merge while holding the root storage lock. */
export async function writeSession({ export function writeSession({
nextSession, nextSession,
credentialMutations, credentialMutations,
currentAccountDid, currentAccountDid,
@@ -122,19 +118,23 @@ export async function writeSession({
credentialMutations: SessionCredentialMutation[] credentialMutations: SessionCredentialMutation[]
currentAccountDid?: string currentAccountDid?: string
}): Promise<Schema['session']> { }): Promise<Schema['session']> {
const stored = readFromStorage() ?? _state return runWithPersistedStorageLock({
const session = applySessionUpdate({ operation: () => {
storedSession: stored.session, const stored = readFromStorage() ?? _state
nextSession, const session = applySessionUpdate({
credentialMutations, storedSession: stored.session,
currentAccountDid, nextSession,
}) credentialMutations,
const updated = normalizeData({...stored, session}) currentAccountDid,
writeToStorage(updated) })
_state = updated const updated = normalizeData({...stored, session})
writeToStorage(updated)
_state = updated
broadcastUpdate({key: 'session'}) broadcastUpdate({key: 'session'})
return session return session
},
})
} }
writeSession satisfies PersistedApi['writeSession'] writeSession satisfies PersistedApi['writeSession']
-3
View File
@@ -1,5 +1,3 @@
import {type PersistedApi} from './types'
export function runWithPersistedStorageLock<T>({ export function runWithPersistedStorageLock<T>({
operation, operation,
}: { }: {
@@ -15,4 +13,3 @@ export function runWithPersistedStorageLock<T>({
) )
} }
} }
runWithPersistedStorageLock satisfies PersistedApi['runWithPersistedStorageLock']
-4
View File
@@ -1,5 +1,3 @@
import {type PersistedApi} from './types'
const PERSISTED_STORAGE_LOCK = 'bsky-persisted-storage' const PERSISTED_STORAGE_LOCK = 'bsky-persisted-storage'
export function runWithPersistedStorageLock<T>({ export function runWithPersistedStorageLock<T>({
@@ -22,8 +20,6 @@ export function runWithPersistedStorageLock<T>({
return lockManager.request(PERSISTED_STORAGE_LOCK, operation) return lockManager.request(PERSISTED_STORAGE_LOCK, operation)
} }
runWithPersistedStorageLock satisfies PersistedApi['runWithPersistedStorageLock']
function getLockManager(): LockManager | undefined { function getLockManager(): LockManager | undefined {
if (typeof navigator === 'undefined' || !('locks' in navigator)) { if (typeof navigator === 'undefined' || !('locks' in navigator)) {
return undefined return undefined
-3
View File
@@ -21,9 +21,6 @@ export type PersistedApi = {
/** Omit to preserve the current account read from persisted storage. */ /** Omit to preserve the current account read from persisted storage. */
currentAccountDid?: string currentAccountDid?: string
}): Promise<Schema['session']> }): Promise<Schema['session']>
runWithPersistedStorageLock<T>(args: {
operation: () => T | Promise<T>
}): Promise<T>
onUpdate<K extends keyof Schema>( onUpdate<K extends keyof Schema>(
key: K, key: K,
cb: (v: Schema[K]) => void, cb: (v: Schema[K]) => void,
@@ -17,8 +17,6 @@ jest.mock('#/state/persisted', () => {
readLatest: () => defaults.session, readLatest: () => defaults.session,
writeSession: ({nextSession}: {nextSession: typeof defaults.session}) => writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
Promise.resolve(nextSession), Promise.resolve(nextSession),
runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
Promise.resolve(operation()),
onUpdate: () => () => {}, onUpdate: () => () => {},
} }
}) })
@@ -21,8 +21,6 @@ jest.mock('#/state/persisted', () => {
readLatest: () => defaults.session, readLatest: () => defaults.session,
writeSession: ({nextSession}: {nextSession: typeof defaults.session}) => writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
Promise.resolve(nextSession), Promise.resolve(nextSession),
runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
Promise.resolve(operation()),
onUpdate: () => () => {}, onUpdate: () => () => {},
} }
}) })
@@ -20,8 +20,6 @@ jest.mock('#/state/persisted', () => {
readLatest: () => defaults.session, readLatest: () => defaults.session,
writeSession: ({nextSession}: {nextSession: typeof defaults.session}) => writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
Promise.resolve(nextSession), Promise.resolve(nextSession),
runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
Promise.resolve(operation()),
onUpdate: () => () => {}, onUpdate: () => () => {},
} }
}) })
@@ -49,8 +49,6 @@ jest.mock('#/state/persisted', () => ({
mockPersisted.latest = committed mockPersisted.latest = committed
return Promise.resolve(committed) return Promise.resolve(committed)
}, },
runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
Promise.resolve(operation()),
onUpdate: (_key: 'session', callback: (value: Schema['session']) => void) => { onUpdate: (_key: 'session', callback: (value: Schema['session']) => void) => {
mockPersistedListeners.push(callback) mockPersistedListeners.push(callback)
return () => {} return () => {}
+222 -248
View File
@@ -161,7 +161,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const onSessionChangeRef = useRef<OnSessionChange | null>(null) const onSessionChangeRef = useRef<OnSessionChange | null>(null)
const onSessionChange = useCallback( const onSessionChange = useCallback(
( async (
bundle: SessionBundle, bundle: SessionBundle,
accountDid: string, accountDid: string,
sessionEvent: AtpSessionEvent, sessionEvent: AtpSessionEvent,
@@ -172,164 +172,159 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
? bundle.session.session.refreshJwt ? bundle.session.session.refreshJwt
: sessionData?.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
* Only the live bundle may reset the expiry-rescue bookkeeping: a stale * that bounds the rescue loop. (Its dispatch below is separately dropped
* bundle's late update would otherwise clear the failed-generation set * by the reducer's identity guard.)
* that bounds the rescue loop. (Its dispatch below is separately dropped */
* by the reducer's identity guard.) if (
*/ sessionEvent === 'update' &&
if ( sessionData &&
sessionEvent === 'update' && (store.getState().currentBundleState.bundle as unknown as
sessionData && SessionBundle | PublicSessionBundle) === bundle
(store.getState().currentBundleState.bundle as unknown as ) {
SessionBundle | PublicSessionBundle) === bundle failedExpiryTokensRef.current.get(accountDid)?.clear()
) { }
failedExpiryTokensRef.current.get(accountDid)?.clear()
}
/* /*
* PasswordSession invokes its hooks before updating its live getter. Use * PasswordSession invokes its hooks before updating its live getter. Use
* the delivered payload so a refresh persists the newly rotated tokens. * the delivered payload so a refresh persists the newly rotated tokens.
* *
* A refresh payload carries no didDoc unless the server sends one, so the * 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 * stored account's `pdsUrl` is threaded in as the fallback. Without it the
* refresh would persist `pdsUrl: undefined` and the next cold start would * refresh would persist `pdsUrl: undefined` and the next cold start would
* route pre-refresh requests to the entryway instead of the PDS. * route pre-refresh requests to the entryway instead of the PDS.
*/ */
const refreshedAccount = const refreshedAccount =
sessionEvent === 'update' && sessionData sessionEvent === 'update' && sessionData
? sessionDataToSessionAccount( ? sessionDataToSessionAccount(
sessionData, sessionData,
sessionData.service, sessionData.service,
store.getState().accounts.find(a => a.did === accountDid) store.getState().accounts.find(a => a.did === accountDid)?.pdsUrl,
?.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,
) )
if ( : undefined
committedAccount?.refreshJwt &&
committedAccount.refreshJwt !== refreshedAccount?.refreshJwt && /*
store.getState().currentBundleState.bundle === bundle * 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
const rebuilt = createSessionBundleFromStoredAccount( * out. Failed generations are recorded and bounded to guarantee that a
committedAccount, * repeatedly expiring session eventually falls through to logout.
onSessionChangeRef.current!, */
) if (sessionEvent === 'expired') {
if (rebuilt) { const current = store.getState()
await store.dispatch({ const currentBundle = current.currentBundleState.bundle as unknown as
type: 'replaced-current-bundle', SessionBundle | PublicSessionBundle
newBundle: rebuilt.bundle, const dyingRefreshJwt = sessionData?.refreshJwt
newAccount: rebuilt.account, // Stale bundle events are handled by the reducer's identity guard.
}) if (
} currentBundle === bundle &&
} else if ( current.currentBundleState.did === accountDid &&
!committedAccount?.refreshJwt && dyingRefreshJwt
store.getState().currentBundleState.bundle === bundle ) {
) { 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({ await store.dispatch({
type: 'synced-accounts', type: 'replaced-current-bundle',
syncedAccounts: committedSession.accounts, newBundle: rebuilt.bundle,
syncedCurrentDid: committedSession.currentAccount?.did, 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], [store],
) )
@@ -358,23 +353,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
disposeBundle(bundle) disposeBundle(bundle)
return return
} }
await persistedSession.runWithCredentialLock({ await store.dispatch(
operation: () => {
store.dispatch( type: 'switched-to-account',
{ newBundle: bundle,
type: 'switched-to-account', newAccount: account,
newBundle: bundle, },
newAccount: account, [
}, {
[ type: 'login',
{ accountDid: account.did,
type: 'login', resultRefreshJwt: account.refreshJwt,
accountDid: account.did, },
resultRefreshJwt: account.refreshJwt, ],
}, )
],
),
})
ax.metric('account:create:success', metrics, { ax.metric('account:create:success', metrics, {
session: utils.accountToSessionMetadata(account), session: utils.accountToSessionMetadata(account),
}) })
@@ -401,23 +393,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
disposeBundle(bundle) disposeBundle(bundle)
return return
} }
await persistedSession.runWithCredentialLock({ await store.dispatch(
operation: () => {
store.dispatch( type: 'switched-to-account',
{ newBundle: bundle,
type: 'switched-to-account', newAccount: account,
newBundle: bundle, },
newAccount: account, [
}, {
[ type: 'login',
{ accountDid: account.did,
type: 'login', resultRefreshJwt: account.refreshJwt,
accountDid: account.did, },
resultRefreshJwt: account.refreshJwt, ],
}, )
],
),
})
ax.metric( ax.metric(
'account:loggedIn', 'account:loggedIn',
{logContext, withPassword: true}, {logContext, withPassword: true},
@@ -441,13 +430,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const prevState = store.getState() const prevState = store.getState()
const accountDid = prevState.currentBundleState.did const accountDid = prevState.currentBundleState.did
if (accountDid) { if (accountDid) {
void persistedSession void store
.runWithCredentialLock({ .dispatch({type: 'logged-out-current-account', accountDid}, [
operation: () => {type: 'logout', accountDid},
store.dispatch({type: 'logged-out-current-account', accountDid}, [ ])
{type: 'logout', accountDid},
]),
})
.catch(() => {}) .catch(() => {})
} }
ax.metric( ax.metric(
@@ -489,17 +475,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
.accounts.map(account => account.did), .accounts.map(account => account.did),
]), ]),
] ]
void persistedSession void store
.runWithCredentialLock({ .dispatch(
operation: () => {type: 'logged-out-every-account'},
store.dispatch( accountDids.map(accountDid => ({
{type: 'logged-out-every-account'}, type: 'logout' as const,
accountDids.map(accountDid => ({ accountDid,
type: 'logout' as const, })),
accountDid, )
})),
),
})
.catch(() => {}) .catch(() => {})
ax.metric( ax.metric(
'account:loggedOut', 'account:loggedOut',
@@ -556,24 +539,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
disposeBundle(bundle) disposeBundle(bundle)
return return
} }
const committedSession = await persistedSession.runWithCredentialLock({ const committedSession = await store.dispatch(
operation: () => {
store.dispatch( type: 'switched-to-account',
{ newBundle: bundle,
type: 'switched-to-account', newAccount: account,
newBundle: bundle, },
newAccount: account, [
}, {
[ type: 'refresh',
{ accountDid: account.did,
type: 'refresh', baseRefreshJwt: latestStoredAccount.refreshJwt,
accountDid: account.did, resultRefreshJwt: account.refreshJwt,
baseRefreshJwt: latestStoredAccount.refreshJwt, },
resultRefreshJwt: account.refreshJwt, ],
}, )
],
),
})
const committedAccount = committedSession?.accounts.find( const committedAccount = committedSession?.accounts.find(
candidate => candidate.did === account.did, 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. */ /* getSession targets the PDS; only the persisted account fields are patched. */
const data = await bundle.pdsClient.call(com.atproto.server.getSession, {}) const data = await bundle.pdsClient.call(com.atproto.server.getSession, {})
if (signal.aborted) return if (signal.aborted) return
await persistedSession.runWithCredentialLock({ await store.dispatch({
operation: () => type: 'partial-refresh-session',
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
* Read the did off the response rather than the session: the bundle may * getters throw in that state.
* have been disposed while the request was in flight, and the live */
* getters throw in that state. accountDid: data.did,
*/ patch: {
accountDid: data.did, emailConfirmed: data.emailConfirmed,
patch: { emailAuthFactor: data.emailAuthFactor,
emailConfirmed: data.emailConfirmed, },
emailAuthFactor: data.emailAuthFactor,
},
}),
}) })
}, [store, cancelPendingTask]) }, [store, cancelPendingTask])
@@ -711,17 +688,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
account: redactAccount(account), account: redactAccount(account),
}) })
cancelPendingTask() cancelPendingTask()
void persistedSession void store
.runWithCredentialLock({ .dispatch(
operation: () => {
store.dispatch( type: 'removed-account',
{ accountDid: account.did,
type: 'removed-account', },
accountDid: account.did, [{type: 'remove', accountDid: account.did}],
}, )
[{type: 'remove', accountDid: account.did}],
),
})
.catch(() => {}) .catch(() => {})
addSessionDebugLog({ addSessionDebugLog({
type: 'method:end', type: 'method:end',