From c3b3534f0844ad49e6b5d1f7c97d2cce883a0ece Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:13:27 +0800 Subject: [PATCH] fix(settings): keep staged object settings stable across renders (#293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › HTTP Proxy crashed ("Maximum update depth exceeded") when switching to No proxy or Manual. useSetting selected stagedValue() inside the zustand selector; for an object key ("proxy") whose child keys are staged separately ("proxy.mode", "proxy.host"), stagedValue builds a new merged object on every call, so the snapshot was never stable. useSetting now reuses useStagedValue, which selects base and staged separately and memoises the merge — covering every useSetting on an object-valued key. No other selector calls stagedValue. Fixes #285 --- .../settings/__tests__/dialog.test.tsx | 34 ++++++++++++++++++- src/features/settings/components/context.ts | 2 +- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/features/settings/__tests__/dialog.test.tsx b/src/features/settings/__tests__/dialog.test.tsx index f33d9d401..a4bc899e7 100644 --- a/src/features/settings/__tests__/dialog.test.tsx +++ b/src/features/settings/__tests__/dialog.test.tsx @@ -24,8 +24,9 @@ import { registerAction, executeAction } from "@/platform/actions"; import { useProject } from "@/platform/project"; import { useKeymap } from "@/platform/keymap"; import { ContextMenuHost } from "@/ui"; +import { PageContext, useSetting } from "../components/context"; import { DEFAULT_SETTINGS, setAt } from "../model/schema"; -import { stage, useSettingsDialog } from "../model/staging"; +import { openSettings, stage, useSettingsDialog } from "../model/staging"; import { SettingsDialog } from "../dialog/SettingsDialog"; import { activate } from ".."; import { MessagesHost } from "@/workbench/messages"; @@ -271,6 +272,37 @@ describe("Settings dialog", () => { }); }); + it("keeps object settings stable when child keys are staged (HTTP Proxy)", async () => { + const seen: Array<{ mode: string; host: string }> = []; + function Probe() { + const [proxy] = useSetting<{ mode: string; host: string }>("proxy", "global"); + seen.push(proxy); + return {`${proxy.mode}:${proxy.host}`}; + } + openSettings("general.httpProxy"); + const view = render( + + + , + ); + expect(screen.getByTestId("proxy")).toHaveTextContent("auto:"); + act(() => { + stage("general.httpProxy", "global", "proxy.mode", "manual"); + stage("general.httpProxy", "global", "proxy.host", "127.0.0.1"); + }); + expect(screen.getByTestId("proxy")).toHaveTextContent("manual:127.0.0.1"); + expect(seen.at(-1)).toMatchObject({ mode: "manual", host: "127.0.0.1" }); + view.unmount(); + + const dialog = await openDialog("general.httpProxy"); + fireEvent.click(within(dialog).getByRole("radio", { name: "Manual proxy configuration" })); + expect(within(dialog).getByRole("radio", { name: "Manual proxy configuration" })).toBeChecked(); + fireEvent.change(within(dialog).getByLabelText("Host name"), { target: { value: "127.0.0.1" } }); + expect(within(dialog).getByLabelText("Host name")).toHaveValue("127.0.0.1"); + fireEvent.click(within(dialog).getByRole("radio", { name: "No proxy" })); + expect(within(dialog).getByRole("radio", { name: "No proxy" })).toBeChecked(); + }); + it("survives invalid settings values", async () => { act(() => useProject.setState({ diff --git a/src/features/settings/components/context.ts b/src/features/settings/components/context.ts index 9e762f663..d0a3fc690 100644 --- a/src/features/settings/components/context.ts +++ b/src/features/settings/components/context.ts @@ -19,7 +19,7 @@ export function usePageContext(): PageContextValue { export function useSetting(key: string, scopeOverride?: Scope): [T, (value: T) => void] { const { pageId, scope: pageScope } = usePageContext(); const scope = scopeOverride ?? pageScope; - const value = useSettingsDialog((s) => stagedValue(s, scope, key)) as T; + const value = useStagedValue(scope, key) as T; const set = useCallback((v: T) => stage(pageId, scope, key, v), [pageId, scope, key]); return [value, set]; }