From 650ce6a83976dd26798b88989ca101fefcba63cd Mon Sep 17 00:00:00 2001 From: Lance Woodson <65194841+woodsonl@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:31:18 -0500 Subject: [PATCH 1/2] fix(compression): surface failed settings loads on Hub, Combos and RTK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/settings/compression failures were swallowed on three dashboard surfaces: a non-OK reply became null and a thrown fetch hit an empty catch, so each rendered hard-coded defaults as if they were the stored settings — the Hub showed Default (from panel) and Context Editing off with both controls live, the combos manager suppressed the master-switch warning, hid the Active badge and disabled the override selects, and the RTK page showed the master-switch-OFF banner even when the stored flag was on. Each surface now keeps a loadFailed/loadAttempt pair and re-runs its load per attempt with an ignore flag in the effect cleanup, mirroring #15346 in CompressionPanel.tsx and CompressionSettingsTab.tsx: a failed load shows Prompt Compression: Failed To Load with a Retry, and every control that depends on the loaded settings stays off screen until a GET succeeds. --- .../combos/CompressionCombosPageClient.tsx | 51 ++++- .../context/combos/CompressionHub.tsx | 34 +++- .../context/rtk/RtkContextPageClient.tsx | 42 +++- tests/unit/ui/compressionHub.test.tsx | 99 ++++++++++ .../unit/ui/namedCombos-active-badge.test.tsx | 181 ++++++++++++++++-- .../ui/rtk-context-page-load-failure.test.tsx | 146 ++++++++++++++ 6 files changed, 527 insertions(+), 26 deletions(-) create mode 100644 tests/unit/ui/rtk-context-page-load-failure.test.tsx diff --git a/src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx b/src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx index 2d13c65969e5..d90b66df0edd 100644 --- a/src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx +++ b/src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx @@ -39,6 +39,8 @@ const ENGINE_INTENSITIES: Record = STACKED_PIPELINE_E function NamedCombosManager() { const t = useTranslations("contextCombos"); + const tSettings = useTranslations("settings"); + const tCommon = useTranslations("common"); const [combos, setCombos] = useState([]); const [routingCombos, setRoutingCombos] = useState([]); const [languagePacks, setLanguagePacks] = useState([]); @@ -54,6 +56,12 @@ function NamedCombosManager() { const [activeComboId, setActiveComboId] = useState(null); const [compressionEnabled, setCompressionEnabled] = useState(false); const [error, setError] = useState(null); + // The defaults (activeComboId null, compressionEnabled false) suppress the + // master-switch warning, hide the Active badge and disable the override selects, + // so a failed settings GET must show a retry instead. A retry re-runs the loads; + // answers that arrive for the run it replaced are ignored. + const [loadFailed, setLoadFailed] = useState(false); + const [loadAttempt, setLoadAttempt] = useState(0); const refresh = () => { fetch("/api/context/combos") @@ -63,23 +71,37 @@ function NamedCombosManager() { }; useEffect(() => { + let ignore = false; refresh(); fetch("/api/combos") .then((res) => (res.ok ? res.json() : null)) - .then((data) => setRoutingCombos(Array.isArray(data?.combos) ? data.combos : [])) + .then((data) => { + if (!ignore) setRoutingCombos(Array.isArray(data?.combos) ? data.combos : []); + }) .catch(() => {}); fetch("/api/compression/language-packs") .then((res) => (res.ok ? res.json() : null)) - .then((data) => setLanguagePacks(Array.isArray(data?.packs) ? data.packs : [])) + .then((data) => { + if (!ignore) setLanguagePacks(Array.isArray(data?.packs) ? data.packs : []); + }) .catch(() => {}); fetch("/api/settings/compression") .then((res) => (res.ok ? res.json() : null)) .then((data) => { - setActiveComboId(data?.activeComboId ?? null); - setCompressionEnabled(Boolean(data?.enabled)); + if (ignore) return; + if (data) { + setActiveComboId(data.activeComboId ?? null); + setCompressionEnabled(Boolean(data.enabled)); + } + setLoadFailed(!data); }) - .catch(() => {}); - }, []); + .catch(() => { + if (!ignore) setLoadFailed(true); + }); + return () => { + ignore = true; + }; + }, [loadAttempt]); const resetForm = () => { setEditingId(null); @@ -180,6 +202,23 @@ function NamedCombosManager() { ); }; + if (loadFailed) { + return ( +
+

