Skip to content

Commit bcedccc

Browse files
cliffhallclaude
andcommitted
fix(servers): make settings form re-render on every keystroke (#1361)
The settings modal is fully controlled by App.tsx, but pending edits were stashed in a useRef — refs don't trigger re-renders, so the input's `value` prop stayed stale until the 300ms debounce + PUT + refetch round-tripped. Clicking "Add Header" / "Add Metadata" showed no row, and typing in OAuth fields had no character echo, until that round-trip completed (~500ms). The bug landed in #1353 with the debounced-flush pattern. It surfaced after #1356 because per-keystroke PUTs now also write the keychain, making the round-trip latency more noticeable. Lift the draft into `useState`. Every change re-renders the modal immediately. Keep the 300ms debounce on the PUT side. Initialize the draft from the server-list entry only when the modal opens to a new target (not on every `servers` change) so background refreshes don't clobber the user's in-progress edits. Flush on close to preserve final keystrokes. Verified live against the dev server: header/metadata rows appear on click, OAuth Client ID / Client Secret typing echoes without lag, and the values persist correctly (clientId in mcp.json, clientSecret in the macOS Keychain). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 037225a commit bcedccc

1 file changed

Lines changed: 89 additions & 58 deletions

File tree

clients/web/src/App.tsx

Lines changed: 89 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -763,87 +763,118 @@ function App() {
763763
return servers.find((s) => s.id === configModal.targetId);
764764
}, [configModal, servers]);
765765

766-
const settingsModalTarget = useMemo(() => {
767-
if (!settingsModalTargetId) return undefined;
768-
return servers.find((s) => s.id === settingsModalTargetId);
769-
}, [settingsModalTargetId, servers]);
770-
771-
// Stable starting shape for entries that have no `settings` node yet — the
772-
// form needs concrete arrays / numbers to render.
773-
const settingsModalValue = useMemo<InspectorServerSettings>(() => {
774-
return (
775-
settingsModalTarget?.settings ?? {
766+
// The settings modal is a "dumb" component fully driven by the
767+
// `settings` prop we pass it — every keystroke fires `onSettingsChange`
768+
// back up here for us to render the next value. That round-trip needs
769+
// to be state, not a ref: a ref doesn't cause a re-render, so the
770+
// displayed input value can only change after the debounced PUT
771+
// completes and `servers` refetches, which feels broken (the user
772+
// types and nothing shows for ~half a second). Hold the in-progress
773+
// draft in `settingsDraft` so every keystroke re-renders immediately;
774+
// background refetches of `servers` don't reset it because the draft
775+
// is only (re)initialized when the modal opens to a new target id
776+
// (see effect below).
777+
const [settingsDraft, setSettingsDraft] =
778+
useState<InspectorServerSettings | null>(null);
779+
const settingsTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
780+
undefined,
781+
);
782+
783+
// Initialize / tear down the draft when the modal opens or closes
784+
// against a different target. We intentionally do NOT depend on
785+
// `servers` here — if the user is mid-edit and a background refresh
786+
// arrives, we don't want to clobber their in-progress changes.
787+
useEffect(() => {
788+
if (!settingsModalTargetId) {
789+
setSettingsDraft(null);
790+
return;
791+
}
792+
const target = servers.find((s) => s.id === settingsModalTargetId);
793+
setSettingsDraft(
794+
target?.settings ?? {
776795
headers: [],
777796
metadata: [],
778797
connectionTimeout: 0,
779798
requestTimeout: 0,
780-
}
799+
},
781800
);
782-
}, [settingsModalTarget]);
783-
784-
// The settings modal fires onSettingsChange on every keystroke. Persisting
785-
// each character would run a tight PUT-then-full-refresh loop against the
786-
// backend (and the in-memory `servers` state could flicker mid-typing as
787-
// each refresh resets the modal's prop). Debounce so a burst of edits
788-
// coalesces into a single PUT, and flush on modal close so nothing is lost.
789-
const pendingSettingsRef = useRef<{
790-
id: string;
791-
settings: InspectorServerSettings;
792-
} | null>(null);
793-
const pendingSettingsTimerRef = useRef<
794-
ReturnType<typeof setTimeout> | undefined
795-
>(undefined);
801+
// eslint-disable-next-line react-hooks/exhaustive-deps
802+
}, [settingsModalTargetId]);
803+
804+
// Stable fallback so the modal always has a valid `settings` prop,
805+
// even in the brief window where it's closed (Mantine renders the
806+
// component shell regardless of `opened`).
807+
const settingsModalValue = useMemo<InspectorServerSettings>(
808+
() =>
809+
settingsDraft ?? {
810+
headers: [],
811+
metadata: [],
812+
connectionTimeout: 0,
813+
requestTimeout: 0,
814+
},
815+
[settingsDraft],
816+
);
796817

