[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:
yuneng-jiang
2026-02-28 12:30:47 -08:00
co-authored by Claude Opus 4.6
parent 17f9ccbf16
commit edc19b4830
2 changed files with 142 additions and 8 deletions
@@ -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) {