From 25bc95bdb733735da3b0a815077a222faa73a0d4 Mon Sep 17 00:00:00 2001 From: Dave Hudson Date: Fri, 1 May 2026 12:03:07 +0000 Subject: [PATCH 1/5] IMPLEMENT: fix visualisation reloading to empty state screen (#233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: useUndoStore always initialises with an empty schema. When a dev-server HMR reload or a macOS window-close + dock-reopen creates a fresh renderer context, any unsaved in-memory schema is lost and App.tsx shows the EmptyState because schema.types.length === 0. Fix: add useSessionPersistence hook that debounce-saves unsaved schemas (filePath === null) to an injectable SessionStorage backend (defaults to window.localStorage) and restores on mount when the schema is still empty. Clears the session when a file path is set (file opened/saved) or the schema is replaced with an empty one (File > New). Key decisions: - Storage is injected so tests use a plain Map-backed mock; no globals needed - Only persists unsaved state — project auto-save and manual save already handle files that have a path - Schema + canvas positions are persisted; chat history is not (chat can be rebuilt; schema disappearing is the reported regression) Files changed: - apps/desktop/src/renderer/src/hooks/useSessionPersistence.ts (new) - apps/desktop/src/renderer/src/App.tsx (wire hook after useProjectAutoSave) - apps/desktop/tests/hooks/useSessionPersistence.test.tsx (8 tests, all green) Closes #233 --- apps/desktop/src/renderer/src/App.tsx | 8 + .../src/hooks/useSessionPersistence.ts | 114 ++++++++ .../hooks/useSessionPersistence.test.tsx | 262 ++++++++++++++++++ 3 files changed, 384 insertions(+) create mode 100644 apps/desktop/src/renderer/src/hooks/useSessionPersistence.ts create mode 100644 apps/desktop/tests/hooks/useSessionPersistence.test.tsx diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 630e0d4..ae8c999 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -54,6 +54,7 @@ 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 allotment from './samples/allotment.contexture.json' with { type: 'json' }; @@ -193,6 +194,13 @@ export default function App(): React.JSX.Element { getChat: () => ({ version: '1', messages: chatMessagesRef.current }), }); + useSessionPersistence({ + getLayout: () => ({ version: '1', positions: positionsRef.current }), + 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..49437a2 --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/useSessionPersistence.ts @@ -0,0 +1,114 @@ +/** + * `useSessionPersistence` — persists unsaved schema to localStorage so + * dev-server HMR reloads and macOS window-close + reopen don't wipe the + * in-memory schema, causing the empty state screen to appear. + * + * Only kicks in for unsaved files (filePath === null). Once a file has a + * path, the file itself (or project auto-save) handles persistence. + */ +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. */ + getLayout: () => Layout; + /** Called when an unsaved session is restored. */ + onRestoreSession: (layout: Layout) => void; + /** Storage backend. Defaults to `window.localStorage`. */ + storage?: SessionStorage; +} + +export function useSessionPersistence({ + getLayout, + onRestoreSession, + storage = typeof window !== 'undefined' ? window.localStorage : undefined, +}: UseSessionPersistenceOptions): void { + const onRestoreRef = useRef(onRestoreSession); + onRestoreRef.current = onRestoreSession; + const getLayoutRef = useRef(getLayout); + getLayoutRef.current = getLayout; + 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 + + // Watch schema changes: persist to storage (debounced) when unsaved. + 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: getLayoutRef.current() }; + store.setItem(SESSION_KEY, JSON.stringify(session)); + }; + + const unsubSchema = useUndoStore.subscribe((s) => { + if (s.schema === lastSchema) return; + lastSchema = s.schema; + if (timer !== null) clearTimeout(timer); + timer = setTimeout(flush, 300); + }); + + // 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); + } + }); + + return () => { + unsubSchema(); + unsubDoc(); + if (timer !== null) clearTimeout(timer); + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps +} diff --git a/apps/desktop/tests/hooks/useSessionPersistence.test.tsx b/apps/desktop/tests/hooks/useSessionPersistence.test.tsx new file mode 100644 index 0000000..5b6d2cc --- /dev/null +++ b/apps/desktop/tests/hooks/useSessionPersistence.test.tsx @@ -0,0 +1,262 @@ +/** + * `useSessionPersistence` — persists unsaved schema to localStorage so + * dev-server HMR reloads and macOS window-close + reopen don't wipe the + * in-memory schema and show an empty canvas. + */ +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({ + getLayout: () => ({ 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({ + getLayout: () => ({ 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({ + getLayout: () => ({ 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({ + getLayout: () => ({ 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({ + getLayout: () => ({ 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({ + getLayout: () => ({ 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({ + getLayout: () => ({ version: '1', positions: {} }), + onRestoreSession: vi.fn(), + storage, + }), + ); + + act(() => { + useDocumentStore.getState().setFilePath('/tmp/newly-saved.contexture.json'); + }); + + expect(storage.getItem(SESSION_KEY)).toBeNull(); + }); + + it('handles corrupt storage data without crashing', () => { + const storage = makeStorage(); + storage.setItem(SESSION_KEY, 'not valid json {{{{'); + + const onRestore = vi.fn(); + expect(() => + renderHook(() => + useSessionPersistence({ + getLayout: () => ({ version: '1', positions: {} }), + onRestoreSession: onRestore, + storage, + }), + ), + ).not.toThrow(); + + expect(onRestore).not.toHaveBeenCalled(); + expect(storage.getItem(SESSION_KEY)).toBeNull(); + }); +}); From 7e9be17c5641944906807ce594a30000ab4ee7c0 Mon Sep 17 00:00:00 2001 From: Dave Hudson Date: Fri, 1 May 2026 12:11:50 +0000 Subject: [PATCH 2/5] REVIEW: guard flush against storage write errors; add quota-exceeded test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap `setItem` in `flush` with try/catch so QuotaExceededError (private browsing, full storage) doesn't produce an unhandled error in a setTimeout callback — silent skip is the right behaviour for a best-effort cache - Remove multi-line JSDoc blocks from hook and test files (coding standards: no multi-line comment blocks) - Add test: 'does not crash when storage write throws (e.g. private browsing quota)'; use explicit unmount() to guarantee effect-cleanup order in React 19 and prevent test-order contamination --- .../src/hooks/useSessionPersistence.ts | 14 +++----- .../hooks/useSessionPersistence.test.tsx | 35 ++++++++++++++++--- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/renderer/src/hooks/useSessionPersistence.ts b/apps/desktop/src/renderer/src/hooks/useSessionPersistence.ts index 49437a2..0d8bbdc 100644 --- a/apps/desktop/src/renderer/src/hooks/useSessionPersistence.ts +++ b/apps/desktop/src/renderer/src/hooks/useSessionPersistence.ts @@ -1,11 +1,3 @@ -/** - * `useSessionPersistence` — persists unsaved schema to localStorage so - * dev-server HMR reloads and macOS window-close + reopen don't wipe the - * in-memory schema, causing the empty state screen to appear. - * - * Only kicks in for unsaved files (filePath === null). Once a file has a - * path, the file itself (or project auto-save) handles persistence. - */ import { useEffect, useRef } from 'react'; import type { Schema } from '../model/ir'; import type { Layout } from '../model/layout'; @@ -85,7 +77,11 @@ export function useSessionPersistence({ return; } const session: StoredSession = { schema, layout: getLayoutRef.current() }; - store.setItem(SESSION_KEY, JSON.stringify(session)); + try { + store.setItem(SESSION_KEY, JSON.stringify(session)); + } catch { + // Storage full or unavailable (e.g. private browsing quota) — skip silently. + } }; const unsubSchema = useUndoStore.subscribe((s) => { diff --git a/apps/desktop/tests/hooks/useSessionPersistence.test.tsx b/apps/desktop/tests/hooks/useSessionPersistence.test.tsx index 5b6d2cc..7a1a1ce 100644 --- a/apps/desktop/tests/hooks/useSessionPersistence.test.tsx +++ b/apps/desktop/tests/hooks/useSessionPersistence.test.tsx @@ -1,8 +1,3 @@ -/** - * `useSessionPersistence` — persists unsaved schema to localStorage so - * dev-server HMR reloads and macOS window-close + reopen don't wipe the - * in-memory schema and show an empty canvas. - */ import { SESSION_KEY, type SessionStorage, @@ -241,6 +236,36 @@ describe('useSessionPersistence', () => { 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({ + getLayout: () => ({ 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('handles corrupt storage data without crashing', () => { const storage = makeStorage(); storage.setItem(SESSION_KEY, 'not valid json {{{{'); From 57ef710879f5a909db0b5ebc424b1b840a4f8f5a Mon Sep 17 00:00:00 2001 From: Dave Hudson Date: Fri, 1 May 2026 13:41:50 +0100 Subject: [PATCH 3/5] fix(e2e): clear session storage before specs that need empty state The session-persistence fix from #233 restores any unsaved schema from localStorage on mount, which carries across Electron launches inside a single Playwright run. The import/export and CRUD specs both expect the "Load allotment sample" button (which only renders in the empty state), so the second one to run finds the button missing. Clear `contexture:session:v1` and reload the page in `beforeAll` so each spec starts from a clean empty schema. --- apps/desktop/e2e/contexture-crud.spec.ts | 7 +++++++ apps/desktop/e2e/import-export.spec.ts | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/apps/desktop/e2e/contexture-crud.spec.ts b/apps/desktop/e2e/contexture-crud.spec.ts index fafdab7..42c69a9 100644 --- a/apps/desktop/e2e/contexture-crud.spec.ts +++ b/apps/desktop/e2e/contexture-crud.spec.ts @@ -23,6 +23,13 @@ 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. Clear it + // and reload so the spec starts from a clean empty schema. + await page.evaluate(() => window.localStorage.removeItem('contexture:session:v1')); + await page.reload(); + await page.waitForLoadState('domcontentloaded'); }); test.afterAll(async () => { diff --git a/apps/desktop/e2e/import-export.spec.ts b/apps/desktop/e2e/import-export.spec.ts index d1ebfab..2e3ae53 100644 --- a/apps/desktop/e2e/import-export.spec.ts +++ b/apps/desktop/e2e/import-export.spec.ts @@ -20,6 +20,14 @@ 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. Clear the session and reload so this spec starts + // from a clean empty schema. + await page.evaluate(() => window.localStorage.removeItem('contexture:session:v1')); + await page.reload(); + await page.waitForLoadState('domcontentloaded'); }); test.afterAll(async () => { From b6d284c01b9a0e4d2a1e62a2ec38f1043c523b99 Mon Sep 17 00:00:00 2001 From: Dave Hudson Date: Fri, 1 May 2026 13:54:54 +0100 Subject: [PATCH 4/5] fix(session): flush on pagehide and persist layout-only changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two loss vectors made the untitled-session restore flaky in real use: 1. The 300ms debounce dropped the last write if the renderer was killed before the timer fired (e.g. stopping the dev server right after a chat turn settled). Add a `pagehide` (and `beforeunload`) listener that synchronously flushes any pending state — Electron fires these on dev-server reload and on window close. 2. Pure layout changes (a node drag with no schema mutation) never triggered a write because the only subscription was on the undo store. Take `layout` as a reactive prop and schedule a debounced flush whenever its identity changes, so dragging a node also lands in storage. API change: `useSessionPersistence` now accepts a `layout: Layout` value instead of a `getLayout` getter. Two new tests cover the pagehide flush and the layout-only persistence path. --- apps/desktop/src/renderer/src/App.tsx | 4 +- .../src/hooks/useSessionPersistence.ts | 68 +++++++++++++-- .../hooks/useSessionPersistence.test.tsx | 83 +++++++++++++++++-- 3 files changed, 136 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index ae8c999..f4ef801 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -57,6 +57,7 @@ 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'; @@ -194,8 +195,9 @@ export default function App(): React.JSX.Element { getChat: () => ({ version: '1', messages: chatMessagesRef.current }), }); + const sessionLayout = useMemo(() => ({ version: '1', positions }), [positions]); useSessionPersistence({ - getLayout: () => ({ version: '1', positions: positionsRef.current }), + layout: sessionLayout, onRestoreSession: (layout) => { setPositions(layout.positions); }, diff --git a/apps/desktop/src/renderer/src/hooks/useSessionPersistence.ts b/apps/desktop/src/renderer/src/hooks/useSessionPersistence.ts index 0d8bbdc..96b5404 100644 --- a/apps/desktop/src/renderer/src/hooks/useSessionPersistence.ts +++ b/apps/desktop/src/renderer/src/hooks/useSessionPersistence.ts @@ -19,8 +19,10 @@ export interface SessionStorage { } export interface UseSessionPersistenceOptions { - /** Current canvas layout — read on every debounced save. */ - getLayout: () => Layout; + /** 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`. */ @@ -28,14 +30,14 @@ export interface UseSessionPersistenceOptions { } export function useSessionPersistence({ - getLayout, + layout, onRestoreSession, storage = typeof window !== 'undefined' ? window.localStorage : undefined, }: UseSessionPersistenceOptions): void { const onRestoreRef = useRef(onRestoreSession); onRestoreRef.current = onRestoreSession; - const getLayoutRef = useRef(getLayout); - getLayoutRef.current = getLayout; + const layoutRef = useRef(layout); + layoutRef.current = layout; const storageRef = useRef(storage); storageRef.current = storage; @@ -60,7 +62,9 @@ export function useSessionPersistence({ } }, []); // eslint-disable-line react-hooks/exhaustive-deps - // Watch schema changes: persist to storage (debounced) when unsaved. + // 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; @@ -76,7 +80,7 @@ export function useSessionPersistence({ store.removeItem(SESSION_KEY); return; } - const session: StoredSession = { schema, layout: getLayoutRef.current() }; + const session: StoredSession = { schema, layout: layoutRef.current }; try { store.setItem(SESSION_KEY, JSON.stringify(session)); } catch { @@ -84,11 +88,15 @@ export function useSessionPersistence({ } }; + 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; - if (timer !== null) clearTimeout(timer); - timer = setTimeout(flush, 300); + schedule(); }); // Clear immediately when a file path is set (file open / save-as). @@ -101,10 +109,52 @@ export function useSessionPersistence({ } }); + // 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 index 7a1a1ce..677ada7 100644 --- a/apps/desktop/tests/hooks/useSessionPersistence.test.tsx +++ b/apps/desktop/tests/hooks/useSessionPersistence.test.tsx @@ -52,7 +52,7 @@ describe('useSessionPersistence', () => { const onRestore = vi.fn(); renderHook(() => useSessionPersistence({ - getLayout: () => ({ version: '1', positions: {} }), + layout: { version: '1', positions: {} }, onRestoreSession: onRestore, storage, }), @@ -84,7 +84,7 @@ describe('useSessionPersistence', () => { const onRestore = vi.fn(); renderHook(() => useSessionPersistence({ - getLayout: () => ({ version: '1', positions: {} }), + layout: { version: '1', positions: {} }, onRestoreSession: onRestore, storage, }), @@ -109,7 +109,7 @@ describe('useSessionPersistence', () => { const onRestore = vi.fn(); renderHook(() => useSessionPersistence({ - getLayout: () => ({ version: '1', positions: {} }), + layout: { version: '1', positions: {} }, onRestoreSession: onRestore, storage, }), @@ -124,7 +124,7 @@ describe('useSessionPersistence', () => { renderHook(() => useSessionPersistence({ - getLayout: () => ({ version: '1', positions: { Plot: { x: 1, y: 2 } } }), + layout: { version: '1', positions: { Plot: { x: 1, y: 2 } } }, onRestoreSession: vi.fn(), storage, }), @@ -155,7 +155,7 @@ describe('useSessionPersistence', () => { renderHook(() => useSessionPersistence({ - getLayout: () => ({ version: '1', positions: {} }), + layout: { version: '1', positions: {} }, onRestoreSession: vi.fn(), storage, }), @@ -192,7 +192,7 @@ describe('useSessionPersistence', () => { renderHook(() => useSessionPersistence({ - getLayout: () => ({ version: '1', positions: {} }), + layout: { version: '1', positions: {} }, onRestoreSession: vi.fn(), storage, }), @@ -223,7 +223,7 @@ describe('useSessionPersistence', () => { renderHook(() => useSessionPersistence({ - getLayout: () => ({ version: '1', positions: {} }), + layout: { version: '1', positions: {} }, onRestoreSession: vi.fn(), storage, }), @@ -244,7 +244,7 @@ describe('useSessionPersistence', () => { const { unmount } = renderHook(() => useSessionPersistence({ - getLayout: () => ({ version: '1', positions: {} }), + layout: { version: '1', positions: {} }, onRestoreSession: vi.fn(), storage, }), @@ -266,6 +266,71 @@ describe('useSessionPersistence', () => { 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 {{{{'); @@ -274,7 +339,7 @@ describe('useSessionPersistence', () => { expect(() => renderHook(() => useSessionPersistence({ - getLayout: () => ({ version: '1', positions: {} }), + layout: { version: '1', positions: {} }, onRestoreSession: onRestore, storage, }), From 9c13c3fbdd001092d1d09edb29e2b15b5bbd9ad2 Mon Sep 17 00:00:00 2001 From: Dave Hudson Date: Fri, 1 May 2026 14:03:27 +0100 Subject: [PATCH 5/5] fix(e2e): reset in-memory schema before clearing session storage The previous attempt cleared `localStorage` then called `page.reload()`, but the reload itself triggers `pagehide` which the new persistence flush uses to write any non-empty schema back to storage. The next mount then restores it, so the empty state never shows. Reset the in-memory schema to empty via the exposed undo store before clearing the session key. The persistence subscriber sees an empty schema and removes the key on its own; we still belt-and-brace it. --- apps/desktop/e2e/contexture-crud.spec.ts | 19 ++++++++++++++----- apps/desktop/e2e/import-export.spec.ts | 20 +++++++++++++++----- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/apps/desktop/e2e/contexture-crud.spec.ts b/apps/desktop/e2e/contexture-crud.spec.ts index 42c69a9..775283e 100644 --- a/apps/desktop/e2e/contexture-crud.spec.ts +++ b/apps/desktop/e2e/contexture-crud.spec.ts @@ -25,11 +25,20 @@ test.describe('Contexture CRUD', () => { 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. Clear it - // and reload so the spec starts from a clean empty schema. - await page.evaluate(() => window.localStorage.removeItem('contexture:session:v1')); - await page.reload(); - await page.waitForLoadState('domcontentloaded'); + // 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 2e3ae53..f4b7e94 100644 --- a/apps/desktop/e2e/import-export.spec.ts +++ b/apps/desktop/e2e/import-export.spec.ts @@ -23,11 +23,21 @@ test.describe('Import / export round-trip', () => { // 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. Clear the session and reload so this spec starts - // from a clean empty schema. - await page.evaluate(() => window.localStorage.removeItem('contexture:session:v1')); - await page.reload(); - await page.waitForLoadState('domcontentloaded'); + // 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 () => {