From 64bd591ccf6761fa81daf71acc53b5268da951ec Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 21 Jul 2026 16:12:07 -0700 Subject: [PATCH] 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 --- .../policies/_components/index.test.tsx | 49 ++++++++++++++++++- .../policies/_components/index.tsx | 11 ++++- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/index.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/index.test.tsx index 3b6534ab0f0..fe0ce2a06ef 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/index.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/index.test.tsx @@ -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 ? ( + + ) : 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 }) => ( + + ), })); 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(); + + 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); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/index.tsx index 722d8face3d..ca25c13091a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/index.tsx @@ -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 = ({ accessToken, userRole }) const [loadedTemplates, setLoadedTemplates] = useState([]); const [templateQueue, setTemplateQueue] = useState([]); const [templateQueueProgress, setTemplateQueueProgress] = useState<{ current: number; total: number } | null>(null); + const templateQueueTimerRef = useRef | 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 = ({ 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); }