Files
app/src/services/tldraw/snapshotService.ts
T
CC Worker 9f58917c39
app-ci-deploy / test-build-deploy (push) Has been cancelled
fix(canvas): sanitize snapshot session.currentPageId before loadSnapshot — stale snapshots with missing pages caused currentPageId crash
- Pre-load: if session.currentPageId not in snapshot pages, reset to first available page
- Post-load: if store instance.currentPageId still invalid, clear store for fresh start
- On loadSnapshot error: clear store instead of silently failing with corrupt state
- Root cause: teacher1 had a saved snapshot with currentPageId pointing to a deleted page
2026-06-01 06:02:19 +00:00

293 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// External imports
import { TLStore, getSnapshot, Editor, loadSnapshot, TLINSTANCE_ID } from '@tldraw/tldraw';
import logger from '../../debugConfig';
import { SharedStoreService } from './sharedStoreService';
export interface LoadingState {
status: 'loading' | 'ready' | 'error';
error: string;
}
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string;
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY as string;
const BUCKET = 'cc.users';
async function storageGet(path: string, accessToken: string): Promise<unknown | null> {
const url = `${SUPABASE_URL}/storage/v1/object/authenticated/${BUCKET}/${path}`;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
apikey: SUPABASE_ANON_KEY,
},
});
if (res.status === 404 || res.status === 400) return null;
if (!res.ok) throw new Error(`Storage GET ${res.status}: ${await res.text()}`);
return res.json();
}
async function storagePut(path: string, accessToken: string, data: unknown): Promise<void> {
const url = `${SUPABASE_URL}/storage/v1/object/${BUCKET}/${path}`;
const headers = {
Authorization: `Bearer ${accessToken}`,
apikey: SUPABASE_ANON_KEY,
'Content-Type': 'application/json',
};
const body = JSON.stringify(data);
// PUT replaces an existing object; POST creates a new one.
// Avoids x-upsert custom header which self-hosted Supabase CORS may block.
let res = await fetch(url, { method: 'PUT', headers, body });
if (!res.ok && (res.status === 404 || res.status === 400)) {
res = await fetch(url, { method: 'POST', headers, body });
}
if (!res.ok) throw new Error(`Storage ${res.status}: ${await res.text()}`);
}
export class NavigationSnapshotService {
private store: TLStore;
private editor: Editor | null = null;
private currentNodePath: string | null = null;
private _accessToken: string | null = null;
private isAutoSaveEnabled = true;
private isSaving = false;
private isLoading = false;
private pendingOperation: { save?: string; load?: string } | null = null;
private debounceTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(store: TLStore, editor?: Editor) {
this.store = store;
this.editor = editor || null;
logger.debug('snapshot-service', '🔄 Initialized NavigationSnapshotService', {
storeId: store.id,
hasEditor: !!editor,
});
}
setEditor(editor: Editor): void {
this.editor = editor;
}
setAccessToken(token: string): void {
this._accessToken = token;
}
static async loadNodeSnapshotFromDatabase(
nodePath: string,
accessToken: string,
store: TLStore,
setLoadingState: (state: LoadingState) => void,
sharedStore?: SharedStoreService,
editor?: Editor
): Promise<void> {
try {
setLoadingState({ status: 'loading', error: '' });
logger.info('snapshot-service', '📂 Loading snapshot from Storage', { path: nodePath });
const snapshot = await storageGet(nodePath, accessToken);
if (!snapshot) {
logger.debug('snapshot-service', '️ No snapshot found at path — clearing canvas', { nodePath });
// Clear all shapes so the canvas is blank for this new node
if (editor) {
const shapeIds = [...editor.getCurrentPageShapeIds()];
if (shapeIds.length > 0) {
editor.deleteShapes(shapeIds);
}
}
setLoadingState({ status: 'ready', error: '' });
return;
}
const snap = snapshot as { document?: unknown; session?: unknown; schemaVersion?: unknown };
if (!snap.document || !snap.session) {
logger.warn('snapshot-service', '⚠️ Invalid snapshot format at path', { nodePath });
setLoadingState({ status: 'ready', error: '' });
return;
}
if (sharedStore) {
await sharedStore.loadSnapshot(snapshot, setLoadingState);
return;
}
// Sanitize session.currentPageId against pages actually in the document
const docStore = (snap.document as { store?: Record<string, { typeName: string; id: string }> })?.store ?? {};
const pageIds = Object.values(docStore).filter((r) => (r as { typeName: string }).typeName === 'page').map((r) => r.id);
const session = snap.session as { currentPageId?: string } | undefined;
if (session?.currentPageId && pageIds.length > 0 && !pageIds.includes(session.currentPageId)) {
logger.warn('snapshot-service', '⚠️ session.currentPageId not in snapshot pages — resetting to first page', {
currentPageId: session.currentPageId, availablePages: pageIds
});
session.currentPageId = pageIds[0];
}
const snapshotCopy = {
schemaVersion: snap.schemaVersion || (snap.document as { schema?: { schemaVersion?: unknown } })?.schema?.schemaVersion,
document: snap.document,
session: snap.session,
};
const targetStore = editor ? editor.store : store;
try {
loadSnapshot(targetStore, snapshotCopy as any);
// Post-load validation: ensure instance.currentPageId is still valid
const instance = targetStore.get(TLINSTANCE_ID);
if (instance) {
const pages = targetStore.allRecords().filter((r) => (r as { typeName: string }).typeName === 'page');
const pageValid = pages.some((p) => p.id === instance.currentPageId);
if (!pageValid) {
logger.warn('snapshot-service', '⚠️ Post-load: currentPageId invalid — clearing corrupt store', { currentPageId: instance.currentPageId });
targetStore.clear();
}
}
logger.debug('snapshot-service', '✅ Snapshot loaded successfully');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const isSchemaMigration = /migration|schema|Incompatible/i.test(msg);
if (isSchemaMigration) {
logger.debug('snapshot-service', '️ Schema migration warning (non-critical)', { error: msg });
} else {
logger.warn('snapshot-service', '⚠️ Unexpected loadSnapshot error — clearing store to allow fresh start', { error: msg });
targetStore.clear();
}
}
setLoadingState({ status: 'ready', error: '' });
} catch (error) {
logger.error('snapshot-service', '❌ Failed to load snapshot', {
error: error instanceof Error ? error.message : 'Unknown error',
});
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to load snapshot',
});
}
}
static async saveNodeSnapshotToDatabase(
nodePath: string,
accessToken: string,
store: TLStore
): Promise<void> {
try {
logger.info('snapshot-service', '💾 Saving snapshot to Storage', { path: nodePath });
const snapshot = getSnapshot(store);
await storagePut(nodePath, accessToken, snapshot);
logger.debug('snapshot-service', '✅ Snapshot saved successfully');
} catch (error) {
logger.error('snapshot-service', '❌ Failed to save snapshot', {
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
private async saveCurrentSnapshot(nodePath: string): Promise<void> {
if (!this.currentNodePath || this.currentNodePath !== nodePath) return;
if (!this._accessToken) {
logger.debug('snapshot-service', '⚠️ No access token — snapshot save skipped');
return;
}
try {
this.isSaving = true;
await NavigationSnapshotService.saveNodeSnapshotToDatabase(nodePath, this._accessToken, this.store);
logger.debug('snapshot-service', '✅ Saved navigation snapshot', { nodePath });
} catch (error) {
logger.error('snapshot-service', '❌ Failed to save navigation snapshot', {
error: error instanceof Error ? error.message : 'Unknown error',
nodePath,
});
throw error;
} finally {
this.isSaving = false;
}
}
private async loadSnapshotForNode(node: { node_storage_path: string }): Promise<void> {
if (!this._accessToken) {
logger.debug('snapshot-service', '⚠️ No access token — snapshot load skipped');
return;
}
try {
this.isLoading = true;
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
node.node_storage_path,
this._accessToken,
this.store,
(state: LoadingState) => {
if (state.status === 'ready') {
this.currentNodePath = node.node_storage_path;
}
},
undefined,
this.editor || undefined
);
} finally {
this.isLoading = false;
}
}
async handleNavigationStart(fromNode: { node_storage_path: string } | null, toNode: { node_storage_path: string } | null): Promise<void> {
if (!toNode) return;
if (this.debounceTimeout) clearTimeout(this.debounceTimeout);
return new Promise((resolve) => {
this.debounceTimeout = setTimeout(async () => {
try {
await this.executeNavigation(fromNode, toNode);
resolve();
} catch (error) {
logger.error('snapshot-service', '❌ Navigation failed', error);
throw error;
}
}, 100);
});
}
private async executeNavigation(fromNode: { node_storage_path: string } | null, toNode: { node_storage_path: string }): Promise<void> {
if (this.isSaving || this.isLoading) {
this.pendingOperation = {
save: fromNode?.node_storage_path,
load: toNode.node_storage_path,
};
return;
}
this.currentNodePath = null;
if (toNode.node_storage_path) {
await this.loadSnapshotForNode(toNode);
}
if (this.pendingOperation) {
const op = this.pendingOperation;
this.pendingOperation = null;
await this.handleNavigationStart(
op.save ? { node_storage_path: op.save } : null,
op.load ? { node_storage_path: op.load } : null
);
}
}
setAutoSave(enabled: boolean): void {
this.isAutoSaveEnabled = enabled;
}
setCurrentNodePath(path: string): void {
this.currentNodePath = path;
}
getCurrentNodePath(): string | null {
return this.currentNodePath;
}
async forceSaveCurrentNode(): Promise<void> {
if (this.currentNodePath) {
await this.saveCurrentSnapshot(this.currentNodePath);
}
}
clearCurrentNode(): void {
this.currentNodePath = null;
this.store.clear();
}
}