fix(mobile): retry the legacy tab-strip purge on every load, and make host deletion await it

A session that only loads (offline) never reached the retry, which lived in the memoized
file read. Forgetting a host now fails until the older build's blob is gone too.
This commit is contained in:
Jinwoo-H
2026-09-07 16:06:05 -04:00
parent 736836b992
commit cb2de35cc3
2 changed files with 41 additions and 10 deletions
+20
View File
@@ -275,6 +275,26 @@ describe('session tab strip cache', () => {
expect(asyncStorage.removeItem).toHaveBeenCalledWith(LEGACY_STORAGE_KEY)
})
it('retries a failed legacy removal on a later load, even with no write in between', async () => {
// Why: an offline session only ever loads; the memoized file read must not be the
// only place the retry lives.
const key = getSessionTabStripCacheKey('host-1', 'wt-1')
asyncStorage.removeItem.mockRejectedValueOnce(new Error('bridge down'))
await loadCachedSessionTabStrip(key)
expect(asyncStorage.removeItem).toHaveBeenCalledTimes(1)
await loadCachedSessionTabStrip(key)
expect(asyncStorage.removeItem).toHaveBeenCalledTimes(2)
await loadCachedSessionTabStrip(key)
expect(asyncStorage.removeItem).toHaveBeenCalledTimes(2)
})
it('does not report a host forgotten while its legacy blob is still on disk', async () => {
asyncStorage.removeItem.mockRejectedValue(new Error('bridge down'))
await expect(deleteCachedSessionTabStripForHost('host-1')).rejects.toThrow(/bridge down/)
asyncStorage.removeItem.mockResolvedValue(undefined)
await expect(deleteCachedSessionTabStripForHost('host-1')).resolves.toBeUndefined()
})
it('retries a failed legacy removal on the next write, and stops once it lands', async () => {
const key = getSessionTabStripCacheKey('host-1', 'wt-1')
asyncStorage.removeItem.mockRejectedValueOnce(new Error('bridge down'))
+21 -10
View File
@@ -79,6 +79,9 @@ export async function loadCachedSessionTabStrip(
if (!key) {
return null
}
// Every load, not just the first file read: a removal that failed on launch must be
// retried by an offline session that only ever loads.
removeLegacyBlobsBestEffort()
const cache = await loadFile()
return cache.get(key) ?? null
}
@@ -136,6 +139,9 @@ export async function deleteCachedSessionTabStripForHost(hostId: string): Promis
}
// Queued, not raced: the purge is the last write, and its failure is the caller's.
await enqueueWrite(cache)
// The forgotten host's titles may still sit in the blob an older build wrote. A deletion
// the user asked for is not done until that is gone too, so this one is awaited and thrown.
await removeLegacyBlobs()
}
export function resetSessionTabStripCacheForTests(): void {
@@ -193,19 +199,24 @@ async function loadFile(): Promise<Map<string, MobileSessionTabStripPreview>> {
return loadPromise
}
// Not awaited: the plaintext left by an older build must go, but a failed removal is no reason
// to withhold the strip this build can draw. A failure keeps the key queued for the next try.
function removeLegacyBlobs(): void {
for (const key of pendingLegacyRemovals) {
void AsyncStorage.removeItem(key).then(
() => pendingLegacyRemovals.delete(key),
() => {}
// Loads and ordinary writes do not await this: the plaintext left by an older build must go,
// but a failed removal is no reason to withhold the strip this build can draw. A failure keeps
// the key queued for the next try. A host deletion does await it, and throws on failure.
function removeLegacyBlobs(): Promise<void> {
return Promise.all(
[...pendingLegacyRemovals].map((key) =>
AsyncStorage.removeItem(key).then(() => {
pendingLegacyRemovals.delete(key)
})
)
}
).then(() => {})
}
function removeLegacyBlobsBestEffort(): void {
void removeLegacyBlobs().catch(() => {})
}
async function readStoredFile(): Promise<StoredWorkspace[]> {
removeLegacyBlobs()
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY)
if (!raw) {
@@ -250,7 +261,7 @@ function enqueueWrite(cache: Map<string, MobileSessionTabStripPreview>): Promise
}
async function writeFile(cache: Map<string, MobileSessionTabStripPreview>): Promise<void> {
removeLegacyBlobs()
removeLegacyBlobsBestEffort()
const workspaces: StoredWorkspace[] = [...cache].map(([key, preview]) => ({ key, preview }))
// Throws on purpose: a deletion that only removed the in-memory rows must not be
// reported as a deletion, or the forgotten host's titles stay in plaintext on disk.