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() {
)} - {previewMutation.isError && !previewMutation.isPending && ( + {previewMutation.isError && + !previewMutation.isPending && + previewMutation.variables === previewGenerationRef.current && (Preview failed:{" "}