From e79d8e8e52cfaf844f2d50f711b5bf39df8845b1 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 31 Jul 2026 18:58:44 +0300 Subject: [PATCH] add persisted.readLatest for cross-tab session reads Co-Authored-By: Claude Fable 5 --- src/state/persisted/index.ts | 12 ++++++++++++ src/state/persisted/index.web.ts | 23 +++++++++++++++++++++++ src/state/persisted/types.ts | 9 +++++++++ 3 files changed, 44 insertions(+) diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts index 8c043a3420..0659e7817b 100644 --- a/src/state/persisted/index.ts +++ b/src/state/persisted/index.ts @@ -31,6 +31,18 @@ export function get(key: K): Schema[K] { } get satisfies PersistedApi['get'] +/** + * Native is single-instance: there is no other tab that could have written + * newer data behind our back, so the in-memory `_state` is already the truth + * and a synchronous fresh read is impossible anyway (AsyncStorage is async). + * This mirrors {@link get}; the web implementation is the one that actually + * re-reads the store. + */ +export function readLatest(key: K): Schema[K] { + return _state[key] +} +readLatest satisfies PersistedApi['readLatest'] + export async function write( key: K, value: Schema[K], diff --git a/src/state/persisted/index.web.ts b/src/state/persisted/index.web.ts index 35e796810d..a4c5d1b247 100644 --- a/src/state/persisted/index.web.ts +++ b/src/state/persisted/index.web.ts @@ -39,6 +39,29 @@ export function get(key: K): Schema[K] { } get satisfies PersistedApi['get'] +/** + * Force a fresh synchronous re-read of localStorage and return the requested + * key from it, WITHOUT adopting it as `_state`. + * + * This exists for the cross-tab expiry-rescue path. A frozen tab may not have + * processed a queued broadcast yet, so {@link get} (and persisted's in-memory + * `_state`) can be stale even though another tab already wrote healthy tokens + * to storage. Reading through storage directly here is the only way to see the + * true cross-tab-latest tokens on web. + * + * Crucially we do NOT adopt into `_state`. {@link readFromStorage} memoizes by + * raw string and returns the same object reference for unchanged data, so + * adopting here would make the later queued broadcast/storage event for that + * same write see `next === _state` and suppress its listener notification - + * leaving non-current-account changes (removals, other tokens, metadata) stale + * indefinitely. Leaving `_state` alone lets that queued event still fire. + */ +export function readLatest(key: K): Schema[K] { + const next = readFromStorage() + return next?.[key] ?? _state[key] +} +readLatest satisfies PersistedApi['readLatest'] + // eslint-disable-next-line @typescript-eslint/require-await export async function write( key: K, diff --git a/src/state/persisted/types.ts b/src/state/persisted/types.ts index d1fdfc26cb..4b5a29d938 100644 --- a/src/state/persisted/types.ts +++ b/src/state/persisted/types.ts @@ -3,6 +3,15 @@ import {type Schema} from './schema' export type PersistedApi = { init(): Promise get(key: K): Schema[K] + /** + * Like {@link get}, but on web forces a fresh synchronous re-read of the + * backing store before returning (without adopting it as the in-memory + * state). This exists for the cross-tab expiry-rescue path: a frozen tab may + * not have processed a queued broadcast yet, so {@link get} can be stale + * while another tab has already written healthy tokens to storage. On native + * it is identical to {@link get} (single-instance, no other writer). + */ + readLatest(key: K): Schema[K] write(key: K, value: Schema[K]): Promise onUpdate( key: K,