@@ -54,8 +54,8 @@ export function SaveAsProjectModal({
5454 sourceProjectId : string ;
5555 defaultName ?: string ;
5656 defaultDescription ?: string ;
57- onSaveNew : ( name ?: string , description ?: string ) => void ;
58- onOverwrite : ( project : InterlinearProjectSummary ) => void ;
57+ onSaveNew : ( name ?: string , description ?: string ) => void | Promise < void > ;
58+ onOverwrite : ( project : InterlinearProjectSummary ) => void | Promise < void > ;
5959 onClose : ( ) => void ;
6060} > ) {
6161 const [ localizedStrings , stringsLoading ] = useLocalizedStrings ( SAVE_AS_MODAL_STRING_KEYS ) ;
@@ -65,6 +65,15 @@ export function SaveAsProjectModal({
6565 const [ projects , setProjects ] = useState < InterlinearProjectSummary [ ] > ( [ ] ) ;
6666 const [ isLoading , setIsLoading ] = useState ( true ) ;
6767
68+ /**
69+ * True while a save (new or overwrite) is in flight; disables the save controls to block
70+ * re-submits.
71+ */
72+ const [ isSubmitting , setIsSubmitting ] = useState ( false ) ;
73+ // Ref mirror of `isSubmitting` so a submit handler can short-circuit a second invocation
74+ // synchronously, before the re-render that disables the button lands (guards programmatic races).
75+ const isSubmittingRef = useRef ( false ) ;
76+
6877 /** The existing project pending an overwrite confirmation, or `undefined`. */
6978 const [ confirmOverwrite , setConfirmOverwrite ] = useState < InterlinearProjectSummary | undefined > (
7079 undefined ,
@@ -96,6 +105,9 @@ export function SaveAsProjectModal({
96105 throw new TypeError ( 'getProjectsForSource did not return an array' ) ;
97106 setProjects ( parsed . filter ( isInterlinearProjectSummary ) ) ;
98107 } catch ( e ) {
108+ // Ignore a failure from a load that a newer one has superseded (mirrors the success-path stale
109+ // guard above) so a stale rejection cannot fire a spurious error notification.
110+ if ( gen !== loadGenRef . current ) return ;
99111 logger . error ( 'Interlinearizer: failed to load projects for Save As' , e ) ;
100112 await papi . notifications
101113 . send ( { message : '%interlinearizer_error_load_projects_failed%' , severity : 'error' } )
@@ -109,11 +121,46 @@ export function SaveAsProjectModal({
109121 loadProjects ( ) ;
110122 } , [ loadProjects ] ) ;
111123
112- /** Saves the draft as a new project with the trimmed name/description (blank fields → undefined). */
113- const handleSaveNew = useCallback ( ( ) => {
114- onSaveNew ( name . trim ( ) || undefined , description . trim ( ) || undefined ) ;
124+ /**
125+ * Saves the draft as a new project with the trimmed name/description (blank fields → undefined),
126+ * blocking re-entry while the save is in flight so a double-click cannot create duplicate
127+ * projects.
128+ */
129+ const handleSaveNew = useCallback ( async ( ) => {
130+ /* v8 ignore next -- button is disabled while submitting; ref guards against programmatic races */
131+ if ( isSubmittingRef . current ) return ;
132+ isSubmittingRef . current = true ;
133+ setIsSubmitting ( true ) ;
134+ try {
135+ await onSaveNew ( name . trim ( ) || undefined , description . trim ( ) || undefined ) ;
136+ } finally {
137+ isSubmittingRef . current = false ;
138+ setIsSubmitting ( false ) ;
139+ }
115140 } , [ name , description , onSaveNew ] ) ;
116141
142+ /**
143+ * Overwrites the chosen existing project with the draft, blocking re-entry while the save is in
144+ * flight so a double-click cannot fire the overwrite (or another save) twice.
145+ *
146+ * @param project - The existing project to overwrite.
147+ */
148+ const handleConfirmOverwrite = useCallback (
149+ async ( project : InterlinearProjectSummary ) => {
150+ /* v8 ignore next -- button is disabled while submitting; ref guards against programmatic races */
151+ if ( isSubmittingRef . current ) return ;
152+ isSubmittingRef . current = true ;
153+ setIsSubmitting ( true ) ;
154+ try {
155+ await onOverwrite ( project ) ;
156+ } finally {
157+ isSubmittingRef . current = false ;
158+ setIsSubmitting ( false ) ;
159+ }
160+ } ,
161+ [ onOverwrite ] ,
162+ ) ;
163+
117164 /* v8 ignore next */ if ( stringsLoading ) return undefined ;
118165
119166 return (
@@ -153,7 +200,7 @@ export function SaveAsProjectModal({
153200 placeholder = { localizedStrings [ '%interlinearizer_modal_create_description_placeholder%' ] }
154201 />
155202 < div className = "tw:flex tw:justify-end tw:mb-4" >
156- < Button onClick = { handleSaveNew } data-testid = "save-as-new" >
203+ < Button onClick = { handleSaveNew } data-testid = "save-as-new" disabled = { isSubmitting } >
157204 { localizedStrings [ '%interlinearizer_modal_saveAs_save_new%' ] }
158205 </ Button >
159206 </ div >
@@ -199,7 +246,8 @@ export function SaveAsProjectModal({
199246 variant = "destructive"
200247 size = "sm"
201248 data-testid = "save-as-overwrite-confirm"
202- onClick = { ( ) => onOverwrite ( confirmOverwrite ) }
249+ onClick = { ( ) => handleConfirmOverwrite ( confirmOverwrite ) }
250+ disabled = { isSubmitting }
203251 >
204252 { localizedStrings [ '%interlinearizer_modal_saveAs_overwrite_confirm_ok%' ] }
205253 </ Button >
@@ -208,7 +256,7 @@ export function SaveAsProjectModal({
208256 ) }
209257
210258 < div className = "tw:flex tw:gap-2 tw:justify-end" >
211- < Button variant = "secondary" onClick = { onClose } disabled = { isLoading } >
259+ < Button variant = "secondary" onClick = { onClose } disabled = { isLoading || isSubmitting } >
212260 { localizedStrings [ '%interlinearizer_modal_saveAs_cancel%' ] }
213261 </ Button >
214262 </ div >
0 commit comments