From cdf3601923f48c78e407be20face858767fc3ce2 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 30 Jul 2026 13:22:12 -0700 Subject: [PATCH] fix(ui): settle superseded preview requests silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A preview left in flight across a structural configuration change could still publish its result or error, so the page described a package that was no longer selected. Previews now carry a request generation that structural changes bump: a superseded preview keeps its progress panel while running (imports and previews stay attached per the earlier fix) but settles silently — no stale preview render, error panel, or toast. Imports are deliberately not gated: they mutate the server, so their outcome is always published. Component tests cover the silent settle and the current-request failure path. --- ui/src/pages/CompanyImport.test.tsx | 15 +++++++++++++-- ui/src/pages/CompanyImport.tsx | 22 +++++++++++++++++----- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/ui/src/pages/CompanyImport.test.tsx b/ui/src/pages/CompanyImport.test.tsx index 9e282bbdf55..d9d32abdfaa 100644 --- a/ui/src/pages/CompanyImport.test.tsx +++ b/ui/src/pages/CompanyImport.test.tsx @@ -346,8 +346,9 @@ describe("CompanyImport", () => { expect(container.textContent).toContain("Uploading and analyzing your package"); expect(container.textContent).toContain("Keep this page open."); - // Config edits while the request is in flight must not detach it from - // the UI: the progress panel keeps reporting it until it settles. + // A mid-flight config edit keeps the progress panel visible (the request + // really is still running) but supersedes the request, so its failure + // settles silently instead of describing a package no longer selected. await enterGithubUrl("https://github.com/acme/starter-b/tree/main/company"); expect(container.textContent).toContain("Uploading and analyzing your package"); @@ -358,6 +359,16 @@ describe("CompanyImport", () => { await flushReact(); expect(container.textContent).not.toContain("Uploading and analyzing your package"); + expect(container.textContent).not.toContain("Preview failed:"); + expect(mockPushToast).not.toHaveBeenCalled(); + + // A failure of the currently configured request renders a durable panel. + await clickButton((text) => text === "Preview import"); + await act(async () => { + rejectPreview(new Error("stream disconnected")); + }); + await flushReact(); + expect(container.textContent).toContain("Preview failed: stream disconnected"); expect(container.textContent).toContain("Retry, or use the CLI folder import"); expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ tone: "error" })); diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index 9556b472ea3..0f22c4d7061 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -770,9 +770,16 @@ export function CompanyImport() { return { type: "github", url }; } + // Monotonic id for preview requests. Structural configuration changes bump + // it, so an in-flight preview they supersede settles silently instead of + // publishing a result or error for a package that is no longer selected. + // Imports are not gated this way: they mutate the server, so their outcome + // is always published. + const previewGenerationRef = useRef(0); + // Preview mutation const previewMutation = useMutation({ - mutationFn: () => { + mutationFn: (_generation: number) => { const source = buildSource(); if (!source) throw new Error("No source configured."); return companiesApi.importPreview({ @@ -785,7 +792,8 @@ export function CompanyImport() { collisionStrategy, }); }, - onSuccess: (result) => { + onSuccess: (result, generation) => { + if (generation !== previewGenerationRef.current) return; setImportPreview(result); // Build conflicts and set default name overrides with prefix @@ -848,7 +856,8 @@ export function CompanyImport() { const firstFile = Object.keys(result.files)[0]; if (firstFile) setSelectedFile(firstFile); }, - onError: (err) => { + onError: (err, generation) => { + if (generation !== previewGenerationRef.current) return; pushToast({ tone: "error", title: "Preview failed", @@ -939,6 +948,7 @@ export function CompanyImport() { } function resetImportFlowState() { + previewGenerationRef.current += 1; setImportPreview(null); resetMutationState(); } @@ -1457,7 +1467,7 @@ export function CompanyImport() {