797-
const flushPendingSettings = useCallback(() => {
798-
if (pendingSettingsTimerRef.current) {
799-
clearTimeout(pendingSettingsTimerRef.current);
800-
pendingSettingsTimerRef.current = undefined;
801-
}
802-
const pending = pendingSettingsRef.current;
803-
if (!pending) return;
804-
pendingSettingsRef.current = null;
805-
// Fire-and-forget — but surface failures via toast. The modal closes
806-
// immediately on user dismiss, so a silent fail-on-flush would leave
807-
// the user thinking their last edits saved when they didn't (especially
808-
// painful for the OAuth client secret).
809-
updateServerSettings(pending.id, pending.settings).catch((err) => {
810-
notifications.show({
811-
title: `Failed to save settings for "${pending.id}"`,
812-
message: err instanceof Error ? err.message : String(err),
813-
color: "red",
818+
// Fire the PUT. Surface failures via toast — the modal usually closes
819+
// immediately on user dismiss, so a silent fail-on-flush would leave
820+
// the user thinking their last edits saved when they didn't
821+
// (especially painful for the OAuth client secret).
822+
const sendSettingsUpdate = useCallback(
823+
(id: string, settings: InspectorServerSettings) => {
824+
updateServerSettings(id, settings).catch((err) => {
825+
notifications.show({
826+
title: `Failed to save settings for "${id}"`,
827+
message: err instanceof Error ? err.message : String(err),
828+
color: "red",
829+
});
814830
});
815-
});
816-
}, [updateServerSettings]);
831+
},
832+
[updateServerSettings],
833+
);
817834

818835
const onSettingsChange = useCallback(
819836
(next: InspectorServerSettings) => {
820837
if (!settingsModalTargetId) return;
821-
pendingSettingsRef.current = {
822-
id: settingsModalTargetId,
823-
settings: next,
824-
};
825-
if (pendingSettingsTimerRef.current) {
826-
clearTimeout(pendingSettingsTimerRef.current);
827-
}
828-
pendingSettingsTimerRef.current = setTimeout(flushPendingSettings, 300);
838+
// Update the displayed draft synchronously — this is the
839+
// re-render that makes typed characters and added rows appear.
840+
setSettingsDraft(next);
841+
// Debounce the PUT. Persisting each character would run a tight
842+
// PUT-then-full-refresh loop against the backend; coalesce a
843+
// burst of edits into one PUT and flush on modal close.
844+
if (settingsTimerRef.current) clearTimeout(settingsTimerRef.current);
845+
const id = settingsModalTargetId;
846+
settingsTimerRef.current = setTimeout(() => {
847+
settingsTimerRef.current = undefined;
848+
sendSettingsUpdate(id, next);
849+
}, 300);
829850
},
830-
[settingsModalTargetId, flushPendingSettings],
851+
[settingsModalTargetId, sendSettingsUpdate],
831852
);
832853

833854
const onSettingsModalClose = useCallback(() => {
834-
flushPendingSettings();
855+
// Flush any pending debounce synchronously so the last edits land
856+
// even if the user dismisses before the 300ms timer fires. The
857+
// useEffect above will null out the draft once the target id
858+
// clears, so we don't need to touch it here.
859+
if (settingsTimerRef.current) {
860+
clearTimeout(settingsTimerRef.current);
861+
settingsTimerRef.current = undefined;
862+
if (settingsDraft && settingsModalTargetId) {
863+
sendSettingsUpdate(settingsModalTargetId, settingsDraft);
864+
}
865+
}
835866
setSettingsModalTargetId(undefined);
836-
}, [flushPendingSettings]);
867+
}, [settingsDraft, settingsModalTargetId, sendSettingsUpdate]);
837868

838869
// Cancel any pending debounce on unmount (route change / HMR). Without
839870
// this a stale setTimeout could still fire one final `updateServerSettings`
840871
// against an unmounted component — harmless today (just an extra PUT of
841872
// the final payload) but the cleanest pattern is to clear the timer.
842873
useEffect(() => {
843874
return () => {
844-
if (pendingSettingsTimerRef.current) {
845-
clearTimeout(pendingSettingsTimerRef.current);
846-
pendingSettingsTimerRef.current = undefined;
875+
if (settingsTimerRef.current) {
876+
clearTimeout(settingsTimerRef.current);
877+
settingsTimerRef.current = undefined;
847878
}
848879
};
849880
}, []);

0 commit comments

Comments
 (0)