diff --git a/apps/desktop/e2e/contexture-crud.spec.ts b/apps/desktop/e2e/contexture-crud.spec.ts index fafdab7..775283e 100644 --- a/apps/desktop/e2e/contexture-crud.spec.ts +++ b/apps/desktop/e2e/contexture-crud.spec.ts @@ -23,6 +23,22 @@ test.describe('Contexture CRUD', () => { electronApp = await launchElectron(); page = await electronApp.firstWindow(); await page.waitForLoadState('domcontentloaded'); + // `useSessionPersistence` restores any unsaved schema left in + // `window.localStorage` by a prior spec, which would hide the + // empty state and the "Load allotment sample" button. Reset the + // in-memory schema to empty AND clear the session key so the + // persistence loop doesn't immediately re-write it. + await page.evaluate(() => { + const win = window as unknown as { + __contextureUndoStore: { + getState: () => { apply: (op: unknown) => unknown }; + }; + }; + win.__contextureUndoStore + .getState() + .apply({ kind: 'replace_schema', schema: { version: '1', types: [] } }); + window.localStorage.removeItem('contexture:session:v1'); + }); }); test.afterAll(async () => { diff --git a/apps/desktop/e2e/import-export.spec.ts b/apps/desktop/e2e/import-export.spec.ts index d1ebfab..f4b7e94 100644 --- a/apps/desktop/e2e/import-export.spec.ts +++ b/apps/desktop/e2e/import-export.spec.ts @@ -20,6 +20,24 @@ test.describe('Import / export round-trip', () => { electronApp = await launchElectron(); page = await electronApp.firstWindow(); await page.waitForLoadState('domcontentloaded'); + // Prior specs in the same run leave an unsaved schema in + // `window.localStorage` which `useSessionPersistence` restores on + // mount — that hides the empty state and the "Load allotment + // sample" button. Reset the in-memory schema to empty AND clear + // the session key so the persistence loop doesn't immediately + // re-write it (storage is cleared synchronously when the schema + // becomes empty, but we belt-and-brace it). + await page.evaluate(() => { + const win = window as unknown as { + __contextureUndoStore: { + getState: () => { apply: (op: unknown) => unknown }; + }; + }; + win.__contextureUndoStore + .getState() + .apply({ kind: 'replace_schema', schema: { version: '1', types: [] } }); + window.localStorage.removeItem('contexture:session:v1'); + }); }); test.afterAll(async () => { diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 630e0d4..f4ef801 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -54,8 +54,10 @@ import { useDrift } from './hooks/useDrift'; import { useFileMenu } from './hooks/useFileMenu'; import { useNewProject } from './hooks/useNewProject'; import { useProjectAutoSave } from './hooks/useProjectAutoSave'; +import { useSessionPersistence } from './hooks/useSessionPersistence'; import { emit as emitJsonSchema } from './model/emit-json-schema'; import { emit as emitZod } from './model/emit-zod'; +import type { Layout } from './model/layout'; import allotment from './samples/allotment.contexture.json' with { type: 'json' }; import { STDLIB_REGISTRY } from './services/stdlib-registry'; import { validate } from './services/validation'; @@ -193,6 +195,14 @@ export default function App(): React.JSX.Element { getChat: () => ({ version: '1', messages: chatMessagesRef.current }), }); + const sessionLayout = useMemo(() => ({ version: '1', positions }), [positions]); + useSessionPersistence({ + layout: sessionLayout, + onRestoreSession: (layout) => { + setPositions(layout.positions); + }, + }); + // Pull the recent-files list when the empty state might need it and // again whenever the file-path changes (a successful open/save // bumps the list). diff --git a/apps/desktop/src/renderer/src/hooks/useSessionPersistence.ts b/apps/desktop/src/renderer/src/hooks/useSessionPersistence.ts new file mode 100644 index 0000000..96b5404 --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/useSessionPersistence.ts @@ -0,0 +1,160 @@ +import { useEffect, useRef } from 'react'; +import type { Schema } from '../model/ir'; +import type { Layout } from '../model/layout'; +import { useDocumentStore } from '../store/document'; +import { useUndoStore } from '../store/undo'; + +export const SESSION_KEY = 'contexture:session:v1'; + +interface StoredSession { + schema: Schema; + layout: Layout; +} + +/** Minimal storage interface — satisfied by `window.localStorage`. */ +export interface SessionStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +export interface UseSessionPersistenceOptions { + /** Current canvas layout — read on every debounced save and on the + * pre-unload flush. Identity-changes also schedule a save so a pure + * drag (no schema mutation) still persists. */ + layout: Layout; + /** Called when an unsaved session is restored. */ + onRestoreSession: (layout: Layout) => void; + /** Storage backend. Defaults to `window.localStorage`. */ + storage?: SessionStorage; +} + +export function useSessionPersistence({ + layout, + onRestoreSession, + storage = typeof window !== 'undefined' ? window.localStorage : undefined, +}: UseSessionPersistenceOptions): void { + const onRestoreRef = useRef(onRestoreSession); + onRestoreRef.current = onRestoreSession; + const layoutRef = useRef(layout); + layoutRef.current = layout; + const storageRef = useRef(storage); + storageRef.current = storage; + + // On mount: restore from storage if the schema is empty and no file is open. + useEffect(() => { + const store = storageRef.current; + if (!store) return; + const schema = useUndoStore.getState().schema; + if (schema.types.length > 0) return; + const { filePath } = useDocumentStore.getState(); + if (filePath !== null) return; + + try { + const raw = store.getItem(SESSION_KEY); + if (!raw) return; + const session = JSON.parse(raw) as StoredSession; + if (!session.schema || session.schema.types.length === 0) return; + useUndoStore.getState().apply({ kind: 'replace_schema', schema: session.schema }); + onRestoreRef.current(session.layout ?? { version: '1', positions: {} }); + } catch { + store.removeItem(SESSION_KEY); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Persistence loop: schema and layout changes both trigger a debounced + // write. A `pagehide` listener flushes synchronously so a dev-server + // restart inside the debounce window doesn't drop the last edit. + useEffect(() => { + let timer: ReturnType | null = null; + let lastSchema = useUndoStore.getState().schema; + + const flush = (): void => { + timer = null; + const store = storageRef.current; + if (!store) return; + const { filePath } = useDocumentStore.getState(); + if (filePath !== null) return; + const schema = useUndoStore.getState().schema; + if (schema.types.length === 0) { + store.removeItem(SESSION_KEY); + return; + } + const session: StoredSession = { schema, layout: layoutRef.current }; + try { + store.setItem(SESSION_KEY, JSON.stringify(session)); + } catch { + // Storage full or unavailable (e.g. private browsing quota) — skip silently. + } + }; + + const schedule = (): void => { + if (timer !== null) clearTimeout(timer); + timer = setTimeout(flush, 300); + }; + + const unsubSchema = useUndoStore.subscribe((s) => { + if (s.schema === lastSchema) return; + lastSchema = s.schema; + schedule(); + }); + + // Clear immediately when a file path is set (file open / save-as). + let lastFilePath = useDocumentStore.getState().filePath; + const unsubDoc = useDocumentStore.subscribe((s) => { + if (s.filePath === lastFilePath) return; + lastFilePath = s.filePath; + if (s.filePath !== null) { + storageRef.current?.removeItem(SESSION_KEY); + } + }); + + // Flush synchronously before the renderer goes away. `pagehide` is + // the reliable Electron equivalent of `beforeunload`; both are + // wired so a normal close and a dev-server reload both land. + const onPageHide = (): void => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + flush(); + }; + if (typeof window !== 'undefined') { + window.addEventListener('pagehide', onPageHide); + window.addEventListener('beforeunload', onPageHide); + } + + return () => { + unsubSchema(); + unsubDoc(); + if (timer !== null) clearTimeout(timer); + if (typeof window !== 'undefined') { + window.removeEventListener('pagehide', onPageHide); + window.removeEventListener('beforeunload', onPageHide); + } + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Layout-only changes (e.g. a node drag with no schema mutation) also + // need to land in storage. Schedule a debounced flush whenever the + // caller-supplied layout reference changes. + // biome-ignore lint/correctness/useExhaustiveDependencies: storageRef is stable + useEffect(() => { + const store = storageRef.current; + if (!store) return; + const { filePath } = useDocumentStore.getState(); + if (filePath !== null) return; + const schema = useUndoStore.getState().schema; + if (schema.types.length === 0) return; + + const handle = setTimeout(() => { + const session: StoredSession = { schema: useUndoStore.getState().schema, layout }; + try { + store.setItem(SESSION_KEY, JSON.stringify(session)); + } catch { + // Quota / private-mode — silent. + } + }, 300); + return () => clearTimeout(handle); + }, [layout]); +} diff --git a/apps/desktop/tests/hooks/useSessionPersistence.test.tsx b/apps/desktop/tests/hooks/useSessionPersistence.test.tsx new file mode 100644 index 0000000..677ada7 --- /dev/null +++ b/apps/desktop/tests/hooks/useSessionPersistence.test.tsx @@ -0,0 +1,352 @@ +import { + SESSION_KEY, + type SessionStorage, + useSessionPersistence, +} from '@renderer/hooks/useSessionPersistence'; +import { useDocumentStore } from '@renderer/store/document'; +import { useUndoStore } from '@renderer/store/undo'; +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +function makeStorage(): SessionStorage & { data: Map } { + const data = new Map(); + return { + data, + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => { + data.set(key, value); + }, + removeItem: (key) => { + data.delete(key); + }, + }; +} + +beforeEach(() => { + vi.useFakeTimers(); + // Reset stores to clean state. + useUndoStore.getState().apply({ kind: 'replace_schema', schema: { version: '1', types: [] } }); + const d = useDocumentStore.getState(); + d.setFilePath(null); + d.setMode('scratch'); + d.markClean(); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe('useSessionPersistence', () => { + it('restores unsaved schema from storage when schema is empty on mount', () => { + const storage = makeStorage(); + storage.setItem( + SESSION_KEY, + JSON.stringify({ + schema: { version: '1', types: [{ kind: 'object', name: 'Plot', fields: [] }] }, + layout: { version: '1', positions: { Plot: { x: 10, y: 20 } } }, + }), + ); + + const onRestore = vi.fn(); + renderHook(() => + useSessionPersistence({ + layout: { version: '1', positions: {} }, + onRestoreSession: onRestore, + storage, + }), + ); + + expect(useUndoStore.getState().schema.types).toHaveLength(1); + expect(useUndoStore.getState().schema.types[0].name).toBe('Plot'); + expect(onRestore).toHaveBeenCalledWith({ + version: '1', + positions: { Plot: { x: 10, y: 20 } }, + }); + }); + + it('does not restore when schema already has types (another load was faster)', () => { + useUndoStore.getState().apply({ + kind: 'add_type', + type: { kind: 'object', name: 'Existing', fields: [] }, + }); + + const storage = makeStorage(); + storage.setItem( + SESSION_KEY, + JSON.stringify({ + schema: { version: '1', types: [{ kind: 'object', name: 'SessionType', fields: [] }] }, + layout: { version: '1', positions: {} }, + }), + ); + + const onRestore = vi.fn(); + renderHook(() => + useSessionPersistence({ + layout: { version: '1', positions: {} }, + onRestoreSession: onRestore, + storage, + }), + ); + + expect(useUndoStore.getState().schema.types[0].name).toBe('Existing'); + expect(onRestore).not.toHaveBeenCalled(); + }); + + it('does not restore when filePath is already set on mount', () => { + useDocumentStore.getState().setFilePath('/tmp/already-open.contexture.json'); + + const storage = makeStorage(); + storage.setItem( + SESSION_KEY, + JSON.stringify({ + schema: { version: '1', types: [{ kind: 'object', name: 'Plot', fields: [] }] }, + layout: { version: '1', positions: {} }, + }), + ); + + const onRestore = vi.fn(); + renderHook(() => + useSessionPersistence({ + layout: { version: '1', positions: {} }, + onRestoreSession: onRestore, + storage, + }), + ); + + expect(useUndoStore.getState().schema.types).toHaveLength(0); + expect(onRestore).not.toHaveBeenCalled(); + }); + + it('saves schema to storage when schema changes and filePath is null', async () => { + const storage = makeStorage(); + + renderHook(() => + useSessionPersistence({ + layout: { version: '1', positions: { Plot: { x: 1, y: 2 } } }, + onRestoreSession: vi.fn(), + storage, + }), + ); + + act(() => { + useUndoStore.getState().apply({ + kind: 'add_type', + type: { kind: 'object', name: 'Plot', fields: [] }, + }); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + const raw = storage.getItem(SESSION_KEY); + expect(raw).not.toBeNull(); + const session = JSON.parse(raw ?? ''); + expect(session.schema.types[0].name).toBe('Plot'); + expect(session.layout.positions.Plot).toEqual({ x: 1, y: 2 }); + }); + + it('does not save to storage when filePath is set (file handles persistence)', async () => { + useDocumentStore.getState().setFilePath('/tmp/saved.contexture.json'); + + const storage = makeStorage(); + + renderHook(() => + useSessionPersistence({ + layout: { version: '1', positions: {} }, + onRestoreSession: vi.fn(), + storage, + }), + ); + + act(() => { + useUndoStore.getState().apply({ + kind: 'add_type', + type: { kind: 'object', name: 'Plot', fields: [] }, + }); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + expect(storage.getItem(SESSION_KEY)).toBeNull(); + }); + + it('clears storage when the schema becomes empty', async () => { + const storage = makeStorage(); + storage.setItem( + SESSION_KEY, + JSON.stringify({ + schema: { version: '1', types: [{ kind: 'object', name: 'Plot', fields: [] }] }, + layout: { version: '1', positions: {} }, + }), + ); + + useUndoStore.getState().apply({ + kind: 'add_type', + type: { kind: 'object', name: 'Plot', fields: [] }, + }); + + renderHook(() => + useSessionPersistence({ + layout: { version: '1', positions: {} }, + onRestoreSession: vi.fn(), + storage, + }), + ); + + act(() => { + useUndoStore + .getState() + .apply({ kind: 'replace_schema', schema: { version: '1', types: [] } }); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + expect(storage.getItem(SESSION_KEY)).toBeNull(); + }); + + it('clears storage when filePath becomes non-null (file opened or saved)', () => { + const storage = makeStorage(); + storage.setItem( + SESSION_KEY, + JSON.stringify({ + schema: { version: '1', types: [{ kind: 'object', name: 'Plot', fields: [] }] }, + layout: { version: '1', positions: {} }, + }), + ); + + renderHook(() => + useSessionPersistence({ + layout: { version: '1', positions: {} }, + onRestoreSession: vi.fn(), + storage, + }), + ); + + act(() => { + useDocumentStore.getState().setFilePath('/tmp/newly-saved.contexture.json'); + }); + + expect(storage.getItem(SESSION_KEY)).toBeNull(); + }); + + it('does not crash when storage write throws (e.g. private browsing quota)', async () => { + const storage = makeStorage(); + storage.setItem = vi.fn().mockImplementation(() => { + throw new DOMException('QuotaExceededError'); + }); + + const { unmount } = renderHook(() => + useSessionPersistence({ + layout: { version: '1', positions: {} }, + onRestoreSession: vi.fn(), + storage, + }), + ); + + act(() => { + useUndoStore.getState().apply({ + kind: 'add_type', + type: { kind: 'object', name: 'Plot', fields: [] }, + }); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + // If we reach here without throwing, the test passes. + // Explicitly unmount before afterEach to guarantee effect cleanup order. + unmount(); + }); + + it('flushes synchronously on pagehide so a pending debounce never drops', () => { + const storage = makeStorage(); + + renderHook(() => + useSessionPersistence({ + layout: { version: '1', positions: {} }, + onRestoreSession: vi.fn(), + storage, + }), + ); + + act(() => { + useUndoStore.getState().apply({ + kind: 'add_type', + type: { kind: 'object', name: 'Plot', fields: [] }, + }); + }); + + // Do NOT advance timers — simulate the renderer being killed + // mid-debounce by dispatching `pagehide` before the 300ms fires. + expect(storage.getItem(SESSION_KEY)).toBeNull(); + act(() => { + window.dispatchEvent(new Event('pagehide')); + }); + + const raw = storage.getItem(SESSION_KEY); + expect(raw).not.toBeNull(); + const session = JSON.parse(raw ?? ''); + expect(session.schema.types[0].name).toBe('Plot'); + }); + + it('persists layout-only changes (drag with no schema mutation)', async () => { + const storage = makeStorage(); + // Seed a non-empty schema so the layout-only effect has something + // to persist. + useUndoStore.getState().apply({ + kind: 'add_type', + type: { kind: 'object', name: 'Plot', fields: [] }, + }); + + const { rerender } = renderHook( + (layout: { version: '1'; positions: Record }) => + useSessionPersistence({ + layout, + onRestoreSession: vi.fn(), + storage, + }), + { + initialProps: { version: '1', positions: { Plot: { x: 0, y: 0 } } }, + }, + ); + + // Simulate a drag — new layout reference, same schema. + rerender({ version: '1', positions: { Plot: { x: 100, y: 200 } } }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + const raw = storage.getItem(SESSION_KEY); + expect(raw).not.toBeNull(); + const session = JSON.parse(raw ?? ''); + expect(session.layout.positions.Plot).toEqual({ x: 100, y: 200 }); + }); + + it('handles corrupt storage data without crashing', () => { + const storage = makeStorage(); + storage.setItem(SESSION_KEY, 'not valid json {{{{'); + + const onRestore = vi.fn(); + expect(() => + renderHook(() => + useSessionPersistence({ + layout: { version: '1', positions: {} }, + onRestoreSession: onRestore, + storage, + }), + ), + ).not.toThrow(); + + expect(onRestore).not.toHaveBeenCalled(); + expect(storage.getItem(SESSION_KEY)).toBeNull(); + }); +});