fix(settings): keep staged object settings stable across renders (#293)

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
This commit is contained in:
l0ng-ai authored and GitHub committed 2026-10-29 23:13:27 +08:00
1 parent aeec0e6759
commit c3b3534f08
2 files changed
+34 -2

No files matched your search

@@ -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 <output data-testid="proxy">{`${proxy.mode}:${proxy.host}`}</output>;
}
openSettings("general.httpProxy");
const view = render(
<PageContext.Provider value={{ pageId: "general.httpProxy", scope: "global" }}>
<Probe />
</PageContext.Provider>,
);
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({
+1 -1
View File
@@ -19,7 +19,7 @@ export function usePageContext(): PageContextValue {
export function useSetting<T>(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];
}