@@ -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