diff --git a/frontend/editor/src/core/hooks/tools/shared/useBaseTool.test.tsx b/frontend/editor/src/core/hooks/tools/shared/useBaseTool.test.tsx new file mode 100644 index 0000000000..9e0d0f4e14 --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/shared/useBaseTool.test.tsx @@ -0,0 +1,81 @@ +import { describe, expect, test, vi, beforeEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool"; +import type { ToolOperationHook } from "@app/hooks/tools/shared/useToolOperation"; +import type { BaseParametersHook } from "@app/hooks/tools/shared/useBaseParameters"; +import type { StirlingFile } from "@app/types/fileContext"; + +// Isolate useBaseTool from the file/viewer/endpoint contexts so the test can +// focus on the unmount lifecycle wiring. +const scopedFiles: { current: StirlingFile[] } = { current: [] }; +vi.mock("@app/hooks/tools/shared/useViewScopedFiles", () => ({ + useViewScopedFiles: () => scopedFiles.current, +})); +vi.mock("@app/hooks/useEndpointConfig", () => ({ + useEndpointEnabled: () => ({ enabled: true, loading: false }), +})); + +const cancelOperation = vi.fn(); + +const makeOperation = (isLoading: boolean): ToolOperationHook => + ({ + files: [], + thumbnails: [], + isGeneratingThumbnails: false, + downloadUrl: null, + downloadFilename: null, + downloadLocalPath: null, + outputFileIds: [], + isLoading, + status: "", + errorMessage: null, + progress: null, + willUseCloud: false, + executeOperation: vi.fn(), + resetResults: vi.fn(), + clearError: vi.fn(), + cancelOperation, + undoOperation: vi.fn(), + }) as unknown as ToolOperationHook; + +const makeParams = (): BaseParametersHook => + ({ + parameters: {}, + updateParameter: vi.fn(), + setParameters: vi.fn(), + resetParameters: vi.fn(), + validateParameters: () => true, + getEndpointName: () => "compress-pdf", + }) as unknown as BaseParametersHook; + +const props = { + onPreviewFile: vi.fn(), + onComplete: vi.fn(), + onError: vi.fn(), +}; + +describe("useBaseTool unmount behaviour", () => { + beforeEach(() => { + cancelOperation.mockClear(); + scopedFiles.current = []; + }); + + test("aborts the in-flight operation when the tool unmounts mid-run", () => { + const { unmount } = renderHook(() => + useBaseTool("compress", makeParams, () => makeOperation(true), props), + ); + + expect(cancelOperation).not.toHaveBeenCalled(); + unmount(); + expect(cancelOperation).toHaveBeenCalledTimes(1); + }); + + test("does not cancel on unmount when no operation is running", () => { + const { unmount } = renderHook(() => + useBaseTool("compress", makeParams, () => makeOperation(false), props), + ); + + unmount(); + expect(cancelOperation).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/shared/useBaseTool.ts b/frontend/editor/src/core/hooks/tools/shared/useBaseTool.ts index df21dc3b3b..881a20f3c8 100644 --- a/frontend/editor/src/core/hooks/tools/shared/useBaseTool.ts +++ b/frontend/editor/src/core/hooks/tools/shared/useBaseTool.ts @@ -80,6 +80,22 @@ export function useBaseTool< const params = useParams(); const operation = useOperation(); + // Abort any in-flight backend request when the tool unmounts — e.g. the user + // navigates away or switches tools mid-operation. Without this the request + // keeps running and the UI has no way to stop it. A ref keeps the cleanup + // pointed at the latest operation without re-subscribing on every render, and + // we only cancel while something is actually in flight so a normal unmount + // (and React strict-mode's dev remount) stays a no-op. + const operationRef = useRef(operation); + operationRef.current = operation; + useEffect(() => { + return () => { + if (operationRef.current.isLoading) { + operationRef.current.cancelOperation(); + } + }; + }, []); + // Endpoint validation using parameters hook const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(params.getEndpointName());