feat: Unleash feature flags via src/lib/flags.tsx, hello-banner on home page

This commit is contained in:
nholtware 2026-11-05 11:19:45 -06:00
parent 00de7c4d3d
commit e6f8827cb1
12 changed files with 276 additions and 9 deletions

View file

@ -3,12 +3,21 @@ import { createMemoryRouter, RouterProvider } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { routes } from "./routes";
// Flags come from the Unleash SDK; tests control them directly.
const flagsOn = vi.hoisted(() => new Set<string>());
vi.mock("@unleash/proxy-client-react", () => ({
FlagProvider: ({ children }: { children: React.ReactNode }) => children,
useFlag: (name: string) => flagsOn.has(name),
useVariant: () => ({ name: "disabled", enabled: false }),
}));
function renderAt(path: string) {
const router = createMemoryRouter(routes, { initialEntries: [path] });
return render(<RouterProvider router={router} />);
}
beforeEach(() => {
flagsOn.clear();
window.__APP_CONFIG__ = { API_BASE_URL: "/api", APP_NAME: "test-app" };
});
@ -25,6 +34,20 @@ describe("app", () => {
expect(screen.getByTestId("build-sha")).toHaveTextContent("dev");
});
it("home hides the banner while hello-banner is off", async () => {
renderAt("/");
await screen.findByRole("heading", { name: "test-app" });
expect(screen.queryByTestId("banner")).not.toBeInTheDocument();
});
it("home shows the banner while hello-banner is on", async () => {
flagsOn.add("hello-banner");
renderAt("/");
expect(await screen.findByTestId("banner")).toHaveTextContent(
"hello-banner flag is on",
);
});
it("status fetches / and /readyz under API_BASE_URL and renders the JSON", async () => {
const fetchMock = vi.fn(async (url: string) => {
const body =

View file

@ -1,6 +1,10 @@
export interface AppConfig {
API_BASE_URL: string;
APP_NAME: string;
/** Unleash base URL as seen by the browser (empty = flags off). */
UNLEASH_URL: string;
/** Unleash frontend token: public by design (read-only, one environment). */
UNLEASH_FRONTEND_TOKEN: string;
}
declare global {
@ -9,7 +13,12 @@ declare global {
}
}
const defaults: AppConfig = { API_BASE_URL: "/api", APP_NAME: "app" };
const defaults: AppConfig = {
API_BASE_URL: "/api",
APP_NAME: "app",
UNLEASH_URL: "",
UNLEASH_FRONTEND_TOKEN: "",
};
// Runtime config comes from /config.js (window.__APP_CONFIG__), so one image
// serves every environment. Read it at call time so tests can override it.

93
src/lib/flags.test.tsx Normal file
View file

@ -0,0 +1,93 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock the Unleash React SDK: capture provider props, control flag state.
const sdk = vi.hoisted(() => ({
on: new Set<string>(),
providerProps: [] as Record<string, unknown>[],
}));
vi.mock("@unleash/proxy-client-react", () => ({
FlagProvider: (props: { children: React.ReactNode }) => {
sdk.providerProps.push(props);
return props.children;
},
useFlag: (name: string) => sdk.on.has(name),
useVariant: () => ({ name: "disabled", enabled: false }),
}));
import { FlagsProvider, unleashConfig, useFlag } from "./flags";
function Probe() {
return <p>{useFlag("hello-banner") ? "on" : "off"}</p>;
}
beforeEach(() => {
sdk.on.clear();
sdk.providerProps.length = 0;
});
describe("unleashConfig", () => {
const base = { API_BASE_URL: "/api", APP_NAME: "web" };
it("is null when UNLEASH_URL or token is unset", () => {
expect(
unleashConfig({ ...base, UNLEASH_URL: "", UNLEASH_FRONTEND_TOKEN: "" }),
).toBeNull();
expect(
unleashConfig({
...base,
UNLEASH_URL: "http://u",
UNLEASH_FRONTEND_TOKEN: "",
}),
).toBeNull();
});
it("points at the Frontend API", () => {
expect(
unleashConfig({
...base,
UNLEASH_URL: "http://unleash.test/",
UNLEASH_FRONTEND_TOKEN: "*:development.x",
}),
).toMatchObject({
url: "http://unleash.test/api/frontend",
clientKey: "*:development.x",
appName: "web",
});
});
});
describe("FlagsProvider", () => {
it("starts the client when configured", () => {
window.__APP_CONFIG__ = {
APP_NAME: "web",
UNLEASH_URL: "http://unleash.test",
UNLEASH_FRONTEND_TOKEN: "tok",
};
sdk.on.add("hello-banner");
render(
<FlagsProvider>
<Probe />
</FlagsProvider>,
);
expect(screen.getByText("on")).toBeInTheDocument();
expect(sdk.providerProps[0]).toMatchObject({
startClient: true,
config: { url: "http://unleash.test/api/frontend", clientKey: "tok" },
});
});
it("stays offline when UNLEASH_URL is unset", () => {
window.__APP_CONFIG__ = { APP_NAME: "web" };
const info = vi.spyOn(console, "info").mockImplementation(() => {});
render(
<FlagsProvider>
<Probe />
</FlagsProvider>,
);
expect(screen.getByText("off")).toBeInTheDocument();
expect(sdk.providerProps[0]).toMatchObject({
startClient: false,
config: { disableRefresh: true, disableMetrics: true },
});
expect(info).toHaveBeenCalledTimes(1);
});
});

55
src/lib/flags.tsx Normal file
View file

@ -0,0 +1,55 @@
import { useMemo, type ReactNode } from "react";
import {
FlagProvider,
useFlag as useUnleashFlag,
useVariant as useUnleashVariant,
type IConfig,
} from "@unleash/proxy-client-react";
import { getConfig, type AppConfig } from "./config";
// Offline client: no network, no metrics, every flag reads false.
const OFFLINE: IConfig = {
url: "http://localhost/offline",
clientKey: "offline",
appName: "offline",
bootstrap: [],
disableRefresh: true,
disableMetrics: true,
};
/** Unleash Frontend API config from runtime config, or null if unset. */
export function unleashConfig(cfg: AppConfig): IConfig | null {
if (!cfg.UNLEASH_URL || !cfg.UNLEASH_FRONTEND_TOKEN) return null;
return {
url: `${cfg.UNLEASH_URL.replace(/\/+$/, "")}/api/frontend`,
clientKey: cfg.UNLEASH_FRONTEND_TOKEN,
appName: cfg.APP_NAME,
refreshInterval: 15,
};
}
/**
* Wrap the app once (main.tsx). With UNLEASH_URL/UNLEASH_FRONTEND_TOKEN unset
* (local dev, tests) every flag reads false and nothing is fetched.
*/
export function FlagsProvider({ children }: { children: ReactNode }) {
const config = useMemo(() => {
const c = unleashConfig(getConfig());
if (!c) console.info("feature flags disabled: UNLEASH_URL unset");
return c;
}, []);
return (
<FlagProvider config={config ?? OFFLINE} startClient={config !== null}>
{children}
</FlagProvider>
);
}
/** True while the flag is on; re-renders when Unleash reports a change. */
export function useFlag(name: string): boolean {
return useUnleashFlag(name);
}
export function useVariant(name: string) {
return useUnleashVariant(name);
}

View file

@ -1,12 +1,15 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router";
import { FlagsProvider } from "./lib/flags";
import { routes } from "./routes";
const router = createBrowserRouter(routes);
createRoot(document.getElementById("root")!).render(
<StrictMode>
<RouterProvider router={router} />
<FlagsProvider>
<RouterProvider router={router} />
</FlagsProvider>
</StrictMode>,
);

View file

@ -1,8 +1,20 @@
import { buildSha, getConfig } from "../lib/config";
import { useFlag } from "../lib/flags";
export function Home() {
// Demo flag (Plan 07): toggle "hello-banner" in Unleash, then reload.
const banner = useFlag("hello-banner");
return (
<section>
{banner && (
<p
role="status"
data-testid="banner"
style={{ background: "#fff3c4", padding: "0.5rem 1rem" }}
>
The hello-banner flag is on.
</p>
)}
<h1>{getConfig().APP_NAME}</h1>
<p>
Build: <code data-testid="build-sha">{buildSha}</code>

View file

@ -1 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
// vitest globals are off, so Testing Library can't register this itself.
afterEach(cleanup);