fix(canvas): separate store lifecycle from snapshot lifecycle — one store per user session prevents disposed-store crash
Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled

Root cause: disposing store while React's async state update (storeReady=false) hadn't
unmounted Tldraw yet caused tldraw's reactive signals to read currentPageId on a disposed store.

Fix: Effect 1 creates store once per user.id (never recreated on node navigation).
     Effect 2 loads/reloads snapshots when context.node changes, store stays alive.
     Store is only disposed when user changes or component unmounts — after setStoreReady(false)
     has had time to unmount Tldraw cleanly via React's synchronous cleanup.
This commit is contained in:
CC Worker 2026-06-01 06:25:15 +00:00
parent 53adc74a1c
commit 5b6e461706

View File

@ -65,7 +65,6 @@ export default function SinglePlayerPage() {
error: ''
});
const storeRef = useRef<TLStore | null>(null);
const prevStoreRef = useRef<TLStore | null>(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<string>('');
// 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<typeof setTimeout> | 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<typeof setTimeout> | 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.