Skip to content

Commit f453790

Browse files
cliffhallclaude
andcommitted
chore(servers): extract useSettingsDraft hook + tests; review polish (#1361)
Picks up the three review observations on #1362: 1. Extract the draft state + debounce + flush logic from App.tsx into `core/react/useSettingsDraft`. Same behavior; thinner App.tsx; testable in isolation with `renderHook`. Tests pin the regression #1361 was about (synchronous onChange → draft update) plus the debounce window, burst-collapse, flush-on-close, error propagation, and the "background refresh must not clobber in-progress edits" contract via a `resolveInitial` closure swap. 2. Hoist the empty `InspectorServerSettings` fallback to a module-scope `EMPTY_SETTINGS` const. Both the hook's init path and the modal's closed-state fallback share the same object identity; the inline `useMemo` that previously re-derived it is gone. 3. Add a comment on the `resolveInitial` callback noting that we read `servers` from this render's closure under the assumption that the entry point is the "Settings" button on a rendered server card. Documents the contract for future callers (e.g. a keyboard shortcut that opens the modal pre-hydration). Validate clean: 1756 tests pass. New useSettingsDraft.test.tsx adds 13 test cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bcedccc commit f453790

3 files changed

Lines changed: 521 additions & 109 deletions

File tree

clients/web/src/App.tsx

Lines changed: 54 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { StderrLogState } from "@inspector/core/mcp/state/stderrLogState.js";
3030
import type { RedirectUrlProvider } from "@inspector/core/auth/index.js";
3131
import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js";
3232
import { useServers } from "@inspector/core/react/useServers.js";
33+
import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js";
3334
import { useManagedTools } from "@inspector/core/react/useManagedTools.js";
3435
import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js";
3536
import { useManagedResources } from "@inspector/core/react/useManagedResources.js";
@@ -126,6 +127,19 @@ function messagesToLogEntries(messages: MessageEntry[]): LogEntryData[] {
126127
return out;
127128
}
128129

130+
// Stable empty-shell for `InspectorServerSettings`. Used both as the
131+
// initial draft for a server entry that hasn't been touched yet, and as
132+
// the fallback the settings modal renders against when it's closed
133+
// (Mantine renders the dialog shell regardless of `opened`). Hoisted to
134+
// module scope so both call sites share the same object identity and so
135+
// React doesn't re-allocate on every render.
136+
const EMPTY_SETTINGS: InspectorServerSettings = {
137+
headers: [],
138+
metadata: [],
139+
connectionTimeout: 0,
140+
requestTimeout: 0,
141+
};
142+
129143
function App() {
130144
// Theme toggle plumbing (preserved from the pre-wire placeholder).
131145
const { setColorScheme } = useMantineColorScheme();
@@ -763,121 +777,52 @@ function App() {
763777
return servers.find((s) => s.id === configModal.targetId);
764778
}, [configModal, servers]);
765779

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 ?? {
795-
headers: [],
796-
metadata: [],
797-
connectionTimeout: 0,
798-
requestTimeout: 0,
799-
},
800-
);
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-
);
817-
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-
});
780+
// The settings modal is fully controlled — every input change fires
781+
// `onSettingsChange` back up here, and the input's `value` prop only
782+
// updates when this component re-renders with a new `settings` prop.
783+
// We hold the in-progress draft in `useSettingsDraft` so every change
784+
// re-renders synchronously; the hook also debounces the PUT and
785+
// exposes `flush` for the close handler to call. The draft is
786+
// (re)initialized only when the modal opens to a *different* server,
787+
// which is why a background refresh of `servers` can run without
788+
// clobbering in-progress edits.
789+
//
790+
// `resolveInitial` reads `servers` from this render's closure — that
791+
// works because the settings entry point is the "Settings" button on
792+
// a rendered server card, so `servers` is always non-empty by the
793+
// time this hook is called. A future caller that opens the modal
794+
// from elsewhere (e.g. a keyboard shortcut on initial load) would
795+
// need a different initialization path; the empty-shell fallback at
796+
// least keeps the form renderable while `servers` hydrates.
797+
const {
798+
draft: settingsDraft,
799+
onChange: onSettingsChange,
800+
flush: flushSettingsDraft,
801+
} = useSettingsDraft<InspectorServerSettings>({
802+
targetId: settingsModalTargetId,
803+
resolveInitial: (id) =>
804+
servers.find((s) => s.id === id)?.settings ?? EMPTY_SETTINGS,
805+
onPersist: updateServerSettings,
806+
// Surface failures via toast — the modal usually closes
807+
// immediately on user dismiss, so a silent fail-on-flush would
808+
// leave the user thinking their last edits saved when they
809+
// didn't (especially painful for the OAuth client secret).
810+
onError: (id, err) => {
811+
notifications.show({
812+
title: `Failed to save settings for "${id}"`,
813+
message: err instanceof Error ? err.message : String(err),
814+
color: "red",
830815
});
831816
},
832-
[updateServerSettings],
833-
);
817+
});
834818

835-
const onSettingsChange = useCallback(
836-
(next: InspectorServerSettings) => {
837-
if (!settingsModalTargetId) return;
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);
850-
},
851-
[settingsModalTargetId, sendSettingsUpdate],
852-
);
819+
const settingsModalValue: InspectorServerSettings =
820+
settingsDraft ?? EMPTY_SETTINGS;
853821

854822
const onSettingsModalClose = useCallback(() => {
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-
}
823+
flushSettingsDraft();
866824
setSettingsModalTargetId(undefined);
867-
}, [settingsDraft, settingsModalTargetId, sendSettingsUpdate]);
868-
869-
// Cancel any pending debounce on unmount (route change / HMR). Without
870-
// this a stale setTimeout could still fire one final `updateServerSettings`
871-
// against an unmounted component — harmless today (just an extra PUT of
872-
// the final payload) but the cleanest pattern is to clear the timer.
873-
useEffect(() => {
874-
return () => {
875-
if (settingsTimerRef.current) {
876-
clearTimeout(settingsTimerRef.current);
877-
settingsTimerRef.current = undefined;
878-
}
879-
};
880-
}, []);
825+
}, [flushSettingsDraft]);
881826

882827
// The Resources screen needs `isSubscribed` to flip the Subscribe button
883828
// label to "Unsubscribe". Derive it from the live subscriptions list rather

0 commit comments

Comments
 (0)