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: ''
|
error: ''
|
||||||
});
|
});
|
||||||
const storeRef = useRef<TLStore | null>(null);
|
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);
|
const [storeReady, setStoreReady] = useState(false);
|
||||||
|
|
||||||
// TLDraw user preferences
|
// TLDraw user preferences
|
||||||
@ -82,94 +81,94 @@ export default function SinglePlayerPage() {
|
|||||||
setUserPreferences: setTldrawPreferences
|
setUserPreferences: setTldrawPreferences
|
||||||
});
|
});
|
||||||
|
|
||||||
// Orchestrated context.node lifecycle — replaces 3 separate effects
|
|
||||||
const [canvasPhase, setCanvasPhase] = useState<
|
const [canvasPhase, setCanvasPhase] = useState<
|
||||||
'idle' | 'store-init' | 'snapshot-loading' | 'node-placing' | 'ready' | 'navigating' | 'error'
|
'idle' | 'store-init' | 'snapshot-loading' | 'ready' | 'error'
|
||||||
>('idle');
|
>('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(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
const run = async () => {
|
setCanvasPhase('store-init');
|
||||||
try {
|
const store = createTLStore({
|
||||||
setCanvasPhase('store-init');
|
schema: customSchema,
|
||||||
// Create a fresh store directly — bypassing singleton to avoid disposed-store reuse issues
|
shapeUtils: allShapeUtils,
|
||||||
const newStore = createTLStore({
|
bindingUtils: allBindingUtils,
|
||||||
schema: customSchema,
|
});
|
||||||
shapeUtils: allShapeUtils,
|
// Initialize default tldraw records (TLINSTANCE, page, cameras) before mount
|
||||||
bindingUtils: allBindingUtils,
|
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
|
||||||
});
|
|
||||||
|
|
||||||
const snapSvc = new NavigationSnapshotService(newStore, editorRef.current || undefined);
|
const snapSvc = new NavigationSnapshotService(store, undefined);
|
||||||
if (accessToken) snapSvc.setAccessToken(accessToken);
|
if (accessToken) snapSvc.setAccessToken(accessToken);
|
||||||
snapshotServiceRef.current = snapSvc;
|
snapshotServiceRef.current = snapSvc;
|
||||||
|
|
||||||
if (context.node) {
|
let debounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
setCanvasPhase('snapshot-loading');
|
const unsubscribe = store.listen(() => {
|
||||||
const path = context.node.node_storage_path;
|
if (!snapshotServiceRef.current?.getCurrentNodePath()) return;
|
||||||
if (path) {
|
if (debounce) clearTimeout(debounce);
|
||||||
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
debounce = setTimeout(async () => {
|
||||||
path,
|
await snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
|
||||||
accessToken || '',
|
}, 2000);
|
||||||
newStore,
|
});
|
||||||
setLoadingState,
|
|
||||||
undefined,
|
|
||||||
editorRef.current || undefined
|
|
||||||
);
|
|
||||||
snapSvc.setCurrentNodePath(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cancelled) {
|
storeRef.current = store;
|
||||||
newStore.dispose();
|
setStoreReady(true);
|
||||||
return;
|
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 () => {
|
return () => {
|
||||||
cancelled = true;
|
unsubscribe?.();
|
||||||
snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
|
snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
|
||||||
snapshotServiceRef.current?.clearCurrentNode();
|
snapshotServiceRef.current?.clearCurrentNode();
|
||||||
snapshotServiceRef.current = null;
|
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);
|
setStoreReady(false);
|
||||||
setCanvasPhase('idle');
|
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.
|
// Handle navigation changes — save previous snapshot, load next one.
|
||||||
// No automatic node shape placement: the canvas shows only persisted state.
|
// No automatic node shape placement: the canvas shows only persisted state.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user