feat: replace triple context.node useEffect with single state machine

This commit is contained in:
CC Worker 2026-05-31 20:51:22 +00:00
parent d3bd25d544
commit 2d15b7cc03

View File

@ -6,8 +6,7 @@ import {
useTldrawUser, useTldrawUser,
DEFAULT_SUPPORT_VIDEO_TYPES, DEFAULT_SUPPORT_VIDEO_TYPES,
DEFAULT_SUPPORTED_IMAGE_TYPES, DEFAULT_SUPPORTED_IMAGE_TYPES,
TLStore, TLStore
TLStoreWithStatus
} from '@tldraw/tldraw'; } from '@tldraw/tldraw';
import { useTLDraw } from '../../contexts/TLDrawContext'; import { useTLDraw } from '../../contexts/TLDrawContext';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
@ -80,146 +79,83 @@ export default function SinglePlayerPage() {
setUserPreferences: setTldrawPreferences setUserPreferences: setTldrawPreferences
}); });
// Initialize store — runs as soon as user is ready, no editor needed for store creation // Orchestrated context.node lifecycle — replaces 3 separate effects
const [canvasPhase, setCanvasPhase] = useState<
'idle' | 'store-init' | 'snapshot-loading' | 'node-placing' | 'ready' | 'navigating' | 'error'
>('idle');
const phaseError = useRef<string>('');
useEffect(() => { useEffect(() => {
if (!user) { if (!user) return;
logger.debug('single-player-page', '⏳ Waiting for user data'); let cancelled = false;
return;
}
logger.info('single-player-page', '🔄 Starting store initialization', { const run = async () => {
hasUser: !!user,
userType: user.user_type,
username: user.username
});
const initializeStoreAndSnapshot = async () => {
try { try {
setLoadingState({ status: 'loading', error: '' }); setCanvasPhase('store-init');
// 1. Create store
logger.debug('single-player-page', '🔄 Creating TLStore');
const newStore = localStoreService.getStore({ const newStore = localStoreService.getStore({
schema: customSchema, schema: customSchema,
shapeUtils: allShapeUtils, shapeUtils: allShapeUtils,
bindingUtils: allBindingUtils bindingUtils: allBindingUtils
}); });
logger.debug('single-player-page', '✅ TLStore created');
// 2. Initialize snapshot service const snapSvc = new NavigationSnapshotService(newStore, editorRef.current || undefined);
const snapshotService = new NavigationSnapshotService(newStore, editorRef.current || undefined); if (accessToken) snapSvc.setAccessToken(accessToken);
if (accessToken) snapshotService.setAccessToken(accessToken); snapshotServiceRef.current = snapSvc;
snapshotServiceRef.current = snapshotService;
logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService');
// 3. Load initial snapshot if we have a node if (context.node) {
const storagePath = context.node?.node_storage_path; setCanvasPhase('snapshot-loading');
if (storagePath) { const path = context.node.node_storage_path;
logger.debug('single-player-page', '📥 Loading snapshot from database', { if (path) {
node_storage_path: storagePath, await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
user_type: user.user_type, path,
}); accessToken || '',
await NavigationSnapshotService.loadNodeSnapshotFromDatabase( newStore,
storagePath, setLoadingState,
accessToken || '', undefined,
newStore, editorRef.current || undefined
setLoadingState, );
undefined, snapSvc.setCurrentNodePath(path);
editorRef.current || undefined }
);
snapshotService.setCurrentNodePath(storagePath);
logger.debug('single-player-page', '✅ Snapshot loaded from database');
} else {
logger.debug('single-player-page', '⚠️ No node in context, skipping snapshot load');
} }
// 4. Set up debounced auto-save if (cancelled) {
let autoSaveTimeout: ReturnType<typeof setTimeout> | null = null; newStore.dispose();
let isAutoSaving = false; return;
}
let debounce: ReturnType<typeof setTimeout> | null = null;
newStore.listen(() => { newStore.listen(() => {
if (!snapshotServiceRef.current?.getCurrentNodePath()) return; if (!snapshotServiceRef.current?.getCurrentNodePath()) return;
if (isAutoSaving) return; if (debounce) clearTimeout(debounce);
if (autoSaveTimeout) clearTimeout(autoSaveTimeout); debounce = setTimeout(async () => {
autoSaveTimeout = setTimeout(async () => { await snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
if (isAutoSaving) return;
isAutoSaving = true;
try {
logger.debug('single-player-page', '💾 Auto-saving changes (debounced)');
await snapshotServiceRef.current?.forceSaveCurrentNode();
} catch (error) {
logger.error('single-player-page', '❌ Auto-save failed', error);
} finally {
isAutoSaving = false;
}
}, 2000); }, 2000);
}); });
// 5. Update store state
storeRef.current = newStore; storeRef.current = newStore;
setStoreReady(true); setStoreReady(true);
setLoadingState({ status: 'ready', error: '' }); setCanvasPhase('ready');
logger.info('single-player-page', '✅ Store initialization complete'); } catch (e) {
phaseError.current = e instanceof Error ? e.message : 'Init failed';
// 6. Handle cleanup setCanvasPhase('error');
return () => {
logger.debug('single-player-page', '🧹 Starting cleanup');
if (snapshotServiceRef.current) {
snapshotServiceRef.current.forceSaveCurrentNode().catch(error => {
logger.error('single-player-page', '❌ Final save failed', error);
});
snapshotServiceRef.current.clearCurrentNode();
snapshotServiceRef.current = null;
}
storeRef.current?.dispose();
storeRef.current = null;
setStoreReady(false);
logger.debug('single-player-page', '🧹 Cleanup complete');
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to initialize store';
logger.error('single-player-page', '❌ Store initialization failed', error);
setLoadingState({ status: 'error', error: errorMessage });
return undefined;
} }
}; };
initializeStoreAndSnapshot(); run();
}, [user, context.node]); return () => {
cancelled = true;
snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
snapshotServiceRef.current?.clearCurrentNode();
snapshotServiceRef.current = null;
storeRef.current?.dispose();
storeRef.current = null;
setStoreReady(false);
setCanvasPhase('idle');
};
}, [user?.id, context.node?.id]);
// 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.
useEffect(() => {
const handleNodeChange = async () => {
if (!context.node?.id || !snapshotServiceRef.current || !storeRef.current) return;
const snapshotService = snapshotServiceRef.current;
const currentNode = context.node;
try {
setLoadingState({ status: 'loading', error: '' });
logger.debug('single-player-page', '🔄 Handling navigation to node', {
nodeId: currentNode.id,
node_storage_path: currentNode.node_storage_path,
});
const previousNode = context.history.currentIndex > 0
? context.history.nodes[context.history.currentIndex - 1]
: null;
await snapshotService.handleNavigationStart(previousNode, currentNode);
setLoadingState({ status: 'ready', error: '' });
} catch (error) {
logger.error('single-player-page', '❌ Failed to load node snapshot', error);
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to load node data'
});
}
};
handleNodeChange();
}, [context.node, context.history, storeReady]);
// Inject auth and trigger initial context when token is ready // Inject auth and trigger initial context when token is ready
useEffect(() => { useEffect(() => {