From 88eb6cf6cd63f66b7b95d9a8b95d1a4a794a73ff Mon Sep 17 00:00:00 2001 From: "posthog-eu[bot]" <226701856+posthog-eu[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:55:23 +0000 Subject: [PATCH] Abort in-flight tool request when the tool unmounts Tools (Compress most visibly) hold an axios cancel token but only fire it from an explicit cancel button. Navigating away or switching tools mid-operation unmounts the tool component without cancelling, so the request stays pending in the browser with no way for the user to stop it. Wire the operation's existing cancelOperation into useBaseTool's unmount cleanup, guarded on isLoading so only genuinely in-flight operations are cancelled (keeping normal unmounts and React strict-mode dev remounts a no-op). Because every tool consumes useBaseTool, this covers the whole tool surface, not just Compress. Generated-By: PostHog Code Task-Id: a4bc9efb-c047-4373-b007-72425db5e40a --- .../hooks/tools/shared/useBaseTool.test.tsx | 81 +++++++++++++++++++ .../core/hooks/tools/shared/useBaseTool.ts | 16 ++++ 2 files changed, 97 insertions(+) create mode 100644 frontend/editor/src/core/hooks/tools/shared/useBaseTool.test.tsx 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());