fix(FN-2528): canonicalize Dev Server experimental feature flag

- Treat devServer as a legacy alias of canonical devServerView in Experimental Features
- Canonicalize feature keys when rendering and saving settings to prevent duplicate Dev Server rows
- Preserve enabled-state compatibility by reading either key in useAppSettings
- Add regression tests for single-row rendering, alias normalization on save, and legacy hook behavior
This commit is contained in:
Fusion
2026-04-25 16:28:38 -07:00
committed by gsxdsm
parent ad416b5581
commit 359e08c45c
4 changed files with 128 additions and 12 deletions

View File

@@ -93,17 +93,49 @@ const DEFAULT_NTFY_EVENTS: NtfyNotificationEvent[] = [
/** Well-known experimental feature flags with display labels.
* These always appear in the Experimental Features settings tab,
* regardless of whether they exist in the project's settings blob. */
* regardless of whether they exist in the project's settings blob.
* IMPORTANT: Dev Server is canonically keyed by `devServerView`; `devServer`
* is treated as a legacy alias and must never render as a second row. */
const KNOWN_EXPERIMENTAL_FEATURES: Record<string, string> = {
insights: "Insights",
roadmap: "Roadmaps",
memoryView: "Memory Editor",
skillsView: "Skills View",
nodesView: "Nodes View",
devServer: "Dev Server",
devServerView: "Dev Server View",
devServerView: "Dev Server",
};
const EXPERIMENTAL_FEATURE_LEGACY_ALIASES: Record<string, string> = {
devServer: "devServerView",
};
function getCanonicalExperimentalFeatureKey(key: string): string {
return EXPERIMENTAL_FEATURE_LEGACY_ALIASES[key] ?? key;
}
function isExperimentalFeatureEnabled(features: Record<string, boolean>, key: string): boolean {
if (features[key] === true) {
return true;
}
return Object.entries(EXPERIMENTAL_FEATURE_LEGACY_ALIASES).some(
([legacyKey, canonicalKey]) => canonicalKey === key && features[legacyKey] === true,
);
}
function normalizeExperimentalFeaturesForSave(features?: Record<string, boolean>): Record<string, boolean> {
if (!features) {
return {};
}
const normalized: Record<string, boolean> = {};
for (const [key, enabled] of Object.entries(features)) {
normalized[getCanonicalExperimentalFeatureKey(key)] = enabled;
}
return normalized;
}
type LegacySectionId = "pi-extensions";
export type SectionId = SettingsSection["id"] | LegacySectionId;
@@ -963,6 +995,7 @@ export function SettingsModal({
worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined,
taskPrefix: form.taskPrefix?.trim() || undefined,
overlapIgnorePaths: (form.overlapIgnorePaths ?? []).map((path) => path.trim()).filter((path) => path.length > 0),
experimentalFeatures: normalizeExperimentalFeaturesForSave(form.experimentalFeatures),
};
// Always save both global and project settings with strict scope separation.
@@ -2811,11 +2844,16 @@ export function SettingsModal({
}
case "experimental": {
const experimentalFeatures = form.experimentalFeatures ?? {};
// Merge known features (always shown) with any custom features from settings
// Merge known features (always shown) with custom features from settings,
// while canonicalizing legacy aliases (e.g. devServer → devServerView)
// so only one user-visible row is rendered per feature.
const allFeatureKeys = Array.from(
new Set([...Object.keys(KNOWN_EXPERIMENTAL_FEATURES), ...Object.keys(experimentalFeatures)])
new Set([
...Object.keys(KNOWN_EXPERIMENTAL_FEATURES),
...Object.keys(experimentalFeatures).map(getCanonicalExperimentalFeatureKey),
])
).sort((a, b) => a.localeCompare(b));
const featureFlags = allFeatureKeys.map((key) => [key, experimentalFeatures[key] === true] as const);
const featureFlags = allFeatureKeys.map((key) => [key, isExperimentalFeatureEnabled(experimentalFeatures, key)] as const);
return (
<>
@@ -2838,13 +2876,23 @@ export function SettingsModal({
type="checkbox"
checked={enabled}
onChange={(e) => {
setForm((f) => ({
...f,
experimentalFeatures: {
setForm((f) => {
const nextExperimentalFeatures = {
...(f.experimentalFeatures ?? {}),
[key]: e.target.checked,
},
}));
};
for (const [legacyKey, canonicalKey] of Object.entries(EXPERIMENTAL_FEATURE_LEGACY_ALIASES)) {
if (canonicalKey === key) {
delete nextExperimentalFeatures[legacyKey];
}
}
return {
...f,
experimentalFeatures: nextExperimentalFeatures,
};
});
}}
/>
<span>{KNOWN_EXPERIMENTAL_FEATURES[key] ?? key}</span>

View File

@@ -891,6 +891,54 @@ describe("SettingsModal", () => {
expect(screen.getByText("Roadmaps")).toBeInTheDocument();
});
it("shows a single canonical Dev Server toggle", async () => {
renderModal();
await openExperimentalFeaturesSection();
const devServerToggles = screen.getAllByLabelText("Dev Server");
expect(devServerToggles).toHaveLength(1);
});
it("does not render duplicate Dev Server rows when legacy and canonical keys are both present", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: { devServer: true, devServerView: true },
});
renderModal();
await openExperimentalFeaturesSection();
const devServerToggles = screen.getAllByLabelText("Dev Server");
expect(devServerToggles).toHaveLength(1);
expect(devServerToggles[0]).toBeChecked();
});
it("normalizes legacy devServer flag to canonical devServerView on save", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: { devServer: true },
});
renderModal();
await openExperimentalFeaturesSection();
const devServerToggle = screen.getByLabelText("Dev Server") as HTMLInputElement;
expect(devServerToggle).toBeChecked();
await userEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
});
const payload = mockUpdateSettings.mock.calls[0][0];
expect(payload.experimentalFeatures).toEqual({ devServerView: true });
expect(payload.experimentalFeatures.devServer).toBeUndefined();
});
it("shows feature flags when experimentalFeatures is set", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,

View File

@@ -159,4 +159,24 @@ describe("useAppSettings", () => {
// config defaults remain (maxConcurrent stays at initial 2)
expect(result.current.maxConcurrent).toBe(2);
});
it("treats legacy experimentalFeatures.devServer as enabling Dev Server", async () => {
mockFetchSettings.mockResolvedValueOnce({
autoMerge: false,
globalPause: false,
enginePaused: false,
prAuthAvailable: true,
taskStuckTimeoutMs: 600000,
showQuickChatFAB: false,
experimentalFeatures: {
devServer: true,
},
} as never);
const { result } = renderHook(() => useAppSettings("proj_123"));
await waitFor(() => {
expect(result.current.devServerEnabled).toBe(true);
});
});
});

View File

@@ -72,7 +72,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
setInsightsEnabled(features.insights === true);
setRoadmapEnabled(features.roadmap === true);
setMemoryEnabled(features.memoryView === true);
setDevServerEnabled(features.devServerView === true);
setDevServerEnabled(features.devServerView === true || features.devServer === true);
}
}, [projectId]);