diff --git a/src/pages/tldraw/singlePlayerPage.tsx b/src/pages/tldraw/singlePlayerPage.tsx index e9f22f5..09af2f1 100644 --- a/src/pages/tldraw/singlePlayerPage.tsx +++ b/src/pages/tldraw/singlePlayerPage.tsx @@ -65,7 +65,6 @@ export default function SinglePlayerPage() { error: '' }); const storeRef = useRef(null); - const prevStoreRef = useRef(null); // holds old store until tldraw has unmounted cleanly const [storeReady, setStoreReady] = useState(false); // TLDraw user preferences @@ -82,94 +81,94 @@ export default function SinglePlayerPage() { setUserPreferences: setTldrawPreferences }); - // Orchestrated context.node lifecycle — replaces 3 separate effects const [canvasPhase, setCanvasPhase] = useState< - 'idle' | 'store-init' | 'snapshot-loading' | 'node-placing' | 'ready' | 'navigating' | 'error' + 'idle' | 'store-init' | 'snapshot-loading' | 'ready' | 'error' >('idle'); - const phaseError = useRef(''); + // Effect 1: Create the store ONCE per user session. Never recreate on node changes. + // Disposing the store while Tldraw is subscribed causes currentPageId crashes; keeping + // the store alive for the component lifetime avoids all disposal race conditions. useEffect(() => { if (!user) return; - let cancelled = false; - const run = async () => { - try { - setCanvasPhase('store-init'); - // Create a fresh store directly — bypassing singleton to avoid disposed-store reuse issues - const newStore = createTLStore({ - schema: customSchema, - shapeUtils: allShapeUtils, - bindingUtils: allBindingUtils, - }); + setCanvasPhase('store-init'); + const store = createTLStore({ + schema: customSchema, + shapeUtils: allShapeUtils, + bindingUtils: allBindingUtils, + }); + // Initialize default tldraw records (TLINSTANCE, page, cameras) before mount + (store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable(); - const snapSvc = new NavigationSnapshotService(newStore, editorRef.current || undefined); - if (accessToken) snapSvc.setAccessToken(accessToken); - snapshotServiceRef.current = snapSvc; + const snapSvc = new NavigationSnapshotService(store, undefined); + if (accessToken) snapSvc.setAccessToken(accessToken); + snapshotServiceRef.current = snapSvc; - if (context.node) { - setCanvasPhase('snapshot-loading'); - const path = context.node.node_storage_path; - if (path) { - await NavigationSnapshotService.loadNodeSnapshotFromDatabase( - path, - accessToken || '', - newStore, - setLoadingState, - undefined, - editorRef.current || undefined - ); - snapSvc.setCurrentNodePath(path); - } - } + let debounce: ReturnType | null = null; + const unsubscribe = store.listen(() => { + if (!snapshotServiceRef.current?.getCurrentNodePath()) return; + if (debounce) clearTimeout(debounce); + debounce = setTimeout(async () => { + await snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {}); + }, 2000); + }); - if (cancelled) { - newStore.dispose(); - return; - } + storeRef.current = store; + setStoreReady(true); + setCanvasPhase('ready'); - // After snapshot loading (which may overwrite or clear store records), - // ensure TLINSTANCE + page + cameras exist so tldraw never crashes on empty/corrupt store - (newStore as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable(); - - let debounce: ReturnType | null = null; - newStore.listen(() => { - if (!snapshotServiceRef.current?.getCurrentNodePath()) return; - if (debounce) clearTimeout(debounce); - debounce = setTimeout(async () => { - await snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {}); - }, 2000); - }); - - // Dispose the previous store only AFTER we have a new one ready. - // Disposing while storeReady=true would crash tldraw's reactive signals. - prevStoreRef.current?.dispose(); - prevStoreRef.current = null; - storeRef.current = newStore; - setStoreReady(true); - setCanvasPhase('ready'); - } catch (e) { - phaseError.current = e instanceof Error ? e.message : 'Init failed'; - setCanvasPhase('error'); - } - }; - - run(); return () => { - cancelled = true; + unsubscribe?.(); snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {}); snapshotServiceRef.current?.clearCurrentNode(); snapshotServiceRef.current = null; - // Don't dispose the store here — Tldraw is still subscribed via storeReady=true. - // Move the current store to prevStoreRef; it will be disposed when the next store is ready. - // This prevents tldraw's reactive signals from reading a disposed store during React's async unmount. - if (storeRef.current) { - prevStoreRef.current = storeRef.current; - storeRef.current = null; - } setStoreReady(false); setCanvasPhase('idle'); + store.dispose(); + storeRef.current = null; }; - }, [user?.id, context.node?.id]); + }, [user?.id]); // Only recreate store if user changes, NOT on every node navigation + + // Effect 2: Load/reload snapshot when context.node changes. Store stays alive. + useEffect(() => { + if (!user || !storeRef.current) return; + + const store = storeRef.current; + let cancelled = false; + + const loadNodeSnapshot = async () => { + if (!context.node?.node_storage_path) { + // No node — clear to blank canvas using ensureStoreIsUsable to restore defaults + (store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable(); + snapshotServiceRef.current?.clearCurrentNode(); + setCanvasPhase('ready'); + return; + } + + setCanvasPhase('snapshot-loading'); + try { + await NavigationSnapshotService.loadNodeSnapshotFromDatabase( + context.node.node_storage_path, + accessToken || '', + store, + setLoadingState, + undefined, + editorRef.current || undefined + ); + if (!cancelled) { + // Repair store in case snapshot overwrote TLINSTANCE + (store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable(); + snapshotServiceRef.current?.setCurrentNodePath(context.node.node_storage_path); + setCanvasPhase('ready'); + } + } catch (e) { + if (!cancelled) setCanvasPhase('ready'); // show blank canvas on error + } + }; + + loadNodeSnapshot(); + return () => { cancelled = true; }; + }, [context.node?.id, storeReady]); // re-run when node changes OR after store becomes ready // Handle navigation changes — save previous snapshot, load next one. // No automatic node shape placement: the canvas shows only persisted state.