+ {tSettings("compressionTitle")}: {tCommon("failedToLoad")} +

+ +
+ ); + } + return (
diff --git a/src/app/(dashboard)/dashboard/context/combos/CompressionHub.tsx b/src/app/(dashboard)/dashboard/context/combos/CompressionHub.tsx index 6596764e3304..5573feff7012 100644 --- a/src/app/(dashboard)/dashboard/context/combos/CompressionHub.tsx +++ b/src/app/(dashboard)/dashboard/context/combos/CompressionHub.tsx @@ -64,9 +64,16 @@ function Toggle({ export default function CompressionHub() { const t = useTranslations("contextCombos"); + const tSettings = useTranslations("settings"); + const tCommon = useTranslations("common"); const [settings, setSettings] = useState(null); const [combos, setCombos] = useState([]); const [loading, setLoading] = useState(true); + // A failed settings load must not show the default-profile view: its select and + // Context Editing toggle would save the defaults over the stored row. A retry + // re-runs the load; answers that arrive for the run it replaced are ignored. + const [loadFailed, setLoadFailed] = useState(false); + const [loadAttempt, setLoadAttempt] = useState(0); const [explainerOpen, setExplainerOpen] = useState(false); const [error, setError] = useState(null); @@ -87,9 +94,8 @@ export default function CompressionHub() { if (cancelled) return; if (settingsData) { setSettings(settingsData as CompressionSettings); - } else { - setSettings({ enabled: false, defaultMode: "off", contextEditing: { enabled: false } }); } + setLoadFailed(!settingsData); if (Array.isArray(combosData?.combos)) { setCombos(combosData.combos as NamedCombo[]); } @@ -99,7 +105,7 @@ export default function CompressionHub() { return () => { cancelled = true; }; - }, []); + }, [loadAttempt]); // ── Settings mutations ─────────────────────────────────────────────────────── const saveSettings = useCallback( @@ -140,6 +146,28 @@ export default function CompressionHub() { ); } + if (loadFailed) { + return ( +
+
+

+ {tSettings("compressionTitle")}: {tCommon("failedToLoad")} +

+ +
+
+ ); + } + const activeCombo = combos.find((c) => c.id === settings?.activeComboId) ?? null; const activePipelineText = activeCombo ? activeCombo.pipeline.map((s) => s.engine).join(" → ") diff --git a/src/app/(dashboard)/dashboard/context/rtk/RtkContextPageClient.tsx b/src/app/(dashboard)/dashboard/context/rtk/RtkContextPageClient.tsx index c49f89b70f6b..80b69217dafe 100644 --- a/src/app/(dashboard)/dashboard/context/rtk/RtkContextPageClient.tsx +++ b/src/app/(dashboard)/dashboard/context/rtk/RtkContextPageClient.tsx @@ -63,6 +63,8 @@ function formatNumber(value: number | undefined): string { export default function RtkContextPageClient() { const t = useTranslations("contextRtk"); + const tSettings = useTranslations("settings"); + const tCommon = useTranslations("common"); const [filters, setFilters] = useState([]); const [config, setConfig] = useState(null); const [analytics, setAnalytics] = useState(null); @@ -71,13 +73,28 @@ export default function RtkContextPageClient() { const [saving, setSaving] = useState(false); const [viewMode, setViewMode] = useState<"simple" | "advanced">("simple"); const [masterEnabled, setMasterEnabled] = useState(null); + // The stored master flag decides the "master switch is OFF" banner, so a failed + // settings GET must not read as off. A retry re-runs the load; answers that arrive + // for the run it replaced are ignored. + const [loadFailed, setLoadFailed] = useState(false); + const [loadAttempt, setLoadAttempt] = useState(0); useEffect(() => { + let ignore = false; fetch("/api/settings/compression") .then((res) => (res.ok ? res.json() : null)) - .then((data) => setMasterEnabled(data?.enabled ?? false)) - .catch(() => {}); - }, []); + .then((data) => { + if (ignore) return; + if (data) setMasterEnabled(Boolean(data.enabled)); + setLoadFailed(!data); + }) + .catch(() => { + if (!ignore) setLoadFailed(true); + }); + return () => { + ignore = true; + }; + }, [loadAttempt]); const loadFilters = () => fetch("/api/context/rtk/filters") @@ -152,6 +169,25 @@ export default function RtkContextPageClient() { [t("avgSavings"), `${rtkStats?.avgSavingsPct ?? analytics?.avgSavingsPct ?? 0}%`], ]; + if (loadFailed) { + return ( +
+
+

+ {tSettings("compressionTitle")}: {tCommon("failedToLoad")} +

+ +
+
+ ); + } + return (
diff --git a/tests/unit/ui/compressionHub.test.tsx b/tests/unit/ui/compressionHub.test.tsx index 20674e153bad..964a161ed628 100644 --- a/tests/unit/ui/compressionHub.test.tsx +++ b/tests/unit/ui/compressionHub.test.tsx @@ -200,3 +200,102 @@ describe("CompressionCombosPageClient", () => { expect(text).toContain("Named combos"); }); }); + +describe("CompressionHub when the settings GET fails", () => { + // The settings GET fails as it does while the server restarts; the combos GET keeps + // answering. A failed load must offer a retry instead of the default-profile view, + // whose select and Context Editing toggle would save the defaults over the stored row. + const FAILURES = { + "a 500": async () => new Response(JSON.stringify({ error: "unavailable" }), { status: 500 }), + "a network error": async () => { + throw new TypeError("Failed to fetch"); + }, + }; + + function failSettingsGet(failure: keyof typeof FAILURES) { + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/api/settings/compression")) return FAILURES[failure](); + if (url.includes("/api/context/combos")) return json({ combos: [] }); + return json({}, 404); + }); + } + + async function mountHub() { + const { default: CompressionHub } = + await import("../../../src/app/(dashboard)/dashboard/context/combos/CompressionHub"); + let container!: HTMLElement; + await act(async () => { + container = mountInContainer(); + }); + await flush(); + return container; + } + + function retryButton(container: HTMLElement) { + return Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Retry" + ); + } + + it.each(Object.keys(FAILURES) as Array)( + "shows a retry in place of the default-profile view after %s", + { timeout: 20000 }, + async (failure) => { + failSettingsGet(failure); + const container = await mountHub(); + + expect(container.querySelector('[data-testid="active-profile-select"]')).toBeNull(); + expect(container.querySelectorAll('[role="switch"]')).toHaveLength(0); + expect(container.textContent).toContain("Failed To Load"); + expect(retryButton(container)).toBeTruthy(); + } + ); + + it("returns with the stored settings once Retry answers", async () => { + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/api/settings/compression")) { + return json({ enabled: true, defaultMode: "rtk", contextEditing: { enabled: true } }); + } + if (url.includes("/api/context/combos")) { + return json({ + combos: [{ id: "c1", name: "Alpha", pipeline: [{ engine: "rtk" }] }], + }); + } + return json({}, 404); + }); + // The Hub's first call is the settings GET; only it fails. + fetchSpy.mockImplementationOnce( + async () => new Response(JSON.stringify({ error: "unavailable" }), { status: 500 }) + ); + const container = await mountHub(); + expect(container.textContent).toContain("Failed To Load"); + + await act(async () => { + retryButton(container)!.click(); + }); + await flush(); + + expect(container.textContent).not.toContain("Failed To Load"); + // The stored row has Context Editing on, which the defaults do not. + const toggle = container.querySelector('[role="switch"]'); + expect(toggle?.getAttribute("aria-checked")).toBe("true"); + const select = container.querySelector( + '[data-testid="active-profile-select"]' + ) as HTMLSelectElement | null; + expect(Array.from(select?.options ?? []).some((option) => option.text === "Alpha")).toBe(true); + }); +}); diff --git a/tests/unit/ui/namedCombos-active-badge.test.tsx b/tests/unit/ui/namedCombos-active-badge.test.tsx index ae9970e39d48..3c797f461f4e 100644 --- a/tests/unit/ui/namedCombos-active-badge.test.tsx +++ b/tests/unit/ui/namedCombos-active-badge.test.tsx @@ -19,7 +19,9 @@ function mount(ui: React.ReactElement): HTMLElement { } beforeEach(() => { - (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; }); afterEach(async () => { @@ -55,23 +57,26 @@ function setupFetchMock() { const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); const combos = [combo("c1", "Alpha"), combo("c2", "Bravo")]; - vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = input.toString(); - void init; - if (url.includes("/api/context/combos/") && url.includes("/assignments")) return json({ assignments: [] }); - if (url.includes("/api/context/combos")) return json({ combos }); - if (url.includes("/api/combos")) return json({ combos: [] }); - if (url.includes("/api/compression/language-packs")) return json({ packs: [] }); - if (url.includes("/api/settings/compression")) return json({ activeComboId: "c2", enabled: true }); - return json({}, 404); - }); + vi.spyOn(globalThis, "fetch").mockImplementation( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + void init; + if (url.includes("/api/context/combos/") && url.includes("/assignments")) + return json({ assignments: [] }); + if (url.includes("/api/context/combos")) return json({ combos }); + if (url.includes("/api/combos")) return json({ combos: [] }); + if (url.includes("/api/compression/language-packs")) return json({ packs: [] }); + if (url.includes("/api/settings/compression")) + return json({ activeComboId: "c2", enabled: true }); + return json({}, 404); + } + ); } describe("NamedCombosManager — active badge, no set-as-default", () => { async function render() { - const { default: CompressionCombosPageClient } = await import( - "../../../src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient" - ); + const { default: CompressionCombosPageClient } = + await import("../../../src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient"); let container!: HTMLElement; await act(async () => { container = mount(); @@ -93,3 +98,151 @@ describe("NamedCombosManager — active badge, no set-as-default", () => { expect(container.querySelector('[data-testid="active-badge-c1"]')).toBeNull(); }); }); + +describe("NamedCombosManager when the settings GET fails", () => { + // The settings GET fails as it does while the server restarts. Its defaults + // (activeComboId null, compressionEnabled false) suppress the master-switch warning, + // hide the Active badge and disable the override selects, so a failed load must offer + // a retry instead of that wrong view. + const FAILURES = { + "a 500": async () => new Response(JSON.stringify({ error: "unavailable" }), { status: 500 }), + "a network error": async () => { + throw new TypeError("Failed to fetch"); + }, + }; + + function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + } + + async function mountPage() { + const { default: CompressionCombosPageClient } = + await import("../../../src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + return container; + } + + function retryButton(container: HTMLElement) { + return Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Retry" + ); + } + + // The page mounts the Hub and the manager side by side; when the settings GET fails + // for both, each shows its own Retry, and every one must be pressed to recover. + async function clickAllRetryButtons(container: HTMLElement) { + for (;;) { + const retry = retryButton(container); + if (!retry) return; + await act(async () => { + retry.click(); + }); + await flush(); + } + } + + // The Hub's settings GET fires before the manager's, so the first two calls are the + // ones that fail; both retries reach the stored row. + function settingsFetch(settingsCalls: { count: number }, fail: () => Promise) { + return vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/api/settings/compression")) { + settingsCalls.count += 1; + if (settingsCalls.count <= 2) return fail(); + return json({ activeComboId: "c2", enabled: false }); + } + if (url.includes("/api/context/combos/")) return json({ assignments: [] }); + if (url.includes("/api/context/combos")) return json({ combos: [combo("c2", "Bravo")] }); + if (url.includes("/api/combos")) return json({ combos: [] }); + if (url.includes("/api/compression/language-packs")) return json({ packs: [] }); + return json({}, 404); + }); + } + + it.each(Object.keys(FAILURES) as Array)( + "shows a retry in place of the defaulted manager after %s", + { timeout: 20000 }, + async (failure) => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/api/settings/compression")) return FAILURES[failure](); + if (url.includes("/api/context/combos/")) return json({ assignments: [] }); + if (url.includes("/api/context/combos")) return json({ combos: [combo("c2", "Bravo")] }); + if (url.includes("/api/combos")) return json({ combos: [] }); + if (url.includes("/api/compression/language-packs")) return json({ packs: [] }); + return json({}, 404); + }); + const container = await mountPage(); + + expect(container.textContent).toContain("Failed To Load"); + expect( + container.querySelector('[data-testid="compression-master-switch-warning"]') + ).toBeNull(); + expect(container.querySelector('[data-testid="active-badge-c2"]')).toBeNull(); + // No control that depends on the loaded settings stays on screen. + expect(container.querySelectorAll("select")).toHaveLength(0); + expect(retryButton(container)).toBeTruthy(); + } + ); + + it("shows the master-switch warning and the Active badge once Retry answers", async () => { + const settingsCalls = { count: 0 }; + settingsFetch(settingsCalls, FAILURES["a 500"]); + const container = await mountPage(); + expect(container.textContent).toContain("Failed To Load"); + + await clickAllRetryButtons(container); + + expect(container.textContent).not.toContain("Failed To Load"); + expect( + container.querySelector('[data-testid="compression-master-switch-warning"]') + ).toBeTruthy(); + expect(container.querySelector('[data-testid="active-badge-c2"]')).toBeTruthy(); + }); + + it("ignores a routing-combos answer from the load a retry superseded", async () => { + let settingsCalls = 0; + let releaseStaleRouting!: (response: Response) => void; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/api/settings/compression")) { + settingsCalls += 1; + if (settingsCalls <= 2) return FAILURES["a 500"](); + return json({ activeComboId: "c2", enabled: false }); + } + if (url.includes("/api/context/combos/")) return json({ assignments: [] }); + if (url.includes("/api/context/combos")) return json({ combos: [combo("c2", "Bravo")] }); + if (url.includes("/api/combos")) { + // The first load's routing-combos GET stays open until after the retried load + // has finished; its answer belongs to the run the retry superseded. + if (!releaseStaleRouting) { + return new Promise((resolve) => { + releaseStaleRouting = (response) => resolve(response); + }); + } + return json({ combos: [] }); + } + if (url.includes("/api/compression/language-packs")) return json({ packs: [] }); + return json({}, 404); + }); + const container = await mountPage(); + await clickAllRetryButtons(container); + expect( + container.querySelector('[data-testid="compression-master-switch-warning"]') + ).toBeTruthy(); + + await act(async () => { + releaseStaleRouting(json({ combos: [{ id: "stale", name: "StaleRoute", config: null }] })); + }); + await flush(); + + expect(container.textContent).not.toContain("StaleRoute"); + }); +}); diff --git a/tests/unit/ui/rtk-context-page-load-failure.test.tsx b/tests/unit/ui/rtk-context-page-load-failure.test.tsx new file mode 100644 index 000000000000..37f48c759f08 --- /dev/null +++ b/tests/unit/ui/rtk-context-page-load-failure.test.tsx @@ -0,0 +1,146 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +const containers: HTMLElement[] = []; +const roots: Array<{ unmount: () => void }> = []; + +function mount(ui: React.ReactElement): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + const root = createRoot(container); + roots.push(root); + act(() => { + root.render(ui); + }); + return container; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await act(async () => { + while (roots.length > 0) roots.pop()?.unmount(); + }); + for (let i = 0; i < 10; i++) await Promise.resolve(); + while (containers.length > 0) containers.pop()?.remove(); + document.body.innerHTML = ""; +}); + +async function flush() { + await act(async () => { + for (let i = 0; i < 10; i++) await Promise.resolve(); + }); +} + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function rtkConfig() { + return { + enabled: true, + intensity: "standard", + applyToToolResults: true, + applyToAssistantMessages: false, + applyToCodeBlocks: false, + enabledFilters: [], + disabledFilters: [], + maxLinesPerResult: 40, + maxCharsPerResult: 4000, + deduplicateThreshold: 30, + customFiltersEnabled: false, + trustProjectFilters: false, + rawOutputRetention: "never", + rawOutputMaxBytes: 8192, + }; +} + +async function mountPage() { + const { default: RtkContextPageClient } = + await import("../../../src/app/(dashboard)/dashboard/context/rtk/RtkContextPageClient"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + return container; +} + +function retryButton(container: HTMLElement) { + return Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Retry" + ); +} + +describe("RtkContextPageClient when the settings GET fails", () => { + // The page reads only the master flag from GET /api/settings/compression. A failed + // GET must not fall back to enabled:false, which would show the "master switch is + // OFF" banner while the stored flag is on. + const FAILURES = { + "a 500": async () => new Response(JSON.stringify({ error: "unavailable" }), { status: 500 }), + "a network error": async () => { + throw new TypeError("Failed to fetch"); + }, + }; + + it.each(Object.keys(FAILURES) as Array)( + "shows a retry instead of the master-switch-off banner after %s", + { timeout: 20000 }, + async (failure) => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/api/settings/compression")) return FAILURES[failure](); + if (url.includes("/api/context/rtk/filters")) return json({ filters: [] }); + if (url.includes("/api/context/rtk/config")) return json(rtkConfig()); + if (url.includes("/api/context/analytics")) return json({}); + return json({}, 404); + }); + const container = await mountPage(); + + expect(container.textContent).toContain("Failed To Load"); + expect(container.textContent).not.toContain("master switch is OFF"); + // No control that depends on the loaded settings stays on screen. + expect(container.querySelectorAll("input, select, textarea")).toHaveLength(0); + expect(retryButton(container)).toBeTruthy(); + } + ); + + it("drops the master-switch-off banner once Retry answers", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/api/settings/compression")) return json({ enabled: true }); + if (url.includes("/api/context/rtk/filters")) return json({ filters: [] }); + if (url.includes("/api/context/rtk/config")) return json(rtkConfig()); + if (url.includes("/api/context/analytics")) return json({}); + return json({}, 404); + }); + fetchSpy.mockImplementationOnce( + async () => new Response(JSON.stringify({ error: "unavailable" }), { status: 500 }) + ); + const container = await mountPage(); + expect(container.textContent).toContain("Failed To Load"); + + await act(async () => { + retryButton(container)!.click(); + }); + await flush(); + + expect(container.textContent).not.toContain("Failed To Load"); + // The stored flag is on, so the banner the defaults would show stays hidden. + expect(container.textContent).not.toContain("master switch is OFF"); + expect(container.querySelectorAll("input, select, textarea").length).toBeGreaterThan(0); + }); +}); From 6f43cc91e3f3381bdcc7605369a8fccb50d8fdf8 Mon Sep 17 00:00:00 2001 From: Lance Woodson <65194841+woodsonl@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:34:47 -0500 Subject: [PATCH 2/2] docs(changelog): add the #15583 fixes fragment --- changelog.d/fixes/15583-surface-failed-settings-loads.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/fixes/15583-surface-failed-settings-loads.md diff --git a/changelog.d/fixes/15583-surface-failed-settings-loads.md b/changelog.d/fixes/15583-surface-failed-settings-loads.md new file mode 100644 index 000000000000..3d060e32efe0 --- /dev/null +++ b/changelog.d/fixes/15583-surface-failed-settings-loads.md @@ -0,0 +1 @@ +- **fix(compression):** the Compression Hub, the combos manager and the RTK page show a Retry when `GET /api/settings/compression` fails, and keep the controls that depend on the loaded settings off screen until a GET succeeds, so a failed load can no longer render defaults as the stored settings ([#15583](https://github.com/diegosouzapw/OmniRoute/pull/15583)) — thanks @woodsonl