diff --git a/frontend/src/hooks/useAutoSaveProject.ts b/frontend/src/hooks/useAutoSaveProject.ts index 9692bf19..8f2b74a3 100644 --- a/frontend/src/hooks/useAutoSaveProject.ts +++ b/frontend/src/hooks/useAutoSaveProject.ts @@ -30,18 +30,36 @@ const IDLE: AutoSaveState = { status: 'idle', lastSavedAt: null, errorMessage: n let installedImpl: AutoSaveImpl | null = null; +/** Hooks that mounted before an impl was installed, waiting to start it. */ +const installWaiters = new Set<() => void>(); + export function installAutoSaveImpl(impl: AutoSaveImpl | null): void { installedImpl = impl; + // Overlays load through a dynamic import that races the first React + // commit: a hook whose mount effect ran before the overlay chunk + // evaluated used to see `installedImpl === null` and stay idle for the + // whole life of the tab — no auto-save, no unload flush. Start those + // already-mounted hooks now that the impl exists. + if (impl) installWaiters.forEach((start) => start()); } export function useAutoSaveProject(): AutoSaveState { const [state, setState] = useState(IDLE); useEffect(() => { - if (!installedImpl) return; - return installedImpl(setState); - // Mount-only — impl is installed at module load, swapping at runtime is unsupported. - // eslint-disable-next-line react-hooks/exhaustive-deps + let cleanup: (() => void) | null = null; + const start = () => { + if (installedImpl && !cleanup) cleanup = installedImpl(setState); + }; + start(); + // Late-install support only — swapping a live impl at runtime is still + // unsupported (the first installed impl keeps running until unmount). + installWaiters.add(start); + return () => { + installWaiters.delete(start); + cleanup?.(); + cleanup = null; + }; }, []); return state;