From 449e66f9cb5d665a0b4ca1d5d44128f6738ad284 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Tue, 30 Sep 2025 00:06:19 +0100 Subject: [PATCH 1/2] Make persisting synchronous --- src/state/session/index.tsx | 82 +++++++++++++++++++++++-------------- 1 file changed, 52 insertions(+), 30 deletions(-) diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index e7f37269c82..c9714da0961 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -14,7 +14,7 @@ import { createAgentAndResume, sessionAccountToSession, } from './agent' -import {getInitialState, reducer} from './reducer' +import {type Action, getInitialState, reducer, type State} from './reducer' export {isSignupQueued} from './util' import {addSessionDebugLog} from './logging' @@ -46,13 +46,50 @@ const ApiContext = React.createContext({ }) ApiContext.displayName = 'SessionApiContext' -export function Provider({children}: React.PropsWithChildren<{}>) { - const cancelPendingTask = useOneTaskAtATime() - const [state, dispatch] = React.useReducer(reducer, null, () => { +class SessionStore { + private state: State + private listeners = new Set<() => void>() + + constructor() { const initialState = getInitialState(persisted.get('session').accounts) addSessionDebugLog({type: 'reducer:init', state: initialState}) - return initialState - }) + this.state = initialState + } + + getState = (): State => { + return this.state + } + + subscribe = (listener: () => void) => { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + dispatch = (action: Action) => { + const nextState = reducer(this.state, action) + if (nextState.needsPersist) { + nextState.needsPersist = false + const persistedData = { + accounts: nextState.accounts, + currentAccount: nextState.accounts.find( + a => a.did === nextState.currentAgentState.did, + ), + } + addSessionDebugLog({type: 'persisted:broadcast', data: persistedData}) + persisted.write('session', persistedData) + } + this.state = nextState + this.listeners.forEach(listener => listener()) + } +} + +const store = new SessionStore() + +export function Provider({children}: React.PropsWithChildren<{}>) { + const cancelPendingTask = useOneTaskAtATime() + const state = React.useSyncExternalStore(store.subscribe, store.getState) const onAgentSessionChange = React.useCallback( (agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => { @@ -60,7 +97,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { if (sessionEvent === 'expired' || sessionEvent === 'create-failed') { emitSessionDropped() } - dispatch({ + store.dispatch({ type: 'received-agent-event', agent, refreshedAccount, @@ -84,7 +121,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { if (signal.aborted) { return } - dispatch({ + store.dispatch({ type: 'switched-to-account', newAgent: agent, newAccount: account, @@ -107,7 +144,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { if (signal.aborted) { return } - dispatch({ + store.dispatch({ type: 'switched-to-account', newAgent: agent, newAccount: account, @@ -128,7 +165,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { logContext => { addSessionDebugLog({type: 'method:start', method: 'logout'}) cancelPendingTask() - dispatch({ + store.dispatch({ type: 'logged-out-current-account', }) logger.metric( @@ -147,7 +184,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { logContext => { addSessionDebugLog({type: 'method:start', method: 'logout'}) cancelPendingTask() - dispatch({ + store.dispatch({ type: 'logged-out-every-account', }) logger.metric( @@ -176,7 +213,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { if (signal.aborted) { return } - dispatch({ + store.dispatch({ type: 'switched-to-account', newAgent: agent, newAccount: account, @@ -193,7 +230,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const signal = cancelPendingTask() const {data} = await agent.com.atproto.server.getSession() if (signal.aborted) return - dispatch({ + store.dispatch({ type: 'partial-refresh-session', accountDid: agent.session!.did, patch: { @@ -211,7 +248,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { account, }) cancelPendingTask() - dispatch({ + store.dispatch({ type: 'removed-account', accountDid: account.did, }) @@ -219,26 +256,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }, [cancelPendingTask], ) - - React.useEffect(() => { - if (state.needsPersist) { - state.needsPersist = false - const persistedData = { - accounts: state.accounts, - currentAccount: state.accounts.find( - a => a.did === state.currentAgentState.did, - ), - } - addSessionDebugLog({type: 'persisted:broadcast', data: persistedData}) - persisted.write('session', persistedData) - } - }, [state]) - React.useEffect(() => { return persisted.onUpdate('session', nextSession => { const synced = nextSession addSessionDebugLog({type: 'persisted:receive', data: synced}) - dispatch({ + store.dispatch({ type: 'synced-accounts', syncedAccounts: synced.accounts, syncedCurrentDid: synced.currentAccount?.did, From 54894fa1a2954fdd89dc996a405aad36eb490a31 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Tue, 30 Sep 2025 00:53:56 +0100 Subject: [PATCH 2/2] Initialize later so persisted is filled --- src/state/session/index.tsx | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index c9714da0961..71c9fbb7a30 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -51,6 +51,7 @@ class SessionStore { private listeners = new Set<() => void>() constructor() { + // Careful: By the time this runs, `persisted` needs to already be filled. const initialState = getInitialState(persisted.get('session').accounts) addSessionDebugLog({type: 'reducer:init', state: initialState}) this.state = initialState @@ -69,6 +70,8 @@ class SessionStore { dispatch = (action: Action) => { const nextState = reducer(this.state, action) + this.state = nextState + // Persist synchronously without waiting for the React render cycle. if (nextState.needsPersist) { nextState.needsPersist = false const persistedData = { @@ -80,15 +83,13 @@ class SessionStore { addSessionDebugLog({type: 'persisted:broadcast', data: persistedData}) persisted.write('session', persistedData) } - this.state = nextState this.listeners.forEach(listener => listener()) } } -const store = new SessionStore() - export function Provider({children}: React.PropsWithChildren<{}>) { const cancelPendingTask = useOneTaskAtATime() + const [store] = React.useState(() => new SessionStore()) const state = React.useSyncExternalStore(store.subscribe, store.getState) const onAgentSessionChange = React.useCallback( @@ -105,7 +106,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { sessionEvent, }) }, - [], + [store], ) const createAccount = React.useCallback( @@ -129,7 +130,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { logger.metric('account:create:success', metrics, {statsig: true}) addSessionDebugLog({type: 'method:end', method: 'createAccount', account}) }, - [onAgentSessionChange, cancelPendingTask], + [store, onAgentSessionChange, cancelPendingTask], ) const login = React.useCallback( @@ -156,7 +157,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) addSessionDebugLog({type: 'method:end', method: 'login', account}) }, - [onAgentSessionChange, cancelPendingTask], + [store, onAgentSessionChange, cancelPendingTask], ) const logoutCurrentAccount = React.useCallback< @@ -175,7 +176,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) addSessionDebugLog({type: 'method:end', method: 'logout'}) }, - [cancelPendingTask], + [store, cancelPendingTask], ) const logoutEveryAccount = React.useCallback< @@ -194,7 +195,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) addSessionDebugLog({type: 'method:end', method: 'logout'}) }, - [cancelPendingTask], + [store, cancelPendingTask], ) const resumeSession = React.useCallback( @@ -220,7 +221,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }) addSessionDebugLog({type: 'method:end', method: 'resumeSession', account}) }, - [onAgentSessionChange, cancelPendingTask], + [store, onAgentSessionChange, cancelPendingTask], ) const partialRefreshSession = React.useCallback< @@ -238,7 +239,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { emailAuthFactor: data.emailAuthFactor, }, }) - }, [state, cancelPendingTask]) + }, [store, state, cancelPendingTask]) const removeAccount = React.useCallback( account => { @@ -254,7 +255,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }) addSessionDebugLog({type: 'method:end', method: 'removeAccount', account}) }, - [cancelPendingTask], + [store, cancelPendingTask], ) React.useEffect(() => { return persisted.onUpdate('session', nextSession => { @@ -284,7 +285,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } } }) - }, [state, resumeSession]) + }, [store, state, resumeSession]) const stateContext = React.useMemo( () => ({