From f4aff44e8f6bb1d215dbad9e3ab9ef32976a485c Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 31 Aug 2026 14:20:02 -0500 Subject: [PATCH] Preserve authoritative account selection --- plans/versioned-localstorage-sessions.md | 12 +++++ src/state/persisted/__tests__/session-test.ts | 50 ++++++++++++++++++- src/state/persisted/index.ts | 3 ++ src/state/persisted/index.web.ts | 3 ++ src/state/persisted/session-merge.ts | 8 ++- src/state/persisted/types.ts | 2 + .../provider-session-events-test.tsx | 3 ++ src/state/session/index.tsx | 4 ++ 8 files changed, 83 insertions(+), 2 deletions(-) diff --git a/plans/versioned-localstorage-sessions.md b/plans/versioned-localstorage-sessions.md index 5e609f62c9..9ade8979c0 100644 --- a/plans/versioned-localstorage-sessions.md +++ b/plans/versioned-localstorage-sessions.md @@ -104,6 +104,18 @@ If a queued stale refresh succeeds, its result is conditionally committed agains Tabs must not publish complete in-memory session snapshots as authoritative state. Metadata updates should patch metadata onto a fresh localStorage read without touching credential fields. +Current-account selection is also explicit mutation intent, not a field inferred from an otherwise stale session snapshot. A commit preserves the current account read from localStorage unless the operation explicitly switches accounts. Login, account creation, and explicit resume/switch operations may select their target DID. Refreshes and metadata updates do not change the persisted selection. Logout, removal, and expiration begin from the persisted selection; the final credential-state validation clears it only if that selected account is no longer active. + +For example: + +```text +Tab A commits: current account Y +Tab B stale memory: current account X +Tab B updates: metadata or refreshed credentials for X +Tab B rereads: current account Y +Tab B commits: preserve current account Y +``` + ## 3. Commit refreshes conditionally Start with both tabs and shared storage at the same credential generation: diff --git a/src/state/persisted/__tests__/session-test.ts b/src/state/persisted/__tests__/session-test.ts index 8879a602a5..23ca23afe3 100644 --- a/src/state/persisted/__tests__/session-test.ts +++ b/src/state/persisted/__tests__/session-test.ts @@ -8,6 +8,7 @@ import { } from '../session-merge' const DID = 'did:plc:example123' +const OTHER_DID = 'did:plc:other456' function jwt({jti, issuedAt}: {jti: string; issuedAt: number}) { const encode = (value: object) => @@ -16,15 +17,17 @@ function jwt({jti, issuedAt}: {jti: string; issuedAt: number}) { } function account({ + did = DID, refreshJwt, accessJwt = `access-${refreshJwt}`, }: { + did?: PersistedAccount['did'] refreshJwt?: string accessJwt?: string }): PersistedAccount { return { service: 'https://bsky.social/', - did: DID, + did, handle: 'alice.test', refreshJwt, accessJwt, @@ -166,6 +169,49 @@ describe('versioned persisted sessions', () => { expect(result.accounts[0].accessJwt).toBe(`access-${refreshB}`) }) + it('preserves the persisted current account for ordinary updates', () => { + const alice = account({refreshJwt: refreshA}) + const bob = { + ...account({did: OTHER_DID, refreshJwt: refreshB}), + handle: 'bob.test', + } + const result = applySessionUpdate({ + storedSession: { + accounts: [alice, bob], + currentAccount: bob, + }, + nextSession: { + accounts: [{...alice, handle: 'alice-renamed.test'}, bob], + currentAccount: alice, + }, + credentialMutations: [], + }) + + expect(result.currentAccount?.did).toBe(OTHER_DID) + }) + + it('changes the persisted current account for an explicit selection', () => { + const alice = account({refreshJwt: refreshA}) + const bob = { + ...account({did: OTHER_DID, refreshJwt: refreshB}), + handle: 'bob.test', + } + const result = applySessionUpdate({ + storedSession: { + accounts: [alice, bob], + currentAccount: bob, + }, + nextSession: { + accounts: [alice, bob], + currentAccount: alice, + }, + credentialMutations: [], + currentAccountDid: DID, + }) + + expect(result.currentAccount?.did).toBe(DID) + }) + it('does not let a stale expiry clear a newer generation', () => { const storedSession = session({ account: account({refreshJwt: refreshB}), @@ -203,6 +249,8 @@ describe('versioned persisted sessions', () => { mutation: {type: 'logout', accountDid: DID}, }) + expect(loggedOut.currentAccount).toBeUndefined() + const staleRefresh = update({ storedSession: loggedOut, nextAccount: account({refreshJwt: refreshC}), diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts index 1fd22d6f92..2caea8a672 100644 --- a/src/state/persisted/index.ts +++ b/src/state/persisted/index.ts @@ -81,15 +81,18 @@ write satisfies PersistedApi['write'] export function writeSession({ nextSession, credentialMutations, + currentAccountDid, }: { nextSession: Schema['session'] credentialMutations: SessionCredentialMutation[] + currentAccountDid?: string }): Promise { return enqueueWrite(async () => { const session = applySessionUpdate({ storedSession: _state.session, nextSession, credentialMutations, + currentAccountDid, }) const next = normalizeData({..._state, session}) await persistWithRetry(next) diff --git a/src/state/persisted/index.web.ts b/src/state/persisted/index.web.ts index eff0aad4ea..508d9db0eb 100644 --- a/src/state/persisted/index.web.ts +++ b/src/state/persisted/index.web.ts @@ -116,15 +116,18 @@ write satisfies PersistedApi['write'] export async function writeSession({ nextSession, credentialMutations, + currentAccountDid, }: { nextSession: Schema['session'] 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) diff --git a/src/state/persisted/session-merge.ts b/src/state/persisted/session-merge.ts index 1d596a0dfa..79be8fde7c 100644 --- a/src/state/persisted/session-merge.ts +++ b/src/state/persisted/session-merge.ts @@ -79,10 +79,12 @@ export function applySessionUpdate({ storedSession, nextSession, credentialMutations, + currentAccountDid, }: { storedSession: Schema['session'] nextSession: Schema['session'] credentialMutations: SessionCredentialMutation[] + currentAccountDid?: string }): Schema['session'] { const incomingByDid = new Map( nextSession.accounts.map(account => [account.did, account]), @@ -112,9 +114,13 @@ export function applySessionUpdate({ }) } + const persistedCurrentDid = storedSession.currentAccount?.did + const selectedCurrentDid = currentAccountDid ?? persistedCurrentDid const result: Schema['session'] = { accounts, - currentAccount: nextSession.currentAccount, + currentAccount: selectedCurrentDid + ? accounts.find(account => account.did === selectedCurrentDid) + : undefined, credentialStates, } diff --git a/src/state/persisted/types.ts b/src/state/persisted/types.ts index f9df6fece1..5f455fef16 100644 --- a/src/state/persisted/types.ts +++ b/src/state/persisted/types.ts @@ -18,6 +18,8 @@ export type PersistedApi = { writeSession(args: { nextSession: Schema['session'] credentialMutations: SessionCredentialMutation[] + /** Omit to preserve the current account read from persisted storage. */ + currentAccountDid?: string }): Promise runWithPersistedStorageLock(args: { operation: () => T | Promise diff --git a/src/state/session/__tests__/provider-session-events-test.tsx b/src/state/session/__tests__/provider-session-events-test.tsx index 10b06eb040..4bf0c99e25 100644 --- a/src/state/session/__tests__/provider-session-events-test.tsx +++ b/src/state/session/__tests__/provider-session-events-test.tsx @@ -30,9 +30,11 @@ jest.mock('#/state/persisted', () => ({ writeSession: ({ nextSession, credentialMutations, + currentAccountDid, }: { nextSession: Schema['session'] credentialMutations: import('#/state/persisted').SessionCredentialMutation[] + currentAccountDid?: string }) => { const { applySessionUpdate, @@ -41,6 +43,7 @@ jest.mock('#/state/persisted', () => ({ storedSession: mockPersisted.latest, nextSession, credentialMutations, + currentAccountDid, }) mockPersisted.session = committed mockPersisted.latest = committed diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 3b9be69c2b..5fd574160e 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -131,6 +131,10 @@ class SessionStore { persistence = persistedSession.writeSession({ nextSession: persistedData, credentialMutations, + currentAccountDid: + action.type === 'switched-to-account' + ? action.newAccount.did + : undefined, }) } this.listeners.forEach(listener => listener())