Skip to content

Commit adb53ae

Browse files
Programcodeclaude
andcommitted
Drop a stale Prepare retry and stop source rankings on cancel
Prepare's Retry skipped the ownership check, so a late retry for one debate could replace another debate's loaded results and leave a spinner. Every load now takes a sequence number and only the newest may write. Generate, Attack and Ask's whole-answer path pass their request signal to the source rankings, which stop before using the read and before writing once the student cancels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7b939df commit adb53ae

7 files changed

Lines changed: 47 additions & 14 deletions

File tree

‎apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useFocusEffect, useRouter } from 'expo-router';
2-
import { useCallback, useState } from 'react';
2+
import { useCallback, useRef, useState } from 'react';
33
import { useTranslation } from 'react-i18next';
44
import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
55

@@ -35,9 +35,13 @@ export default function PrepareScreen() {
3535
const openTool = (tool: PrepareTool) =>
3636
tool === 'generate' ? router.push({ pathname: '/generate', params: { from: 'prepare' } }) : router.push({ pathname: '/coming/[tool]', params: { tool } });
3737

38-
const load = useCallback(async (isCurrent: () => boolean) => {
38+
// Every load, from focus or Retry, takes a number; only the newest may write, so a late Retry for another debate never replaces this one's.
39+
const loads = useRef(0);
40+
const load = useCallback(async () => {
3941
if (!userId || !debateId) return;
4042
const forDebate = debateId;
43+
const request = ++loads.current;
44+
const isCurrent = () => loads.current === request;
4145
try {
4246
const next = await fetchPrepareWork(userId, forDebate, SAVED_LIMIT);
4347
if (!isCurrent()) return;
@@ -52,10 +56,9 @@ export default function PrepareScreen() {
5256
// Reload on focus, so a case generated a moment ago, or on the website, shows up.
5357
useFocusEffect(
5458
useCallback(() => {
55-
let active = true;
56-
void load(() => active);
59+
void load();
5760
return () => {
58-
active = false;
61+
loads.current += 1;
5962
};
6063
}, [load]),
6164
);
@@ -97,7 +100,7 @@ export default function PrepareScreen() {
97100
<Text tone="secondary" style={styles.body}>
98101
{t('mobile.debate.loadFailed')}
99102
</Text>
100-
<TextButton label={t('common.retry')} onPress={() => void load(() => true)} />
103+
<TextButton label={t('common.retry')} onPress={() => void load()} />
101104
</Card>
102105
) : items === null ? (
103106
<ActivityIndicator style={styles.loading} accessibilityRole="progressbar" />

‎apps/mobile/src/app/ask.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ export default function AskScreen() {
248248
// A whole answer ranks the Library sources it used and says when the question was shortened, as on the website.
249249
if (whole.extras) {
250250
const { truncated, assessments } = whole.extras;
251-
const assessed = await keepSourceAssessments(userId, debateId, assessments);
251+
const assessed = await keepSourceAssessments(userId, debateId, assessments, controller.signal);
252252
if (!isCurrent()) return;
253253
const notices = [
254254
...(assessed ? [] : [`${t('aiClient.sourceSaveFailed')}. ${t('aiClient.sourceAssessmentSaveFailed')}`]),

‎apps/mobile/src/app/attack/index.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ export default function AttackScreen() {
120120
if (!isCurrent()) return;
121121
const { result, discoveredSources, truncated, assessments } = await attackCase(input, { config, language: outputLanguage, debateContext, signal: controller.signal });
122122
if (!isCurrent()) return;
123-
const assessed = await keepSourceAssessments(forUser, debateId, assessments);
123+
const assessed = await keepSourceAssessments(forUser, debateId, assessments, controller.signal);
124124
if (!isCurrent()) return;
125125
const sources = await autosaveSources(forUser, discoveredSources, 'attack', debateId, controller.signal);
126126
if (!isCurrent()) return;

‎apps/mobile/src/app/generate.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export default function GenerateScreen() {
9999
if (!isCurrent()) return;
100100
const { result, discoveredSources, truncated, assessments } = await generateCase(input, { config, language: outputLanguage, debateContext, signal: controller.signal });
101101
if (!isCurrent()) return;
102-
const assessed = await keepSourceAssessments(forUser, debateId, assessments);
102+
const assessed = await keepSourceAssessments(forUser, debateId, assessments, controller.signal);
103103
if (!isCurrent()) return;
104104
const sources = await autosaveSources(forUser, discoveredSources, 'generate', debateId, controller.signal);
105105
if (!isCurrent()) return;

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,12 @@ export async function autosaveSources(
124124
* Saves the rankings of the Library sources a result used, into the debate
125125
* the request was sent for. One-off work has no Library to rank. Returns
126126
* false when the rankings could not be saved; the result itself still stands.
127+
* Once the request is cancelled or replaced, no ranking is written.
127128
*/
128-
export async function keepSourceAssessments(userId: string, debateId: string | null, assessments: SourceAssessmentUpdate[]): Promise<boolean> {
129+
export async function keepSourceAssessments(userId: string, debateId: string | null, assessments: SourceAssessmentUpdate[], signal?: AbortSignal): Promise<boolean> {
129130
if (!debateId || assessments.length === 0) return true;
130131
try {
131-
await applySourceAssessments(userId, debateId, assessments);
132+
await applySourceAssessments(userId, debateId, assessments, signal);
132133
return true;
133134
} catch (cause) {
134135
console.warn('Could not save the source rankings:', cause instanceof Error ? cause.message : cause);
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
const supabase = vi.hoisted(() => ({ from: vi.fn() }));
4+
5+
vi.mock('./supabase', () => ({ supabase }));
6+
7+
import { applySourceAssessments } from './sources';
8+
9+
describe('applySourceAssessments', () => {
10+
it('writes no ranking once Generate, Attack or Ask is cancelled while the sources are read', async () => {
11+
const controller = new AbortController();
12+
let finishRead = (_rows: unknown) => {};
13+
const read = new Promise((resolve) => (finishRead = resolve));
14+
const chain = { eq: () => chain, then: read.then.bind(read) };
15+
const written = { eq: () => written, then: (done: (value: unknown) => unknown) => done({ error: null }) };
16+
const update = vi.fn(() => written);
17+
supabase.from.mockReturnValue({ select: () => chain, update });
18+
const assessment = { key: 'https://example.com/a', relevance: 0.8, importance: 2 as const, strength: 2 as const, evaluatedAt: '2026-10-09T00:00:00Z', retained: true };
19+
20+
const applying = applySourceAssessments('user-1', 'debate-1', [assessment], controller.signal);
21+
controller.abort();
22+
finishRead({ data: [{ id: 'source-a', origin: 'https://example.com/a', metadata: null }], error: null });
23+
24+
await expect(applying).resolves.toBeUndefined();
25+
expect(update).not.toHaveBeenCalled();
26+
});
27+
});

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,15 +151,17 @@ export async function removeSource(userId: string, id: string, debateId?: string
151151
* Saves the server's rankings onto the debate's Library sources a result
152152
* used, as the website's applySourceAssessments does. The debate is the one
153153
* the request was sent for, read when the student acted; a URL saved in
154-
* several debates is ranked only in that one.
154+
* several debates is ranked only in that one. Once the request is cancelled
155+
* or replaced, nothing is written.
155156
*/
156-
export async function applySourceAssessments(userId: string, debateId: string, assessments: SourceAssessmentUpdate[]): Promise<void> {
157-
if (assessments.length === 0) return;
157+
export async function applySourceAssessments(userId: string, debateId: string, assessments: SourceAssessmentUpdate[], signal?: AbortSignal): Promise<void> {
158+
if (assessments.length === 0 || signal?.aborted) return;
158159
const { data, error } = await supabase
159160
.from('sources')
160161
.select('id,origin,metadata')
161162
.eq('user_id', userId)
162163
.eq('debate_id', debateId);
164+
if (signal?.aborted) return;
163165
if (error) throw new Error(error.message);
164166
type Row = { id: string; origin: string | null; metadata: { url?: string; semantic?: SourceSemanticMetadata } | null };
165167
const candidates = (data ?? []) as Row[];

0 commit comments

Comments
 (0)