Compare commits

...
Author SHA1 Message Date
posthog-eu[bot] 88eb6cf6cd 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
2026-07-15 10:55:23 +00:00
2 changed files with 97 additions and 0 deletions
@@ -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<unknown> =>
({
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<unknown>;
const makeParams = (): BaseParametersHook<unknown> =>
({
parameters: {},
updateParameter: vi.fn(),
setParameters: vi.fn(),
resetParameters: vi.fn(),
validateParameters: () => true,
getEndpointName: () => "compress-pdf",
}) as unknown as BaseParametersHook<unknown>;
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();
});
});
@@ -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());