feat: bootstrap Krill Switch flags during SSR

This commit is contained in:
Jesse Merhi
2026-07-29 01:22:09 +10:00
parent 3432b21132
commit 847a643545
11 changed files with 213 additions and 345 deletions
+5
View File
@@ -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=="],
+1
View File
@@ -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",
+1
View File
@@ -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.
+17 -12
View File
@@ -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
+11 -3
View File
@@ -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 (
<ConvexAuthProvider client={convex} shouldHandleCode={false}>
<FeatureFlagProvider>
<FeatureFlagProvider contextKey={featureFlagContextKey} initialValues={initialFeatureFlags}>
<TooltipProvider delayDuration={400}>
<AuthCodeHandler />
<AuthErrorHandler />
+7
View File
@@ -0,0 +1,7 @@
export const FEATURE_FLAG_DEFAULTS: FeatureFlagValues = {
souls: false,
};
export type FeatureFlagValues = {
souls: boolean;
};
+67
View File
@@ -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<FeatureFlagValues> {
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<InitialFeatureFlags> => {
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 };
}
},
);
+40
View File
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { evaluateInitialFeatureFlags } from "./featureFlags.functions";
const fetchMock = vi.fn<typeof fetch>();
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",
}),
);
});
});
+28 -106
View File
@@ -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<typeof fetch>();
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<Response>((resolve) => {
resolveFetch = resolve;
}),
);
render(
<FeatureFlagProvider
baseUrl="https://flags.openclaw.ai"
contextKey="user-123"
evalKey="ks_clawhub_production_public"
>
<FlagProbe />
</FeatureFlagProvider>,
);
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<Response>(() => {}));
const observedValues: boolean[] = [];
function FirstRenderProbe() {
observedValues.push(useFeatureFlag("souls"));
return null;
return <FlagProbe />;
}
render(
<FeatureFlagProvider contextKey="user-123" evalKey="ks_clawhub_production_public">
<FirstRenderProbe />
</FeatureFlagProvider>,
);
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(
<FeatureFlagProvider
baseUrl="https://flags.openclaw.ai"
contextKey="user-123"
evalKey="ks_clawhub_production_public"
pollIntervalMs={1_000}
initialValues={{ souls: true }}
>
<FirstRenderProbe />
</FeatureFlagProvider>,
);
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(
<FeatureFlagProvider
baseUrl="https://flags.openclaw.ai"
contextKey="user-123"
evalKey="ks_clawhub_production_public"
initialValues={{ souls: true }}
>
<FlagProbe />
</FeatureFlagProvider>,
);
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(
<FeatureFlagProvider evalKey="">
<FlagProbe />
+29 -223
View File
@@ -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<FeatureFlagValues>(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<FeatureFlagEvaluation> {
const headers: Record<string, string> = {
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<FeatureFlagValues> | 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<string | null>(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<void> {
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 (
<krill.FeatureFlagProvider
baseUrl={
baseUrl ?? getRuntimeEnv("VITE_KRILLSWITCH_BASE_URL") ?? DEFAULT_KRILLSWITCH_BASE_URL
}
}
void refresh();
const onVisibilityChange = () => {
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 <FeatureFlagsContext.Provider value={values}>{children}</FeatureFlagsContext.Provider>;
}
export function useFeatureFlag<Key extends keyof FeatureFlagValues>(
key: Key,
): FeatureFlagValues[Key] {
return useContext(FeatureFlagsContext)[key];
contextKey={contextKey}
evalKey={resolvedEvalKey}
initialValues={initialValues}
pollIntervalMs={pollIntervalMs}
>
{children}
</krill.FeatureFlagProvider>
);
}
+7 -1
View File
@@ -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 }) {
<HeadContent />
</head>
<body>
<AppProviders>
<AppProviders
featureFlagContextKey={initialFeatureFlags.contextKey}
initialFeatureFlags={initialFeatureFlags.values}
>
<div className="app-shell">
<PromotionsBar />
<Header />