Skip to content

Commit cc272cc

Browse files
committed
fix(activity): guard insight generation against stale handles
A generation that settled after the range changed (or after a newer generation started) ran its completion callbacks unconditionally: it cleared the current handle -- leaving the new generation unabortable -- and could overwrite the panel with a result for the previous range. Tag each generation with a version, bumped on start and on abort, and skip the handle, insight, error, and phase updates when the settling generation is no longer current.
1 parent 8c729d6 commit cc272cc

2 files changed

Lines changed: 47 additions & 2 deletions

File tree

frontend/src/lib/components/activity/ActivityInsight.svelte

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
2828
// Guards stale fetch responses when the range changes mid-flight.
2929
let fetchVersion = 0;
30+
// Bumped on every generation start and on abort, so a generation that
31+
// settles after the range changed (or after a newer one started) is
32+
// ignored instead of clobbering the current handle or panel state.
33+
let genVersion = 0;
3034
// The in-flight generation, so we can abort it on range change/unmount.
3135
let handle: GenerateInsightHandle | null = null;
3236
@@ -62,6 +66,8 @@
6266
function abortGeneration() {
6367
handle?.abort();
6468
handle = null;
69+
// Invalidate the aborted generation so its late settle is a no-op.
70+
genVersion++;
6571
}
6672
6773
$effect(() => {
@@ -109,7 +115,8 @@
109115
phase = "starting";
110116
error = null;
111117
112-
handle = generateInsight(
118+
const v = ++genVersion;
119+
const current = generateInsight(
113120
{
114121
type: "daily_activity",
115122
date_from: dateFrom,
@@ -118,17 +125,21 @@
118125
agent: "claude",
119126
},
120127
(p) => {
128+
if (v !== genVersion) return;
121129
phase = p;
122130
},
123131
);
132+
handle = current;
124133
125-
handle.done
134+
current.done
126135
.then((result) => {
136+
if (v !== genVersion) return;
127137
handle = null;
128138
insight = result;
129139
generating = false;
130140
})
131141
.catch((e) => {
142+
if (v !== genVersion) return;
132143
handle = null;
133144
if (e instanceof DOMException && e.name === "AbortError") {
134145
return;

frontend/src/lib/components/activity/ActivityInsight.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,40 @@ describe("ActivityInsight", () => {
8383
);
8484
});
8585

86+
it("ignores a generation that settles after the range changed", async () => {
87+
let resolveStale!: (insight: unknown) => void;
88+
const abortStale = vi.fn();
89+
mocks.generateInsight.mockReturnValueOnce({
90+
abort: abortStale,
91+
done: new Promise((r) => {
92+
resolveStale = r;
93+
}),
94+
});
95+
const { rerender } = render(ActivityInsight, {
96+
dateFrom: "2026-06-15", dateTo: "2026-06-21",
97+
});
98+
await settle();
99+
100+
// Start a generation for the first range.
101+
await fireEvent.click(screen.getByRole("button", { name: /generate/i }));
102+
expect(mocks.generateInsight).toHaveBeenCalledTimes(1);
103+
104+
// Range change aborts and invalidates the in-flight generation.
105+
await rerender({ dateFrom: "2026-06-08", dateTo: "2026-06-14" });
106+
await settle();
107+
expect(abortStale).toHaveBeenCalled();
108+
109+
// The aborted generation settles late; its result must not reach the panel.
110+
resolveStale({
111+
id: 99, project: null, content: "STALE RESULT", type: "daily_activity",
112+
date_from: "2026-06-15", date_to: "2026-06-21", agent: "claude",
113+
model: null, prompt: null, created_at: "2026-06-21T00:00:00Z",
114+
});
115+
await settle();
116+
117+
expect(document.body.textContent).not.toContain("STALE RESULT");
118+
});
119+
86120
it("prefills the Insights page range and navigates", async () => {
87121
render(ActivityInsight, { dateFrom: "2026-06-15", dateTo: "2026-06-21" });
88122
await settle();

0 commit comments

Comments
 (0)