fix(ui): stop the policy template queue timer chain on unmount

Applying multiple AI-suggested templates schedules each next template
via setTimeout(handleUseTemplate, 500) with the timer id discarded, so
navigating away mid-queue let the chain keep running: it continued
creating guardrails through the API and setting state on the unmounted
panel. Store the timer in a ref and clear it in an unmount cleanup.
The regression test drives the two-template flow and asserts no
further guardrail fetches happen after unmount
This commit is contained in:
ryan-crabbe-berri 2026-07-21 16:12:07 -07:00
parent 2b2ae4ca49
commit 64bd591ccf
2 changed files with 56 additions and 4 deletions

View file

@ -104,7 +104,12 @@ vi.mock("./add_policy_form", () => ({
vi.mock("./guardrail_selection_modal", () => ({
__esModule: true,
default: () => null,
default: ({ visible, onConfirm }: { visible: boolean; onConfirm: (defs: unknown[]) => void }) =>
visible ? (
<button type="button" data-testid="guardrail-selection-confirm" onClick={() => onConfirm([])}>
confirm guardrails
</button>
) : null,
}));
vi.mock("./template_parameter_modal", () => ({
@ -114,7 +119,20 @@ vi.mock("./template_parameter_modal", () => ({
vi.mock("./ai_suggestion_modal", () => ({
__esModule: true,
default: () => null,
default: ({ onSelectTemplates }: { onSelectTemplates: (templates: unknown[]) => void }) => (
<button
type="button"
data-testid="ai-suggestion-select-two"
onClick={() =>
onSelectTemplates([
{ id: "tmpl-1", guardrailDefinitions: [] },
{ id: "tmpl-2", guardrailDefinitions: [] },
])
}
>
select two templates
</button>
),
}));
vi.mock("./policy_test_panel", () => ({
@ -196,3 +214,30 @@ describe("PoliciesPanel attachment delete", () => {
});
});
});
describe("PoliciesPanel template queue", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("stops processing queued templates after unmount", async () => {
const user = userEvent.setup();
const { unmount } = renderWithProviders(<PoliciesPanel accessToken="test-token" userRole="Admin" />);
await user.click(await screen.findByTestId("ai-suggestion-select-two"));
const callsAfterFirstTemplate = networkingMocks.getGuardrailsList.mock.calls.length;
await user.click(await screen.findByTestId("guardrail-selection-confirm"));
await waitFor(() => {
expect(networkingMocks.getGuardrailsList.mock.calls.length).toBe(callsAfterFirstTemplate + 1);
});
const callsBeforeUnmount = networkingMocks.getGuardrailsList.mock.calls.length;
unmount();
await new Promise((resolve) => setTimeout(resolve, 700));
expect(networkingMocks.getGuardrailsList.mock.calls.length).toBe(callsBeforeUnmount);
});
});

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
import { Alert } from "antd";
@ -66,6 +66,13 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({ accessToken, userRole })
const [loadedTemplates, setLoadedTemplates] = useState<any[]>([]);
const [templateQueue, setTemplateQueue] = useState<any[]>([]);
const [templateQueueProgress, setTemplateQueueProgress] = useState<{ current: number; total: number } | null>(null);
const templateQueueTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (templateQueueTimerRef.current !== null) clearTimeout(templateQueueTimerRef.current);
};
}, []);
const isAdmin = userRole ? isAdminRole(userRole) : false;
@ -338,7 +345,7 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({ accessToken, userRole })
setTemplateQueue(remaining);
setTemplateQueueProgress((prev) => (prev ? { ...prev, current: prev.current + 1 } : null));
// Small delay so user can see the success message
setTimeout(() => handleUseTemplate(nextTemplate), 500);
templateQueueTimerRef.current = setTimeout(() => handleUseTemplate(nextTemplate), 500);
} else {
setTemplateQueueProgress(null);
}