mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-19 18:19:21 +00:00
[Fix] UI - Virtual Keys: Fix beforeunload listener memory leak in UserDashboard
Move beforeunload event listener from the component render body into a useEffect with cleanup. Previously, every re-render added a new duplicate listener that was never removed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
17f9ccbf16
commit
edc19b4830
@@ -0,0 +1,132 @@
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { renderWithProviders } from "../../tests/test-utils";
|
||||
|
||||
// Track addEventListener/removeEventListener calls for "beforeunload"
|
||||
const addEventListenerSpy = vi.spyOn(window, "addEventListener");
|
||||
const removeEventListenerSpy = vi.spyOn(window, "removeEventListener");
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
// Mock networking with importOriginal so all exports are available
|
||||
vi.mock("./networking", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./networking")>();
|
||||
return {
|
||||
...actual,
|
||||
getProxyBaseUrl: vi.fn().mockReturnValue("http://localhost:4000"),
|
||||
getProxyUISettings: vi.fn().mockResolvedValue({}),
|
||||
keyInfoCall: vi.fn().mockResolvedValue({}),
|
||||
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
userInfoCall: vi.fn().mockResolvedValue({ user_info: {}, keys: [], teams: [] }),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock jwt-decode to return a valid token structure
|
||||
vi.mock("jwt-decode", () => ({
|
||||
jwtDecode: vi.fn().mockReturnValue({
|
||||
key: "test-access-token",
|
||||
user_role: "proxy_admin",
|
||||
user_email: "test@example.com",
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock cookie utility
|
||||
vi.mock("@/utils/cookieUtils", () => ({
|
||||
clearTokenCookies: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock fetchTeams
|
||||
vi.mock("./common_components/fetch_teams", () => ({
|
||||
fetchTeams: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock heavy child components to isolate UserDashboard behavior
|
||||
vi.mock("./organisms/create_key_button", () => ({
|
||||
default: () => <div data-testid="create-key-mock" />,
|
||||
}));
|
||||
|
||||
vi.mock("./VirtualKeysPage/VirtualKeysTable", () => ({
|
||||
VirtualKeysTable: () => <div data-testid="virtual-keys-table-mock" />,
|
||||
}));
|
||||
|
||||
vi.mock("../app/onboarding/page", () => ({
|
||||
default: () => <div data-testid="onboarding-mock" />,
|
||||
}));
|
||||
|
||||
// Provide a token cookie so the component doesn't redirect to login
|
||||
Object.defineProperty(document, "cookie", {
|
||||
writable: true,
|
||||
value: "token=fake-jwt-token",
|
||||
});
|
||||
|
||||
import UserDashboard from "./user_dashboard";
|
||||
|
||||
const defaultProps = {
|
||||
userID: "user-1",
|
||||
userRole: "Admin",
|
||||
userEmail: "test@example.com",
|
||||
teams: [] as any[],
|
||||
keys: [] as any[],
|
||||
setUserRole: vi.fn(),
|
||||
setUserEmail: vi.fn(),
|
||||
setTeams: vi.fn(),
|
||||
setKeys: vi.fn(),
|
||||
premiumUser: false,
|
||||
organizations: [] as any[],
|
||||
addKey: vi.fn(),
|
||||
createClicked: false,
|
||||
};
|
||||
|
||||
function renderDashboard(props = {}) {
|
||||
return renderWithProviders(<UserDashboard {...defaultProps} {...props} />);
|
||||
}
|
||||
|
||||
describe("UserDashboard beforeunload listener", () => {
|
||||
beforeEach(() => {
|
||||
addEventListenerSpy.mockClear();
|
||||
removeEventListenerSpy.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("registers exactly one beforeunload listener on mount", () => {
|
||||
renderDashboard();
|
||||
|
||||
const beforeUnloadCalls = addEventListenerSpy.mock.calls.filter(
|
||||
([event]) => event === "beforeunload",
|
||||
);
|
||||
expect(beforeUnloadCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not add duplicate listeners on re-render", () => {
|
||||
const { rerender } = renderWithProviders(<UserDashboard {...defaultProps} />);
|
||||
|
||||
addEventListenerSpy.mockClear();
|
||||
|
||||
// Re-render with different props to trigger a render cycle
|
||||
rerender(<UserDashboard {...defaultProps} userEmail="updated@example.com" />);
|
||||
|
||||
const beforeUnloadCalls = addEventListenerSpy.mock.calls.filter(
|
||||
([event]) => event === "beforeunload",
|
||||
);
|
||||
expect(beforeUnloadCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removes the beforeunload listener on unmount", () => {
|
||||
const { unmount } = renderDashboard();
|
||||
|
||||
unmount();
|
||||
|
||||
const removeCalls = removeEventListenerSpy.mock.calls.filter(
|
||||
([event]) => event === "beforeunload",
|
||||
);
|
||||
expect(removeCalls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -93,15 +93,17 @@ const UserDashboard: React.FC<UserDashboardProps> = ({
|
||||
const [userModels, setUserModels] = useState<string[]>([]);
|
||||
const [proxySettings, setProxySettings] = useState<ProxySettings | null>(null);
|
||||
const [selectedTeam, setSelectedTeam] = useState<any | null>(null);
|
||||
// check if window is not undefined
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("beforeunload", function () {
|
||||
// Clear session storage
|
||||
|
||||
// Clear session storage on page unload so next load fetches fresh data.
|
||||
// Note: MCP auth tokens are persistent and should not be cleared on page refresh
|
||||
// They are only cleared on logout
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
sessionStorage.clear();
|
||||
// Note: MCP auth tokens are persistent and should not be cleared on page refresh
|
||||
// They are only cleared on logout
|
||||
});
|
||||
}
|
||||
};
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, []);
|
||||
|
||||
function formatUserRole(userRole: string) {
|
||||
if (!userRole) {
|
||||
|
||||
Reference in New Issue
Block a user