From 96c1b3323c369a006e542d08754e6faa83faf36c Mon Sep 17 00:00:00 2001 From: David Montero Date: Fri, 31 Jul 2026 08:24:44 +0200 Subject: [PATCH] fix(autosave): start late-installed impl for already-mounted hooks The overlay's auto-save implementation arrives via a dynamic import that races the first React commit. A hook whose mount effect ran before the overlay chunk evaluated saw installedImpl === null and stayed idle for the whole life of the tab: no debounced saves, no beforeunload flush, with no visible symptom. Any tab that hard-loaded straight into the editor and never remounted it silently lost every edit made after the project was bound. installAutoSaveImpl now wakes hooks that mounted before it ran, so the implementation starts as soon as it exists. Swapping a live impl at runtime remains unsupported. --- frontend/src/hooks/useAutoSaveProject.ts | 26 ++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) 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;