Skip to content

Commit 58825f3

Browse files
Programcodeclaude
andcommitted
Tell a saved debates list from the server's
An offline or unreachable read answered with the saved copy looked like a server answer, so the five-day question could still open from it; and a failed refresh kept a copy another refresh had painted. cachedReadFrom says where its answer came from, and the debates provider tracks whether the list on screen is a copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 2c98174 commit 58825f3

4 files changed

Lines changed: 33 additions & 13 deletions

File tree

‎apps/mobile/src/lib/debate-context.tsx‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
continueDebate,
1010
createDebate,
1111
deleteDebate,
12-
fetchDebates,
12+
fetchDebatesFrom,
1313
sortDebates,
1414
switchDebate,
1515
updateDebate,
@@ -63,6 +63,8 @@ export function DebateProvider({ children }: { children: ReactNode }) {
6363
const refreshes = useRef(0);
6464
const owner = useRef(userId);
6565
const generation = useRef(0);
66+
// The list on screen is a saved copy (painted while loading, or answered offline), not the server's.
67+
const showingCopy = useRef(false);
6668

6769
const refresh = useCallback(async () => {
6870
const forUser = owner.current;
@@ -71,30 +73,29 @@ export function DebateProvider({ children }: { children: ReactNode }) {
7173
const request = ++refreshes.current;
7274
const gen = generation.current;
7375
const current = () => generation.current === gen && owner.current === forUser && refreshes.current === request && writes.current === writesBefore;
74-
// Whether this request painted the saved copy, which a failed refresh must not leave on screen.
75-
let paintedCopy = false;
7676
return await runRequest(async () => {
77-
const show = (next: Awaited<ReturnType<typeof fetchDebates>>) => {
77+
const show = (next: Awaited<ReturnType<typeof fetchDebatesFrom>>['value']) => {
7878
if (!current()) return;
79-
paintedCopy = true;
79+
showingCopy.current = true;
8080
setFresh(false);
8181
setDebates((previous) => keepIfSame(previous, next.debates));
8282
setCurrentId(next.currentId);
8383
setStatus('ready');
8484
};
85-
const next = await fetchDebates(forUser, show);
85+
const { value: next, fromCopy } = await fetchDebatesFrom(forUser, show);
8686
if (!current()) return;
8787
// Refreshes run on every Debate tab focus; an unchanged list keeps its identity so the tabs do not re-render.
8888
setDebates((previous) => keepIfSame(previous, next.debates));
8989
setCurrentId(next.currentId);
9090
setStatus('ready');
91-
setFresh(true);
91+
showingCopy.current = fromCopy;
92+
setFresh(!fromCopy);
9293
}, async (cause) => {
9394
// A switch or create since this refresh began already holds newer state.
9495
if (!current()) return;
9596
console.warn('Could not load debates:', cause instanceof Error ? cause.message : cause);
96-
// Keep a list the server already gave; a copy painted by this refresh must not hide its refusal.
97-
setStatus((previous) => (previous === 'ready' && !paintedCopy ? previous : 'error'));
97+
// Keep a list the server gave; a saved copy on screen must not hide its refusal.
98+
setStatus((previous) => (previous === 'ready' && !showingCopy.current ? previous : 'error'));
9899
});
99100
}, []);
100101

@@ -106,6 +107,7 @@ export function DebateProvider({ children }: { children: ReactNode }) {
106107
setOneOff(false);
107108
setStatus('loading');
108109
setFresh(false);
110+
showingCopy.current = false;
109111
if (!userId) return;
110112
void refresh();
111113
// Pick up debates started or switched on the website while the app was in the background.

‎apps/mobile/src/lib/debates.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { afterWrite, cachedRead, pickFailure, readAll, readFailed, updateCopy, type CopyMark, type CopyScope } from './offline-cache';
1+
import { afterWrite, cachedRead, cachedReadFrom, pickFailure, readAll, readFailed, updateCopy, type CopyMark, type CopyScope } from './offline-cache';
22
import { supabase } from './supabase';
33

44
/**
@@ -112,6 +112,11 @@ export function fetchDebates(userId: string, onCopy?: (value: DebateList) => voi
112112
return cachedRead(userId, 'debates', 'all', () => loadDebates(userId), undefined, onCopy);
113113
}
114114

115+
/** fetchDebates, saying whether the list is the saved copy rather than the server's answer. */
116+
export function fetchDebatesFrom(userId: string, onCopy?: (value: DebateList) => void): Promise<{ value: DebateList; fromCopy: boolean }> {
117+
return cachedReadFrom(userId, 'debates', 'all', () => loadDebates(userId), undefined, onCopy);
118+
}
119+
115120
async function loadDebates(userId: string): Promise<DebateList> {
116121
const [debatesResult, currentResult] = await Promise.all([
117122
supabase.from('debates').select(DEBATE_COLUMNS).eq('user_id', userId).order('last_activity_at', { ascending: false }),

‎apps/mobile/src/lib/offline-cache.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,14 @@ describe('which failures fall back', () => {
184184
await expect(cachedRead('user-a', 'sources', 'debate-1', offlineFailure)).resolves.toEqual(['source']);
185185
});
186186

187+
// The five-day question waits for a list the server gave (debate-context.tsx), never a copy.
188+
it('says when the answer is the saved copy rather than the server', async () => {
189+
const { cachedReadFrom, setCacheOwner } = await load();
190+
setCacheOwner('user-a');
191+
await expect(cachedReadFrom('user-a', 'debates', 'all', async () => ['debate'])).resolves.toEqual({ value: ['debate'], fromCopy: false });
192+
await expect(cachedReadFrom('user-a', 'debates', 'all', offlineFailure)).resolves.toEqual({ value: ['debate'], fromCopy: true });
193+
});
194+
187195
it('throws a refused session, a policy error or any other server answer instead of serving the copy', async () => {
188196
const { cachedRead, readFailed, setCacheOwner } = await load();
189197
setCacheOwner('user-a');

‎apps/mobile/src/lib/offline-cache.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,11 @@ function noteShown(storageKey: string, group: CacheGroup, fromCopy: boolean, mar
322322
* A screen passes its load's `mark` so a copy it shows stops counting once it moves on.
323323
*/
324324
export async function cachedRead<T>(userId: string, group: CacheGroup, key: string, load: () => Promise<T>, mark?: CopyMark, onCopy?: (value: T) => void): Promise<T> {
325+
return (await cachedReadFrom(userId, group, key, load, mark, onCopy)).value;
326+
}
327+
328+
/** cachedRead, also saying whether the answer is the saved copy (offline or unreachable) rather than the server's. */
329+
export async function cachedReadFrom<T>(userId: string, group: CacheGroup, key: string, load: () => Promise<T>, mark?: CopyMark, onCopy?: (value: T) => void): Promise<{ value: T; fromCopy: boolean }> {
325330
const ticket = ++tickets;
326331
const gen = generation;
327332
const storageKey = entryKey(userId, group, key);
@@ -331,7 +336,7 @@ export async function cachedRead<T>(userId: string, group: CacheGroup, key: stri
331336
if (copy && gen === generation && userId === owner) {
332337
settle(ticket);
333338
if (gen === generation) noteShown(storageKey, group, true, mark);
334-
return copy.value;
339+
return { value: copy.value, fromCopy: true };
335340
}
336341
}
337342
let value: T;
@@ -365,11 +370,11 @@ export async function cachedRead<T>(userId: string, group: CacheGroup, key: stri
365370
const copy = await readCopy<T>(userId, group, key).catch(() => undefined);
366371
if (!copy || gen !== generation || userId !== owner) throw cause;
367372
if (gen === generation) noteShown(storageKey, group, true, mark);
368-
return copy.value;
373+
return { value: copy.value, fromCopy: true };
369374
}
370375
if (gen === generation) noteShown(storageKey, group, false, mark);
371376
writeCopy(userId, group, key, value, ticket, gen).catch(warn('Could not keep a saved copy'));
372-
return value;
377+
return { value, fromCopy: false };
373378
}
374379

375380
/**

0 commit comments

Comments
 (0)