Remove Feature Flags

This commit is contained in:
yuneng-jiang
2025-11-27 17:21:51 -08:00
parent c578889314
commit fb5429bfe7
7 changed files with 6 additions and 471 deletions
@@ -1,21 +1,16 @@
import useFeatureFlags from "@/hooks/useFeatureFlags";
import Sidebar from "@/components/leftnav";
import Sidebar2 from "@/app/(dashboard)/components/Sidebar2";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import Sidebar from "@/components/leftnav";
interface SidebarProviderProps {
setPage: (page: string) => void;
defaultSelectedKey: string;
setPage: (newPage: string) => void;
sidebarCollapsed: boolean;
}
const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: SidebarProviderProps) => {
const { refactoredUIFlag } = useFeatureFlags();
const { accessToken, userRole } = useAuthorized();
return refactoredUIFlag ? (
<Sidebar2 accessToken={accessToken} defaultSelectedKey={defaultSelectedKey} userRole={userRole} />
) : (
return (
<Sidebar
accessToken={accessToken}
setPage={setPage}
+1 -7
View File
@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { FeatureFlagsProvider } from "@/hooks/useFeatureFlags";
const inter = Inter({ subsets: ["latin"] });
@@ -18,12 +17,7 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body className={inter.className}>
<FeatureFlagsProvider>
{children}
</FeatureFlagsProvider>
</body>
<body className={inter.className}>{children}</body>
</html>
);
}
-2
View File
@@ -40,7 +40,6 @@ import UIThemeSettings from "@/components/ui_theme_settings";
import { CostTrackingSettings } from "@/components/CostTrackingSettings";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { cx } from "@/lib/cva.config";
import useFeatureFlags from "@/hooks/useFeatureFlags";
import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider";
import OldTeams from "@/components/OldTeams";
import { SearchTools } from "@/components/search_tools";
@@ -147,7 +146,6 @@ export default function CreateKeyPage() {
const [createClicked, setCreateClicked] = useState<boolean>(false);
const [authLoading, setAuthLoading] = useState(true);
const [userID, setUserID] = useState<string | null>(null);
const { refactoredUIFlag } = useFeatureFlags();
const invitation_id = searchParams.get("invitation_id");
+1 -15
View File
@@ -1,7 +1,7 @@
import Link from "next/link";
import React, { useState, useEffect } from "react";
import type { MenuProps } from "antd";
import { Dropdown, Tooltip, Switch } from "antd";
import { Dropdown, Tooltip } from "antd";
import { getProxyBaseUrl } from "@/components/networking";
import {
UserOutlined,
@@ -15,7 +15,6 @@ import {
import { clearTokenCookies } from "@/utils/cookieUtils";
import { fetchProxySettings } from "@/utils/proxyUtils";
import { useTheme } from "@/contexts/ThemeContext";
import useFeatureFlags from "@/hooks/useFeatureFlags";
interface NavbarProps {
userID: string | null;
@@ -45,7 +44,6 @@ const Navbar: React.FC<NavbarProps> = ({
const baseUrl = getProxyBaseUrl();
const [logoutUrl, setLogoutUrl] = useState("");
const { logoUrl } = useTheme();
const { refactoredUIFlag, setRefactoredUIFlag } = useFeatureFlags();
// Simple logo URL: use custom logo if available, otherwise default
const imageUrl = logoUrl || `${baseUrl}/get_image`;
@@ -114,18 +112,6 @@ const Navbar: React.FC<NavbarProps> = ({
{userEmail || "Unknown"}
</span>
</div>
{/* NEW: Feature flag label + toggle below the email field */}
<div className="flex items-center text-sm pt-2 mt-2 border-t border-gray-100">
<span className="text-gray-500 text-xs">Refactored UI</span>
<Switch
className="ml-auto"
size="small"
checked={refactoredUIFlag}
onChange={(checked) => setRefactoredUIFlag(checked)}
aria-label="Toggle refactored UI feature flag"
/>
</div>
</div>
</div>
),
@@ -1,7 +1,6 @@
import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import PublicModelHub from "./public_model_hub";
import { FeatureFlagsProvider } from "@/hooks/useFeatureFlags";
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({
@@ -58,11 +57,7 @@ beforeEach(() => {
describe("PublicModelHub", () => {
it("renders", () => {
const { container } = render(
<FeatureFlagsProvider>
<PublicModelHub />
</FeatureFlagsProvider>,
);
const { container } = render(<PublicModelHub />);
expect(container).toBeInTheDocument();
});
});
@@ -1,296 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { useRouter } from "next/navigation";
import useFeatureFlags, { FeatureFlagsProvider } from "./useFeatureFlags";
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
}));
// Mock the networking module to control serverRootPath
vi.mock("@/components/networking", () => ({
serverRootPath: "/",
}));
describe("useFeatureFlags", () => {
let mockReplace: ReturnType<typeof vi.fn>;
let originalLocation: Location;
beforeEach(() => {
// Mock router
mockReplace = vi.fn();
(useRouter as ReturnType<typeof vi.fn>).mockReturnValue({
replace: mockReplace,
});
// Store original location
originalLocation = window.location;
// Mock localStorage
Storage.prototype.getItem = vi.fn(() => null);
Storage.prototype.setItem = vi.fn();
Storage.prototype.removeItem = vi.fn();
});
afterEach(() => {
vi.clearAllMocks();
// Restore location
Object.defineProperty(window, "location", {
writable: true,
value: originalLocation,
});
});
describe("FeatureFlagsProvider - redirect logic", () => {
it("should not redirect when refactoredUIFlag is true", async () => {
// Set flag to true
Storage.prototype.getItem = vi.fn(() => "true");
const { result } = renderHook(() => useFeatureFlags(), {
wrapper: FeatureFlagsProvider,
});
expect(result.current.refactoredUIFlag).toBe(true);
// Wait for any effects
await waitFor(() => {
expect(mockReplace).not.toHaveBeenCalled();
});
});
it("should not redirect when already on a /ui path (race condition protection)", async () => {
// Set flag to false to trigger redirect logic
Storage.prototype.getItem = vi.fn(() => "false");
// Mock window.location to be on a custom UI path
delete (window as any).location;
window.location = {
pathname: "/my-custom-path/ui/",
} as Location;
renderHook(() => useFeatureFlags(), {
wrapper: FeatureFlagsProvider,
});
// Wait for timeout and check redirect was NOT called
await new Promise((resolve) => setTimeout(resolve, 150));
expect(mockReplace).not.toHaveBeenCalled();
});
it("should not redirect when on /ui path without custom root", async () => {
// Set flag to false
Storage.prototype.getItem = vi.fn(() => "false");
// Mock window.location to be on standard UI path
delete (window as any).location;
window.location = {
pathname: "/ui/",
} as Location;
renderHook(() => useFeatureFlags(), {
wrapper: FeatureFlagsProvider,
});
// Wait for timeout and check redirect was NOT called
await new Promise((resolve) => setTimeout(resolve, 150));
expect(mockReplace).not.toHaveBeenCalled();
});
it("should redirect when flag is false and not on a /ui path", async () => {
// Set flag to false
Storage.prototype.getItem = vi.fn(() => "false");
// Mock window.location to be on a non-UI path
delete (window as any).location;
window.location = {
pathname: "/some-other-path/",
} as Location;
renderHook(() => useFeatureFlags(), {
wrapper: FeatureFlagsProvider,
});
// Wait for timeout plus a bit more
await new Promise((resolve) => setTimeout(resolve, 150));
// Should have called replace to redirect to base path
expect(mockReplace).toHaveBeenCalledWith("/");
});
it("should not redirect if already at base path", async () => {
// Set flag to false
Storage.prototype.getItem = vi.fn(() => "false");
// Mock window.location to be at root
delete (window as any).location;
window.location = {
pathname: "/",
} as Location;
renderHook(() => useFeatureFlags(), {
wrapper: FeatureFlagsProvider,
});
// Wait for timeout
await new Promise((resolve) => setTimeout(resolve, 150));
expect(mockReplace).not.toHaveBeenCalled();
});
});
describe("useFeatureFlags - flag management", () => {
it("should initialize with false when no value in localStorage", () => {
Storage.prototype.getItem = vi.fn(() => null);
const { result } = renderHook(() => useFeatureFlags(), {
wrapper: FeatureFlagsProvider,
});
expect(result.current.refactoredUIFlag).toBe(false);
});
it("should initialize with true when localStorage has true", () => {
Storage.prototype.getItem = vi.fn(() => "true");
const { result } = renderHook(() => useFeatureFlags(), {
wrapper: FeatureFlagsProvider,
});
expect(result.current.refactoredUIFlag).toBe(true);
});
it("should update localStorage when setRefactoredUIFlag is called", () => {
const setItemMock = vi.fn();
Storage.prototype.setItem = setItemMock;
Storage.prototype.getItem = vi.fn(() => "false");
const { result } = renderHook(() => useFeatureFlags(), {
wrapper: FeatureFlagsProvider,
});
result.current.setRefactoredUIFlag(true);
expect(setItemMock).toHaveBeenCalledWith(
"feature.refactoredUIFlag",
"true"
);
});
it("should handle malformed localStorage values gracefully", () => {
Storage.prototype.getItem = vi.fn(() => "invalid-value");
const { result } = renderHook(() => useFeatureFlags(), {
wrapper: FeatureFlagsProvider,
});
// Should default to false for malformed values
expect(result.current.refactoredUIFlag).toBe(false);
});
});
describe("getBasePath logic with serverRootPath", () => {
it("should handle serverRootPath being set to custom path", async () => {
// Mock the networking module with custom serverRootPath
vi.doMock("@/components/networking", () => ({
serverRootPath: "/my-custom-path",
}));
// Set flag to false to trigger redirect
Storage.prototype.getItem = vi.fn(() => "false");
// Mock location to be on wrong path
delete (window as any).location;
window.location = {
pathname: "/wrong-path/",
} as Location;
renderHook(() => useFeatureFlags(), {
wrapper: FeatureFlagsProvider,
});
// Wait for timeout
await new Promise((resolve) => setTimeout(resolve, 150));
// With default NEXT_PUBLIC_BASE_URL being empty, should redirect to "/"
// (In reality, with serverRootPath="/my-custom-path", it would be "/my-custom-path/")
expect(mockReplace).toHaveBeenCalled();
});
});
describe("storage event synchronization", () => {
it("should update flag when storage event is fired", async () => {
Storage.prototype.getItem = vi.fn(() => "false");
const { result } = renderHook(() => useFeatureFlags(), {
wrapper: FeatureFlagsProvider,
});
expect(result.current.refactoredUIFlag).toBe(false);
// Simulate storage event from another tab
const storageEvent = new StorageEvent("storage", {
key: "feature.refactoredUIFlag",
newValue: "true",
});
window.dispatchEvent(storageEvent);
await waitFor(() => {
expect(result.current.refactoredUIFlag).toBe(true);
});
});
it("should self-heal when storage key is cleared", async () => {
const setItemMock = vi.fn();
Storage.prototype.setItem = setItemMock;
Storage.prototype.getItem = vi.fn(() => "true");
renderHook(() => useFeatureFlags(), {
wrapper: FeatureFlagsProvider,
});
// Simulate storage event where key was cleared
const storageEvent = new StorageEvent("storage", {
key: "feature.refactoredUIFlag",
newValue: null,
});
window.dispatchEvent(storageEvent);
await waitFor(() => {
expect(setItemMock).toHaveBeenCalledWith(
"feature.refactoredUIFlag",
"false"
);
});
});
});
describe("timeout cleanup", () => {
it("should cleanup timeout on unmount", async () => {
Storage.prototype.getItem = vi.fn(() => "false");
delete (window as any).location;
window.location = {
pathname: "/some-path/",
} as Location;
const { unmount } = renderHook(() => useFeatureFlags(), {
wrapper: FeatureFlagsProvider,
});
// Unmount immediately before timeout fires
unmount();
// Wait past the timeout
await new Promise((resolve) => setTimeout(resolve, 150));
// Should not have called replace since component unmounted
expect(mockReplace).not.toHaveBeenCalled();
});
});
});
@@ -1,137 +0,0 @@
"use client";
import React, { createContext, useContext, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { serverRootPath } from "@/components/networking";
const getBasePath = () => {
const raw = process.env.NEXT_PUBLIC_BASE_URL ?? "";
const trimmed = raw.replace(/^\/+|\/+$/g, ""); // strip leading/trailing slashes
const uiPath = trimmed ? `/${trimmed}/` : "/";
// If serverRootPath is set and not "/", prepend it to the UI path
if (serverRootPath && serverRootPath !== "/") {
// Remove trailing slash from serverRootPath and ensure uiPath has no leading slash for proper joining
const cleanServerRoot = serverRootPath.replace(/\/+$/, "");
const cleanUiPath = uiPath.replace(/^\/+/, "");
return `${cleanServerRoot}/${cleanUiPath}`;
}
return uiPath;
}
type Flags = {
refactoredUIFlag: boolean;
setRefactoredUIFlag: (v: boolean) => void;
};
const STORAGE_KEY = "feature.refactoredUIFlag";
const FeatureFlagsCtx = createContext<Flags | null>(null);
/** Safely read the flag from localStorage. If anything goes wrong, reset to false. */
function readFlagSafely(): boolean {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) {
localStorage.setItem(STORAGE_KEY, "false");
return false;
}
const v = raw.trim().toLowerCase();
if (v === "true" || v === "1") return true;
if (v === "false" || v === "0") return false;
// Last chance: try JSON.parse in case something odd was stored.
const parsed = JSON.parse(raw);
if (typeof parsed === "boolean") return parsed;
// Malformed → reset to false
localStorage.setItem(STORAGE_KEY, "false");
return false;
} catch {
// If even accessing localStorage throws, best effort reset then default to false
try {
localStorage.setItem(STORAGE_KEY, "false");
} catch {}
return false;
}
}
function writeFlagSafely(v: boolean) {
try {
localStorage.setItem(STORAGE_KEY, String(v));
} catch {
// Ignore write errors; state will still reflect the intended value.
}
}
export const FeatureFlagsProvider = ({ children }: { children: React.ReactNode }) => {
const router = useRouter(); // ⟵ add this
// Lazy init reads from localStorage only on the client
const [refactoredUIFlag, setRefactoredUIFlagState] = useState<boolean>(() => readFlagSafely());
const setRefactoredUIFlag = (v: boolean) => {
setRefactoredUIFlagState(v);
writeFlagSafely(v);
};
// Keep this flag in sync across tabs/windows.
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY && e.newValue != null) {
const next = e.newValue.trim().toLowerCase();
setRefactoredUIFlagState(next === "true" || next === "1");
}
// If the key was cleared elsewhere, self-heal to false.
if (e.key === STORAGE_KEY && e.newValue === null) {
writeFlagSafely(false);
setRefactoredUIFlagState(false);
}
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
// Redirect to base path the moment the flag is OFF.
useEffect(() => {
if (refactoredUIFlag) return; // only act when turned off
// Wait a moment for serverRootPath to be initialized from getUiConfig()
// This prevents a race condition where we redirect before knowing the correct path
const checkAndRedirect = () => {
const base = getBasePath();
const normalize = (p: string) => (p.endsWith("/") ? p : p + "/");
const current = normalize(window.location.pathname);
// Don't redirect if we're already on a UI path (even if serverRootPath hasn't loaded yet)
// This handles the case where the page is mounted at a custom server root path
if (current.includes("/ui")) {
return;
}
// Avoid a redirect loop if we're already at the base path.
if (current !== base) {
// Replace so the "off" redirect doesn't pollute history.
router.replace(base);
}
};
// Small delay to allow serverRootPath to be set by getUiConfig()
const timeoutId = setTimeout(checkAndRedirect, 100);
return () => clearTimeout(timeoutId);
}, [refactoredUIFlag, router]);
return (
<FeatureFlagsCtx.Provider value={{ refactoredUIFlag, setRefactoredUIFlag }}>{children}</FeatureFlagsCtx.Provider>
);
};
const useFeatureFlags = () => {
const ctx = useContext(FeatureFlagsCtx);
if (!ctx) throw new Error("useFeatureFlags must be used within FeatureFlagsProvider");
return ctx;
};
export default useFeatureFlags;