From 847a6435455187e5db6ef73b4524e6dddcb86cb2 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:22:09 +1000 Subject: [PATCH] feat: bootstrap Krill Switch flags during SSR --- bun.lock | 5 + package.json | 1 + specs/README.md | 1 + specs/feature-flags.md | 29 ++-- src/components/AppProviders.tsx | 14 +- src/lib/featureFlagManifest.ts | 7 + src/lib/featureFlags.functions.ts | 67 ++++++++ src/lib/featureFlags.server.test.ts | 40 +++++ src/lib/featureFlags.test.tsx | 134 ++++----------- src/lib/featureFlags.tsx | 252 ++++------------------------ src/routes/__root.tsx | 8 +- 11 files changed, 213 insertions(+), 345 deletions(-) create mode 100644 src/lib/featureFlagManifest.ts create mode 100644 src/lib/featureFlags.functions.ts create mode 100644 src/lib/featureFlags.server.test.ts diff --git a/bun.lock b/bun.lock index 0db26632..909dc022 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "@fontsource/noto-sans-sc": "5.3.0", "@monaco-editor/react": "4.7.0", "@openclaw/carapace": "git+https://github.com/openclaw/carapace.git#v0.2.0", + "@openclaw/krillswitch-react": "0.0.1", "@openclaw/plugin-inspector": "0.3.17", "@radix-ui/react-avatar": "1.2.3", "@radix-ui/react-dialog": "1.1.20", @@ -452,6 +453,10 @@ "@openclaw/clawhub-admin": ["@openclaw/clawhub-admin@workspace:packages/clawhub-admin"], + "@openclaw/krillswitch-core": ["@openclaw/krillswitch-core@0.0.1", "", {}, "sha512-wL6vicqkJ+nyiKjpOaM2Z4xH/uPKonVCm/U1Lo23mJ0ubqKPRA/SFDw9haRyBAejkpRlyqu+Pf2YRBX/eq1h/w=="], + + "@openclaw/krillswitch-react": ["@openclaw/krillswitch-react@0.0.1", "", { "dependencies": { "@openclaw/krillswitch-core": "0.0.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-dC42PpC9b1pIeO7k50z5gDgDSagkizkASxFk+WMQ2lf5RA1CLNNy8KmQtex8q1HUtpejrX5xyj7PmBm5sfVzBw=="], + "@openclaw/plugin-inspector": ["@openclaw/plugin-inspector@0.3.17", "", { "bin": { "plugin-inspector": "src/cli.js" } }, "sha512-JPPHPhiXMsIvrV8UR8RQjhflMjRZX/uIhy9meE81dup7MMSnRJcsTGOXYACohv6e4z2P95z2QuE7nZkWT6Ysuw=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/package.json b/package.json index d228b31d..554b4a03 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "@fontsource/noto-sans-sc": "5.3.0", "@monaco-editor/react": "4.7.0", "@openclaw/carapace": "git+https://github.com/openclaw/carapace.git#v0.2.0", + "@openclaw/krillswitch-react": "0.0.1", "@openclaw/plugin-inspector": "0.3.17", "@radix-ui/react-avatar": "1.2.3", "@radix-ui/react-dialog": "1.1.20", diff --git a/specs/README.md b/specs/README.md index 9b868c17..426963d5 100644 --- a/specs/README.md +++ b/specs/README.md @@ -28,6 +28,7 @@ into `docs/` and leave only the design record here. - `manual-testing.md`: maintainer CLI smoke checklist. - `dev-worktrees.md`: disposable Worktrunk/Codex worktree lifecycle contract. - `dev-seeding.md`: local development fixture seeding ownership rules. +- `feature-flags.md`: Krill Switch SSR, hydration, identity, and fallback contract. - `mintlify.md`: docs publishing setup notes. - `openclaw-docs-extraction.md`: CLAW-89 extraction classification. - `deploy.md`: maintainer deploy checklist for the ClawHub project. diff --git a/specs/feature-flags.md b/specs/feature-flags.md index b40c33d3..045e1ca3 100644 --- a/specs/feature-flags.md +++ b/specs/feature-flags.md @@ -1,26 +1,31 @@ # Feature flags -ClawHub evaluates release flags through Krill Switch. Flags are a client-side -progressive enhancement, not an authorization or security boundary: protected -operations must continue to enforce their rules in Convex and HTTP handlers. +ClawHub evaluates release flags through Krill Switch during server rendering, +then keeps them fresh in the browser. Flags are not an authorization or security +boundary: protected operations must continue to enforce their rules in Convex +and HTTP handlers. ## Runtime contract -- The browser calls `POST https://flags.openclaw.ai/v1/eval` using the public - environment evaluation key from `VITE_KRILLSWITCH_EVAL_KEY`. +- The root server loader calls `POST https://flags.openclaw.ai/v1/eval` using the + public environment evaluation key from `VITE_KRILLSWITCH_EVAL_KEY`, then + serializes those values for hydration. Visible flagged content must not render + a different code default before hydration. - `VITE_KRILLSWITCH_BASE_URL` can override the evaluation origin for local testing. It defaults to the production evaluation host. - Missing configuration, network errors, invalid payloads, and incompatible - remote value types preserve code-owned defaults and must not block rendering. -- Evaluations use a persisted anonymous context key. Do not add personal or - sensitive attributes without documenting why the targeting requires them. + remote value types preserve code-owned defaults. Server evaluation has a + 200 ms budget and must not block rendering beyond it. +- Evaluations use an anonymous context key persisted in a first-party HTTP-only + cookie and passed to the hydrated provider. The server and browser must use + the same key so targeting and percentage rollouts remain stable. Do not add + personal or sensitive attributes without documenting why targeting needs them. - Values refresh when the page becomes visible and every 60 seconds. ETags avoid retransmitting unchanged evaluations. -The Krill Switch SDK packages are private today, so ClawHub owns a small typed -adapter around the stable public evaluation API. Replace the adapter with the -official React SDK once it is published rather than growing a second general -feature-flag SDK here. +The official `@openclaw/krillswitch-react` SDK owns response validation, typed +manifest merging, SSR evaluation, hydration bootstrap, caching, and polling. +Keep ClawHub's adapter limited to runtime configuration and app-specific flags. ## Initial proof flag diff --git a/src/components/AppProviders.tsx b/src/components/AppProviders.tsx index bcaafb90..a4874c10 100644 --- a/src/components/AppProviders.tsx +++ b/src/components/AppProviders.tsx @@ -10,7 +10,7 @@ import { routeToBannedAccountPage as navigateToBannedAccountPage, } from "../lib/authErrorMessage"; import { isCliDeviceUserCode } from "../lib/cliDeviceCode"; -import { FeatureFlagProvider } from "../lib/featureFlags"; +import { FeatureFlagProvider, type FeatureFlagValues } from "../lib/featureFlags"; import { clearAuthError, setAuthError, useAuthError } from "../lib/useAuthError"; import { AuthErrorMessage } from "./AuthErrorMessage"; import { ClientOnly } from "./ClientOnly"; @@ -181,10 +181,18 @@ export function AuthErrorToast() { return null; } -export function AppProviders({ children }: { children: React.ReactNode }) { +export function AppProviders({ + children, + featureFlagContextKey, + initialFeatureFlags, +}: { + children: React.ReactNode; + featureFlagContextKey: string; + initialFeatureFlags: FeatureFlagValues | null; +}) { return ( - + diff --git a/src/lib/featureFlagManifest.ts b/src/lib/featureFlagManifest.ts new file mode 100644 index 00000000..ca5ce5ad --- /dev/null +++ b/src/lib/featureFlagManifest.ts @@ -0,0 +1,7 @@ +export const FEATURE_FLAG_DEFAULTS: FeatureFlagValues = { + souls: false, +}; + +export type FeatureFlagValues = { + souls: boolean; +}; diff --git a/src/lib/featureFlags.functions.ts b/src/lib/featureFlags.functions.ts new file mode 100644 index 00000000..42183f6b --- /dev/null +++ b/src/lib/featureFlags.functions.ts @@ -0,0 +1,67 @@ +import { createKrillswitchEvaluator } from "@openclaw/krillswitch-react/server"; +import { createServerFn } from "@tanstack/react-start"; +import { getCookie, setCookie } from "@tanstack/react-start/server"; +import { FEATURE_FLAG_DEFAULTS, type FeatureFlagValues } from "./featureFlagManifest"; +import { getRuntimeEnv, isDevRuntime } from "./runtimeEnv"; + +const DEFAULT_KRILLSWITCH_BASE_URL = "https://flags.openclaw.ai"; +const FEATURE_FLAG_CONTEXT_COOKIE = "clawhub-feature-flag-context"; +const FEATURE_FLAG_CONTEXT_MAX_AGE_SECONDS = 365 * 24 * 60 * 60; +const SSR_EVALUATION_TIMEOUT_MS = 200; + +const evaluateFlags = createKrillswitchEvaluator(FEATURE_FLAG_DEFAULTS); + +type InitialFeatureFlags = { + contextKey: string; + values: FeatureFlagValues | null; +}; + +function getOrCreateContextKey(): string { + const existing = getCookie(FEATURE_FLAG_CONTEXT_COOKIE)?.trim(); + if (existing) return existing; + + const contextKey = `anon-${crypto.randomUUID()}`; + setCookie(FEATURE_FLAG_CONTEXT_COOKIE, contextKey, { + httpOnly: true, + maxAge: FEATURE_FLAG_CONTEXT_MAX_AGE_SECONDS, + path: "/", + sameSite: "lax", + secure: !isDevRuntime(), + }); + return contextKey; +} + +export async function evaluateInitialFeatureFlags(args: { + baseUrl: string; + contextKey: string; + evalKey: string; + signal: AbortSignal; +}): Promise { + return await evaluateFlags({ + baseUrl: args.baseUrl, + context: { key: args.contextKey }, + evalKey: args.evalKey, + signal: args.signal, + }); +} + +export const loadInitialFeatureFlags = createServerFn({ method: "GET" }).handler( + async (): Promise => { + const contextKey = getOrCreateContextKey(); + const evalKey = getRuntimeEnv("VITE_KRILLSWITCH_EVAL_KEY"); + if (!evalKey) return { contextKey, values: null }; + + try { + const values = await evaluateInitialFeatureFlags({ + baseUrl: getRuntimeEnv("VITE_KRILLSWITCH_BASE_URL") ?? DEFAULT_KRILLSWITCH_BASE_URL, + contextKey, + evalKey, + signal: AbortSignal.timeout(SSR_EVALUATION_TIMEOUT_MS), + }); + return { contextKey, values }; + } catch (error) { + console.warn("Krill Switch SSR evaluation failed; using code defaults.", error); + return { contextKey, values: null }; + } + }, +); diff --git a/src/lib/featureFlags.server.test.ts b/src/lib/featureFlags.server.test.ts new file mode 100644 index 00000000..da66beb5 --- /dev/null +++ b/src/lib/featureFlags.server.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { evaluateInitialFeatureFlags } from "./featureFlags.functions"; + +const fetchMock = vi.fn(); + +describe("server feature flag evaluation", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("evaluates the manifest with the stable SSR context", async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ flags: { souls: { value: true } } }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + evaluateInitialFeatureFlags({ + baseUrl: "https://flags.openclaw.ai", + contextKey: "anon-stable-context", + evalKey: "ks_clawhub_production_public", + signal: new AbortController().signal, + }), + ).resolves.toEqual({ souls: true }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://flags.openclaw.ai/v1/eval", + expect.objectContaining({ + body: JSON.stringify({ context: { key: "anon-stable-context" } }), + headers: expect.objectContaining({ + authorization: "Bearer ks_clawhub_production_public", + }), + method: "POST", + }), + ); + }); +}); diff --git a/src/lib/featureFlags.test.tsx b/src/lib/featureFlags.test.tsx index b528078b..5f285f64 100644 --- a/src/lib/featureFlags.test.tsx +++ b/src/lib/featureFlags.test.tsx @@ -2,17 +2,14 @@ import { act, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { evaluateFeatureFlags, FeatureFlagProvider, useFeatureFlag } from "./featureFlags"; +import { FeatureFlagProvider, useFeatureFlag } from "./featureFlags"; const fetchMock = vi.fn(); -function evalResponse(value: unknown, options?: { etag?: string; status?: number }): Response { +function evalResponse(value: unknown): Response { return new Response(JSON.stringify({ flags: { souls: { value } } }), { - status: options?.status ?? 200, - headers: { - "content-type": "application/json", - ...(options?.etag ? { etag: options.etag } : {}), - }, + status: 200, + headers: { "content-type": "application/json" }, }); } @@ -28,130 +25,55 @@ describe("feature flags", () => { }); afterEach(() => { - vi.useRealTimers(); vi.unstubAllGlobals(); }); - it("evaluates flags through the public Krill Switch API", async () => { - fetchMock.mockResolvedValueOnce(evalResponse(true, { etag: 'W/"flags-v1"' })); - - const result = await evaluateFeatureFlags({ - baseUrl: "https://flags.openclaw.ai", - contextKey: "user-123", - evalKey: "ks_clawhub_production_public", - }); - - expect(result).toEqual({ - kind: "updated", - etag: 'W/"flags-v1"', - values: { souls: true }, - }); - expect(fetchMock).toHaveBeenCalledWith( - new URL("https://flags.openclaw.ai/v1/eval"), - expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ - authorization: "Bearer ks_clawhub_production_public", - }), - body: JSON.stringify({ context: { key: "user-123" } }), - }), - ); - }); - - it("keeps the code default when a remote value has the wrong type", async () => { - fetchMock.mockResolvedValueOnce(evalResponse("yes")); - - await expect( - evaluateFeatureFlags({ - baseUrl: "https://flags.openclaw.ai/", - contextKey: "user-123", - evalKey: "ks_clawhub_production_public", - }), - ).resolves.toMatchObject({ values: { souls: false } }); - }); - - it("renders defaults immediately and applies a successful evaluation", async () => { - let resolveFetch: ((response: Response) => void) | undefined; - fetchMock.mockReturnValueOnce( - new Promise((resolve) => { - resolveFetch = resolve; - }), - ); - - render( - - - , - ); - - expect(screen.getByText("safe default")).toBeTruthy(); - await act(async () => { - resolveFetch?.(evalResponse(true)); - }); - expect(screen.getByText("has soul")).toBeTruthy(); - }); - - it("keeps cached values out of the first render to prevent hydration mismatches", () => { - localStorage.setItem( - "clawhub.featureFlags.ks_clawhub_production_public.user-123", - JSON.stringify({ souls: true }), - ); + it("hydrates from server values without rendering the code default first", () => { fetchMock.mockReturnValueOnce(new Promise(() => {})); const observedValues: boolean[] = []; function FirstRenderProbe() { observedValues.push(useFeatureFlag("souls")); - return null; + return ; } - render( - - - , - ); - - expect(observedValues[0]).toBe(false); - }); - - it("uses the ETag on later polls and preserves the current value on 304", async () => { - vi.useFakeTimers(); - fetchMock - .mockResolvedValueOnce(evalResponse(true, { etag: 'W/"flags-v1"' })) - .mockResolvedValueOnce(new Response(null, { status: 304 })); - render( + + , + ); + + expect(observedValues[0]).toBe(true); + expect(screen.getByText("has soul")).toBeTruthy(); + }); + + it("applies a successful browser refresh after hydration", async () => { + fetchMock.mockResolvedValueOnce(evalResponse(false)); + + render( + , ); + expect(screen.getByText("has soul")).toBeTruthy(); await act(async () => { await Promise.resolve(); }); - expect(screen.getByText("has soul")).toBeTruthy(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(1_000); - }); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchMock.mock.calls[1]?.[1]).toEqual( - expect.objectContaining({ - headers: expect.objectContaining({ "if-none-match": 'W/"flags-v1"' }), - }), - ); - expect(screen.getByText("has soul")).toBeTruthy(); + expect(screen.getByText("safe default")).toBeTruthy(); }); - it("does not call the service when no evaluation key is configured", () => { + it("renders code defaults without contacting Krill when no evaluation key is configured", () => { render( diff --git a/src/lib/featureFlags.tsx b/src/lib/featureFlags.tsx index 9124562c..31b13e12 100644 --- a/src/lib/featureFlags.tsx +++ b/src/lib/featureFlags.tsx @@ -1,237 +1,43 @@ -import { createContext, type ReactNode, useContext, useEffect, useRef, useState } from "react"; -import { z } from "zod"; +import { createKrillswitch } from "@openclaw/krillswitch-react"; +import type { ReactNode } from "react"; +import { FEATURE_FLAG_DEFAULTS, type FeatureFlagValues } from "./featureFlagManifest"; import { getRuntimeEnv } from "./runtimeEnv"; const DEFAULT_KRILLSWITCH_BASE_URL = "https://flags.openclaw.ai"; -const DEFAULT_POLL_INTERVAL_MS = 60_000; -const ANONYMOUS_CONTEXT_STORAGE_KEY = "clawhub.featureFlags.anonymousContext"; +const krill = createKrillswitch(FEATURE_FLAG_DEFAULTS); -export const FEATURE_FLAG_DEFAULTS = { - souls: false, -}; - -export type FeatureFlagValues = { - souls: boolean; -}; - -type FeatureFlagEvaluation = - | { kind: "not-modified" } - | { kind: "updated"; etag: string | null; values: FeatureFlagValues }; - -type EvaluateFeatureFlagsOptions = { - baseUrl: string; - contextKey: string; - etag?: string | null; - evalKey: string; - signal?: AbortSignal; -}; - -type FeatureFlagProviderProps = { - baseUrl?: string; - children: ReactNode; - contextKey?: string; - evalKey?: string; - pollIntervalMs?: number; -}; - -const evalResponseSchema = z.object({ - flags: z.record( - z.string(), - z.object({ - value: z.unknown(), - }), - ), -}); - -const cachedFlagsSchema = z.object({ - souls: z.boolean(), -}); - -const FeatureFlagsContext = createContext(FEATURE_FLAG_DEFAULTS); - -function evaluationUrl(baseUrl: string): URL { - const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; - return new URL("v1/eval", normalizedBaseUrl); -} - -function valuesFromResponse(payload: unknown): FeatureFlagValues { - const parsed = evalResponseSchema.safeParse(payload); - if (!parsed.success) return FEATURE_FLAG_DEFAULTS; - - const souls = parsed.data.flags.souls?.value; - return { - souls: typeof souls === "boolean" ? souls : FEATURE_FLAG_DEFAULTS.souls, - }; -} - -export async function evaluateFeatureFlags({ - baseUrl, - contextKey, - etag, - evalKey, - signal, -}: EvaluateFeatureFlagsOptions): Promise { - const headers: Record = { - authorization: `Bearer ${evalKey}`, - "content-type": "application/json", - }; - if (etag) headers["if-none-match"] = etag; - - const response = await fetch(evaluationUrl(baseUrl), { - method: "POST", - headers, - body: JSON.stringify({ context: { key: contextKey } }), - signal, - }); - - if (response.status === 304) return { kind: "not-modified" }; - if (!response.ok) { - throw new Error(`Krill Switch evaluation failed with status ${response.status}`); - } - - const payload: unknown = await response.json(); - return { - kind: "updated", - etag: response.headers.get("etag"), - values: valuesFromResponse(payload), - }; -} - -function safeLocalStorage(): Storage | null { - try { - return typeof window === "undefined" ? null : window.localStorage; - } catch { - return null; - } -} - -function storedValue(key: string): string | null { - try { - return safeLocalStorage()?.getItem(key) ?? null; - } catch { - return null; - } -} - -function storeValue(key: string, value: string): void { - try { - safeLocalStorage()?.setItem(key, value); - } catch { - // Flags remain a progressive enhancement when storage is unavailable. - } -} - -function anonymousContextKey(): string { - const stored = storedValue(ANONYMOUS_CONTEXT_STORAGE_KEY); - if (stored) return stored; - - const generatedId = - typeof crypto.randomUUID === "function" - ? crypto.randomUUID() - : Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => - byte.toString(16).padStart(2, "0"), - ).join(""); - const generated = `anon-${generatedId}`; - storeValue(ANONYMOUS_CONTEXT_STORAGE_KEY, generated); - return generated; -} - -function flagStorageKey(evalKey: string, contextKey: string): string { - return `clawhub.featureFlags.${evalKey}.${encodeURIComponent(contextKey)}`; -} - -function readCachedFlags(storageKey: string): FeatureFlagValues { - const raw = storedValue(storageKey); - if (!raw) return FEATURE_FLAG_DEFAULTS; - - try { - const parsed: unknown = JSON.parse(raw); - const cached = cachedFlagsSchema.safeParse(parsed); - return cached.success ? cached.data : FEATURE_FLAG_DEFAULTS; - } catch { - return FEATURE_FLAG_DEFAULTS; - } -} +export type { FeatureFlagValues } from "./featureFlagManifest"; +export const useFeatureFlag = krill.useFeatureFlag; export function FeatureFlagProvider({ baseUrl, children, contextKey, evalKey, - pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, -}: FeatureFlagProviderProps) { + initialValues, + pollIntervalMs, +}: { + baseUrl?: string; + children: ReactNode; + contextKey?: string; + evalKey?: string; + initialValues?: Partial | null; + pollIntervalMs?: number; +}) { const resolvedEvalKey = evalKey ?? getRuntimeEnv("VITE_KRILLSWITCH_EVAL_KEY"); - const resolvedBaseUrl = - baseUrl ?? getRuntimeEnv("VITE_KRILLSWITCH_BASE_URL") ?? DEFAULT_KRILLSWITCH_BASE_URL; - const anonymousKeyRef = useRef(null); - if (!contextKey && anonymousKeyRef.current === null) { - anonymousKeyRef.current = typeof window === "undefined" ? "anonymous" : anonymousContextKey(); - } - const resolvedContextKey = contextKey ?? anonymousKeyRef.current ?? "anonymous"; - const storageKey = resolvedEvalKey ? flagStorageKey(resolvedEvalKey, resolvedContextKey) : null; - const [state, setState] = useState<{ - storageKey: string | null; - values: FeatureFlagValues; - }>({ storageKey: null, values: FEATURE_FLAG_DEFAULTS }); - const values = state.storageKey === storageKey ? state.values : FEATURE_FLAG_DEFAULTS; + if (!resolvedEvalKey) return children; - useEffect(() => { - if (!resolvedEvalKey || !storageKey) { - setState({ storageKey: null, values: FEATURE_FLAG_DEFAULTS }); - return undefined; - } - - const activeEvalKey = resolvedEvalKey; - const activeStorageKey = storageKey; - let disposed = false; - let activeController: AbortController | null = null; - let etag: string | null = null; - setState({ storageKey: activeStorageKey, values: readCachedFlags(activeStorageKey) }); - - async function refresh(): Promise { - activeController?.abort(); - const controller = new AbortController(); - activeController = controller; - try { - const result = await evaluateFeatureFlags({ - baseUrl: resolvedBaseUrl, - contextKey: resolvedContextKey, - etag, - evalKey: activeEvalKey, - signal: controller.signal, - }); - if (disposed || controller.signal.aborted || result.kind === "not-modified") return; - - etag = result.etag; - setState({ storageKey: activeStorageKey, values: result.values }); - storeValue(activeStorageKey, JSON.stringify(result.values)); - } catch { - // Preserve code defaults or last-known values when evaluation is unavailable. - } finally { - if (activeController === controller) activeController = null; + return ( + { - if (document.visibilityState === "visible") void refresh(); - }; - document.addEventListener("visibilitychange", onVisibilityChange); - const pollTimer = window.setInterval(() => void refresh(), pollIntervalMs); - - return () => { - disposed = true; - activeController?.abort(); - window.clearInterval(pollTimer); - document.removeEventListener("visibilitychange", onVisibilityChange); - }; - }, [pollIntervalMs, resolvedBaseUrl, resolvedContextKey, resolvedEvalKey, storageKey]); - - return {children}; -} - -export function useFeatureFlag( - key: Key, -): FeatureFlagValues[Key] { - return useContext(FeatureFlagsContext)[key]; + contextKey={contextKey} + evalKey={resolvedEvalKey} + initialValues={initialValues} + pollIntervalMs={pollIntervalMs} + > + {children} + + ); } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 8c2e5a76..af39cb52 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -23,6 +23,7 @@ import { isBannedAccountAuthError, normalizeAuthErrorMessage, } from "../lib/authErrorMessage"; +import { loadInitialFeatureFlags } from "../lib/featureFlags.functions"; import { getClawHubSiteUrl, SITE_DESCRIPTION, SITE_NAME } from "../lib/site"; import { getThemeModeFromCookieHeader, normalizeThemeMode } from "../lib/themeCookie"; import designSystemCss from "../design-system.css?url"; @@ -30,6 +31,7 @@ import appCss from "../styles.css?url"; const OG_IMAGE_VERSION = "20260723-1"; export const Route = createRootRoute({ + loader: () => loadInitialFeatureFlags(), beforeLoad: ({ location }) => { if (location.pathname === BANNED_ACCOUNT_PATH) return; const authError = getAuthErrorDescription(location); @@ -165,6 +167,7 @@ function getSearchStringValue(search: unknown, key: string) { function RootDocument({ children }: { children: React.ReactNode }) { const router = useRouter(); + const initialFeatureFlags = Route.useLoaderData(); const initialThemeMode = normalizeThemeMode( (router.options.context as { initialThemeMode?: unknown } | undefined)?.initialThemeMode ?? (typeof document === "undefined" ? undefined : getThemeModeFromCookieHeader(document.cookie)), @@ -193,7 +196,10 @@ function RootDocument({ children }: { children: React.ReactNode }) { - +