@@ -177,6 +177,9 @@ export function EditorProvider({
177177 // in, post-redesign) so the no-doc fallback never flashes before loadFile runs.
178178 const [ loading , setLoading ] = useState ( initialFile != null ) ;
179179 const [ busyLabel , setBusyLabel ] = useState < string | null > ( null ) ;
180+ // Synchronous in-flight flag for runBusy's re-entry lock (busyLabel is async
181+ // state and updates a frame late, too slow to block a fast second invocation).
182+ const busyRef = useRef ( false ) ;
180183 const [ error , setError ] = useState < string | null > ( null ) ;
181184 // The dropped PDF turned out to be password-protected — pdf-lib/PDF.js can't
182185 // parse it, so we surface the PDF Password tool instead of a raw load error.
@@ -257,6 +260,17 @@ export function EditorProvider({
257260 fn : ( setLabel : ( label : string ) => void ) => void | Promise < void > ,
258261 ) : Promise < void > => {
259262 return new Promise < void > ( ( resolve , reject ) => {
263+ // Re-entry lock: every byte transform (Apply) and background task funnels
264+ // through here, all branching off docRef.current. A second op started
265+ // before the first resolves would race on the same base and the last
266+ // commit would silently drop the other's edit. The busy overlay blocks
267+ // most double-clicks, but not the 2-rAF window below nor tools that don't
268+ // read busyLabel — so guard synchronously. Re-entrant calls no-op.
269+ if ( busyRef . current ) {
270+ resolve ( ) ;
271+ return ;
272+ }
273+ busyRef . current = true ;
260274 setError ( null ) ; // clear any stale error from a prior operation
261275 setBusyLabel ( label ) ;
262276 requestAnimationFrame ( ( ) => {
@@ -276,6 +290,7 @@ export function EditorProvider({
276290 setError ( e instanceof Error ? e . message : "Something went wrong. Please try again." ) ;
277291 reject ( e ) ;
278292 } finally {
293+ busyRef . current = false ;
279294 setBusyLabel ( null ) ;
280295 }
281296 } ) ;
@@ -357,6 +372,10 @@ export function EditorProvider({
357372 /** Commit a new doc state to history and make it live. */
358373 const commitDoc = useCallback ( ( next : CanvasDoc , label : string ) => {
359374 setDoc ( next ) ;
375+ // A transform can shrink the page count (delete / extract / split) below the
376+ // focused index; clamp it so the stage doesn't land on a missing page and
377+ // render blank.
378+ setSelectedPageState ( ( p ) => Math . min ( p , Math . max ( 0 , next . pageCount - 1 ) ) ) ;
360379 historyRef . current . push ( {
361380 label,
362381 bytes : next . bytes ,
@@ -381,6 +400,7 @@ export function EditorProvider({
381400 const cur = docRef . current ;
382401 if ( ! entry || ! cur ) return ;
383402 setDoc ( { ...cur , bytes : entry . bytes , pages : entry . pages , objects : entry . objects } ) ;
403+ setSelectedPageState ( ( p ) => Math . min ( p , Math . max ( 0 , entry . pages . length - 1 ) ) ) ;
384404 setHistoryVersion ( ( v ) => v + 1 ) ;
385405 } , [ ] ) ;
386406
@@ -410,6 +430,7 @@ export function EditorProvider({
410430 const cur = docRef . current ;
411431 if ( ! base || ! cur ) return ;
412432 setDoc ( { ...cur , bytes : base . bytes , pages : base . pages , objects : base . objects } ) ;
433+ setSelectedPageState ( ( p ) => Math . min ( p , Math . max ( 0 , base . pages . length - 1 ) ) ) ;
413434 toolCheckpointRef . current = historyRef . current . index ( ) ;
414435 setHistoryVersion ( ( v ) => v + 1 ) ;
415436 } , [ ] ) ;
0 commit comments