diff --git a/CHANGELOG.md b/CHANGELOG.md index c22935d9f..181facccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - The Secure your account setup step no longer shows up empty when no passkey or authenticator plugin is on - An installed plugin update older than the copy in a newer image is removed at startup instead of logging an error every boot - Canceling a download to the desktop app right as it starts no longer leaves a partial file behind +- Changing two interface settings at once, like resetting the dashboard, now saves both instead of only the last one ## 26.11.0 diff --git a/src/ui/contexts/UiPreferencesContext.tsx b/src/ui/contexts/UiPreferencesContext.tsx index 613a9e355..8b4f3f9fe 100644 --- a/src/ui/contexts/UiPreferencesContext.tsx +++ b/src/ui/contexts/UiPreferencesContext.tsx @@ -179,10 +179,24 @@ export function UiPreferencesProvider({ const merged = { ...pendingPatch.current }; for (const [key, value] of Object.entries(patch)) { if (key === "overrides" && value && typeof value === "object") { - merged.overrides = { + // Two levels deep like the server, so two keys set in one area + // in the same burst both get sent. + const areas = { ...((merged.overrides as Record) ?? {}), - ...(value as Record), }; + for (const [area, keys] of Object.entries( + value as Record, + )) { + const prev = areas[area]; + areas[area] = + keys && + typeof keys === "object" && + prev && + typeof prev === "object" + ? { ...prev, ...keys } + : keys; + } + merged.overrides = areas; } else if (key === "onboarding" && value && typeof value === "object") { const prev = (merged.onboarding ?? {}) as Record; const next = value as Record; diff --git a/src/ui/tests/contexts/UiPreferencesContext.test.tsx b/src/ui/tests/contexts/UiPreferencesContext.test.tsx index 31f806cc3..3f657f368 100644 --- a/src/ui/tests/contexts/UiPreferencesContext.test.tsx +++ b/src/ui/tests/contexts/UiPreferencesContext.test.tsx @@ -185,6 +185,30 @@ describe("UiPreferencesProvider", () => { expect(result.current?.preferences.overrides).toEqual({}); }); + it("sends every key set in one area within a burst", async () => { + vi.useFakeTimers(); + api.getUserPreferences.mockResolvedValue({ storageMode: "cloud" }); + api.getUiPreferences.mockResolvedValue({ + ...defaultUiPreferences(), + overrides: { "plugin:x": { grid: ["a"], slots: ["b"] } }, + }); + + const { result } = renderHook(() => useUiPreferencesContext(), { wrapper }); + await vi.waitFor(() => expect(result.current?.loaded).toBe(true)); + + act(() => { + result.current?.setPluginOverride("x", "grid", null); + result.current?.setPluginOverride("x", "slots", null); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(api.saveUiPreferences).toHaveBeenCalledWith({ + overrides: { "plugin:x": { grid: null, slots: null } }, + }); + }); + it("prunes an area once its last override is cleared", async () => { api.getUiPreferences.mockResolvedValue({ ...defaultUiPreferences(),