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
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:
parent
53adc74a1c
commit
5b6e461706
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user