@@ -30,6 +30,7 @@ import { StderrLogState } from "@inspector/core/mcp/state/stderrLogState.js";
3030import type { RedirectUrlProvider } from "@inspector/core/auth/index.js" ;
3131import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js" ;
3232import { useServers } from "@inspector/core/react/useServers.js" ;
33+ import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js" ;
3334import { useManagedTools } from "@inspector/core/react/useManagedTools.js" ;
3435import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js" ;
3536import { 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+
129143function 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