diff --git a/desktop/src/features-manifest.d.ts b/desktop/src/features-manifest.d.ts
new file mode 100644
index 000000000..c1f6172ad
--- /dev/null
+++ b/desktop/src/features-manifest.d.ts
@@ -0,0 +1,4 @@
+declare module "@features-manifest" {
+ const manifest: import("@/shared/features/types").FeaturesManifest;
+ export default manifest;
+}
diff --git a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx
index 458edd58f..f23f75a08 100644
--- a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx
+++ b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx
@@ -1,5 +1,6 @@
import { desktopFeatures, useFeatureToggle, useDevToggle } from "@/shared/features";
import type { FeatureDefinition } from "@/shared/features";
+import { Switch } from "@/shared/ui/switch";
function FeatureRow({ feature }: { feature: FeatureDefinition }) {
const [enabled, toggle] = useFeatureToggle(feature.id);
@@ -10,11 +11,10 @@ function FeatureRow({ feature }: { feature: FeatureDefinition }) {
{feature.name}
{feature.description}
- toggle(e.target.checked)}
- type="checkbox"
+ data-testid={`feature-toggle-${feature.id}`}
+ onCheckedChange={toggle}
/>
);
@@ -66,11 +66,10 @@ export function ExperimentalFeaturesCard() {
When off, all dev-tier features are hidden
- setDevEnabled(e.target.checked)}
- type="checkbox"
+ data-testid="feature-toggle-dev-global"
+ onCheckedChange={setDevEnabled}
/>
diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx
index 231bb33d5..a2df6ebf5 100644
--- a/desktop/src/features/settings/ui/SettingsView.tsx
+++ b/desktop/src/features/settings/ui/SettingsView.tsx
@@ -4,8 +4,10 @@ import { ArrowLeft } from "lucide-react";
import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks";
import { getFeature } from "@/shared/features/manifest";
-import { getOverrides, getDevToggle } from "@/shared/features/store";
-import { resolveEnabled } from "@/shared/features/useFeatureEnabled";
+import {
+ resolveEnabled,
+ useFeatureSnapshot,
+} from "@/shared/features/useFeatureEnabled";
import { cn } from "@/shared/lib/cn";
import {
Sidebar,
@@ -117,16 +119,15 @@ export function SettingsView({
}: SettingsViewProps) {
const { isMobile, open: sidebarOpen, setOpen: setSidebarOpen } = useSidebar();
const myMembershipQuery = useMyRelayMembershipQuery();
+ const featureState = useFeatureSnapshot();
const visibleSections = React.useMemo(() => {
const membership = myMembershipQuery.data;
- const overrides = getOverrides();
- const devToggle = getDevToggle();
return settingsSections.filter((s) => {
// Feature gate check
if (s.featureGate) {
const feature = getFeature(s.featureGate);
- if (feature && !resolveEnabled(feature.tier, feature.id, overrides, devToggle)) {
+ if (feature && !resolveEnabled(feature.tier, feature.id, featureState.o, featureState.d)) {
return false;
}
}
@@ -139,7 +140,7 @@ export function SettingsView({
}
return true;
});
- }, [myMembershipQuery.data]);
+ }, [myMembershipQuery.data, featureState]);
const [isLoaded, setIsLoaded] = React.useState(false);
const [appVersion, setAppVersion] = React.useState(null);
diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx
index 67d8290b0..7dec4df9f 100644
--- a/desktop/src/main.tsx
+++ b/desktop/src/main.tsx
@@ -2,6 +2,7 @@ import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "@/app/App";
import "@/shared/styles/globals.css";
+import { runMigrationIfNeeded } from "@/shared/features";
import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider";
import { WorkspacesProvider } from "@/features/workspaces/useWorkspaces";
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
@@ -45,6 +46,7 @@ async function installE2eBridgeIfConfigured() {
async function bootstrap() {
await installE2eBridgeIfConfigured();
+ runMigrationIfNeeded();
renderApp();
}
diff --git a/desktop/src/shared/features/index.ts b/desktop/src/shared/features/index.ts
index a25d45f9b..f45ebc2ce 100644
--- a/desktop/src/shared/features/index.ts
+++ b/desktop/src/shared/features/index.ts
@@ -1,6 +1,11 @@
export { FeatureGate } from "./FeatureGate";
export { allFeatures, desktopFeatures, getFeature } from "./manifest";
-export { getOverrides, setOverride, clearOverride } from "./store";
+export {
+ getOverrides,
+ setOverride,
+ clearOverride,
+ runMigrationIfNeeded,
+} from "./store";
export type {
FeatureDefinition,
FeaturesManifest,
@@ -11,5 +16,6 @@ export {
useFeatureEnabled,
useFeatureToggle,
useDevToggle,
+ useFeatureSnapshot,
resolveEnabled,
} from "./useFeatureEnabled";
diff --git a/desktop/src/shared/features/manifest.ts b/desktop/src/shared/features/manifest.ts
index 04f25cad6..42141c00c 100644
--- a/desktop/src/shared/features/manifest.ts
+++ b/desktop/src/shared/features/manifest.ts
@@ -1,4 +1,4 @@
-import manifestJson from "../../../../features.json";
+import manifestJson from "@features-manifest";
import type { FeatureDefinition, FeaturesManifest } from "./types";
const manifest = manifestJson as FeaturesManifest;
diff --git a/desktop/src/shared/features/resolveEnabled.test.mjs b/desktop/src/shared/features/resolveEnabled.test.mjs
new file mode 100644
index 000000000..818504348
--- /dev/null
+++ b/desktop/src/shared/features/resolveEnabled.test.mjs
@@ -0,0 +1,76 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+
+import { resolveEnabled } from "./resolveEnabled.ts";
+
+describe("resolveEnabled", () => {
+ describe("stable tier", () => {
+ it("always returns true regardless of overrides or env", () => {
+ assert.equal(resolveEnabled("stable", "channels", {}, false, false), true);
+ assert.equal(resolveEnabled("stable", "channels", { channels: false }, false, true), true);
+ assert.equal(resolveEnabled("stable", "channels", {}, true, true), true);
+ });
+ });
+
+ describe("experimental tier", () => {
+ it("returns false by default (no override)", () => {
+ assert.equal(resolveEnabled("experimental", "workflows", {}, true, true), false);
+ });
+
+ it("returns true when user opts in", () => {
+ assert.equal(
+ resolveEnabled("experimental", "workflows", { workflows: true }, true, true),
+ true,
+ );
+ });
+
+ it("returns false when user explicitly opts out", () => {
+ assert.equal(
+ resolveEnabled("experimental", "workflows", { workflows: false }, true, true),
+ false,
+ );
+ });
+
+ it("ignores dev toggle and isDev", () => {
+ assert.equal(
+ resolveEnabled("experimental", "workflows", { workflows: true }, false, false),
+ true,
+ );
+ });
+ });
+
+ describe("dev tier", () => {
+ it("returns false in production builds even with devToggle on", () => {
+ assert.equal(resolveEnabled("dev", "doctor", {}, true, false), false);
+ });
+
+ it("returns false in dev builds when devToggle is off", () => {
+ assert.equal(resolveEnabled("dev", "doctor", {}, false, true), false);
+ });
+
+ it("returns true in dev builds with devToggle on and no override", () => {
+ assert.equal(resolveEnabled("dev", "doctor", {}, true, true), true);
+ });
+
+ it("returns false when per-feature override is explicitly false", () => {
+ assert.equal(
+ resolveEnabled("dev", "doctor", { doctor: false }, true, true),
+ false,
+ );
+ });
+
+ it("returns true when per-feature override is explicitly true", () => {
+ assert.equal(
+ resolveEnabled("dev", "doctor", { doctor: true }, true, true),
+ true,
+ );
+ });
+ });
+
+ describe("unknown tier", () => {
+ it("returns false for unrecognized tier values", () => {
+ // @ts-expect-error — testing invalid input
+ assert.equal(resolveEnabled("unknown", "foo", {}, true, true), false);
+ });
+ });
+});
diff --git a/desktop/src/shared/features/resolveEnabled.ts b/desktop/src/shared/features/resolveEnabled.ts
new file mode 100644
index 000000000..45d62d3da
--- /dev/null
+++ b/desktop/src/shared/features/resolveEnabled.ts
@@ -0,0 +1,30 @@
+import type { FeatureTier } from "./types";
+
+/**
+ * Pure resolution logic for feature visibility.
+ * No side effects, no imports beyond types — safe to test in isolation.
+ *
+ * @param isDev - Whether the current build is a dev build.
+ * Defaults to `import.meta.env?.DEV ?? false` for runtime use.
+ */
+export function resolveEnabled(
+ tier: FeatureTier,
+ featureId: string,
+ overrides: Record,
+ devToggle: boolean,
+ isDev: boolean = (import.meta.env?.DEV as boolean) ?? false,
+): boolean {
+ switch (tier) {
+ case "stable":
+ return true;
+ case "experimental":
+ return overrides[featureId] === true;
+ case "dev":
+ if (!isDev) return false;
+ if (!devToggle) return false;
+ // Allow per-feature suppression even in dev
+ return overrides[featureId] !== false;
+ default:
+ return false;
+ }
+}
diff --git a/desktop/src/shared/features/store.ts b/desktop/src/shared/features/store.ts
index 658c61313..62b14eb6d 100644
--- a/desktop/src/shared/features/store.ts
+++ b/desktop/src/shared/features/store.ts
@@ -1,16 +1,44 @@
/**
* Persistence layer for feature flag overrides.
*
- * localStorage keys:
- * sprout-feature-overrides — JSON object of { [featureId]: boolean }
- * sprout-dev-features — "true" | "false" (global dev toggle)
+ * localStorage keys (versioned to match manifest):
+ * sprout-feature-overrides-v1 — JSON object of { [featureId]: boolean }
+ * sprout-dev-features-v1 — "true" | "false" (global dev toggle)
+ * sprout-features-migrated-v1 — "true" if migration has run
*/
-const OVERRIDES_KEY = "sprout-feature-overrides";
-const DEV_TOGGLE_KEY = "sprout-dev-features";
+import { desktopFeatures } from "./manifest";
+
+const OVERRIDES_KEY = "sprout-feature-overrides-v1";
+const DEV_TOGGLE_KEY = "sprout-dev-features-v1";
+const MIGRATED_KEY = "sprout-features-migrated-v1";
export type FeatureOverrides = Record;
+/**
+ * One-time migration: if no overrides exist yet, seed experimental features
+ * as enabled so existing users don't lose functionality on upgrade.
+ * New installs (no prior localStorage at all) also get this — but that's fine
+ * because new users will see the features as they always have.
+ */
+export function runMigrationIfNeeded(): void {
+ try {
+ if (window.localStorage.getItem(MIGRATED_KEY) === "true") return;
+
+ // Seed all desktop experimental features as enabled
+ const seed: FeatureOverrides = {};
+ for (const f of desktopFeatures) {
+ if (f.tier === "experimental") {
+ seed[f.id] = true;
+ }
+ }
+ window.localStorage.setItem(OVERRIDES_KEY, JSON.stringify(seed));
+ window.localStorage.setItem(MIGRATED_KEY, "true");
+ } catch {
+ // localStorage unavailable — no-op
+ }
+}
+
/** Read all user overrides from localStorage */
export function getOverrides(): FeatureOverrides {
try {
diff --git a/desktop/src/shared/features/useFeatureEnabled.ts b/desktop/src/shared/features/useFeatureEnabled.ts
index 775348228..a6fe401eb 100644
--- a/desktop/src/shared/features/useFeatureEnabled.ts
+++ b/desktop/src/shared/features/useFeatureEnabled.ts
@@ -1,7 +1,7 @@
import { useSyncExternalStore, useCallback } from "react";
import { getFeature } from "./manifest";
+import { resolveEnabled } from "./resolveEnabled";
import { getOverrides, getDevToggle, setOverride, setDevToggle } from "./store";
-import type { FeatureTier } from "./types";
// ---------------------------------------------------------------------------
// Reactive store — components re-render when overrides change
@@ -15,19 +15,55 @@ function subscribe(listener: Listener): () => void {
return () => listeners.delete(listener);
}
-function emitChange(): void {
+/** Notify all subscribers that feature state changed */
+export function emitChange(): void {
+ // Invalidate cached snapshot
+ cachedRaw = null;
+ cachedParsed = null;
for (const listener of listeners) listener();
}
-// Snapshot: a combined key of overrides + dev toggle for change detection
+// ---------------------------------------------------------------------------
+// Cached snapshot — avoids JSON.parse on every render per hook instance
+// ---------------------------------------------------------------------------
+
+interface ParsedSnapshot {
+ o: Record;
+ d: boolean;
+}
+
+let cachedRaw: string | null = null;
+let cachedParsed: ParsedSnapshot | null = null;
+
function getSnapshot(): string {
- return JSON.stringify({ o: getOverrides(), d: getDevToggle() });
+ const raw = JSON.stringify({ o: getOverrides(), d: getDevToggle() });
+ if (raw !== cachedRaw) {
+ cachedRaw = raw;
+ cachedParsed = JSON.parse(raw) as ParsedSnapshot;
+ }
+ return raw;
+}
+
+function getParsedSnapshot(): ParsedSnapshot {
+ // Ensure snapshot is fresh
+ getSnapshot();
+ return cachedParsed!;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
+/**
+ * Returns the current parsed feature state (overrides + dev toggle).
+ * Reactive — re-renders when any feature toggle changes.
+ * Use this in components that need the full state (e.g. SettingsView filtering).
+ */
+export function useFeatureSnapshot(): ParsedSnapshot {
+ useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+ return getParsedSnapshot();
+}
+
/**
* Returns whether a feature is enabled given its tier and user overrides.
*
@@ -36,16 +72,19 @@ function getSnapshot(): string {
* - dev: true only if in dev build AND global dev toggle is on
*/
export function useFeatureEnabled(featureId: string): boolean {
- const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
- const parsed = JSON.parse(snapshot) as {
- o: Record;
- d: boolean;
- };
+ const snapshot = useFeatureSnapshot();
const feature = getFeature(featureId);
- if (!feature) return false;
+ if (!feature) {
+ if (import.meta.env.DEV) {
+ console.warn(
+ `[FeatureFlags] Unknown feature id: "${featureId}". Check features.json.`,
+ );
+ }
+ return false;
+ }
- return resolveEnabled(feature.tier, featureId, parsed.o, parsed.d);
+ return resolveEnabled(feature.tier, featureId, snapshot.o, snapshot.d);
}
/**
@@ -71,38 +110,15 @@ export function useFeatureToggle(
* Hook for the global dev toggle. Returns [enabled, toggle].
*/
export function useDevToggle(): [boolean, (enabled: boolean) => void] {
- const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
- const parsed = JSON.parse(snapshot) as { d: boolean };
+ const snapshot = useFeatureSnapshot();
const toggle = useCallback((value: boolean) => {
setDevToggle(value);
emitChange();
}, []);
- return [parsed.d, toggle];
+ return [snapshot.d, toggle];
}
-// ---------------------------------------------------------------------------
-// Pure resolution logic (exported for testing)
-// ---------------------------------------------------------------------------
-
-export function resolveEnabled(
- tier: FeatureTier,
- featureId: string,
- overrides: Record,
- devToggle: boolean,
-): boolean {
- switch (tier) {
- case "stable":
- return true;
- case "experimental":
- return overrides[featureId] === true;
- case "dev":
- if (!import.meta.env.DEV) return false;
- if (!devToggle) return false;
- // Allow per-feature suppression even in dev
- return overrides[featureId] !== false;
- default:
- return false;
- }
-}
+// Re-export for consumers that imported from here
+export { resolveEnabled } from "./resolveEnabled";
diff --git a/desktop/test-loader-hooks.mjs b/desktop/test-loader-hooks.mjs
index f15fb2cbc..cfc19aad5 100644
--- a/desktop/test-loader-hooks.mjs
+++ b/desktop/test-loader-hooks.mjs
@@ -6,7 +6,13 @@ const srcRoot = path.resolve(
"src",
);
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+
export function resolve(specifier, context, nextResolve) {
+ if (specifier === "@features-manifest") {
+ const resolved = path.join(repoRoot, "features.json");
+ return nextResolve(resolved, context);
+ }
if (specifier.startsWith("@/")) {
const resolved = `${srcRoot}/${specifier.slice(2)}.ts`;
return nextResolve(resolved, context);
diff --git a/desktop/tsconfig.json b/desktop/tsconfig.json
index 456359a35..9ca2a735e 100644
--- a/desktop/tsconfig.json
+++ b/desktop/tsconfig.json
@@ -6,7 +6,8 @@
"module": "ESNext",
"skipLibCheck": true,
"paths": {
- "@/*": ["./src/*"]
+ "@/*": ["./src/*"],
+ "@features-manifest": ["../features.json"]
},
/* Bundler mode */
diff --git a/desktop/vite.config.ts b/desktop/vite.config.ts
index 6d20d7b69..1bac6c5bb 100644
--- a/desktop/vite.config.ts
+++ b/desktop/vite.config.ts
@@ -1,3 +1,4 @@
+import path from "node:path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
@@ -24,6 +25,7 @@ export default defineConfig(async () => ({
resolve: {
alias: {
"@": "/src",
+ "@features-manifest": path.resolve(__dirname, "../features.json"),
},
},