diff --git a/src/lib/stores/resilient-local-storage.ts b/src/lib/stores/resilient-local-storage.ts new file mode 100644 index 000000000..40ab1330d --- /dev/null +++ b/src/lib/stores/resilient-local-storage.ts @@ -0,0 +1,82 @@ +import { createJSONStorage, type StateStorage } from 'zustand/middleware'; + +/** Lists the server hydrates again. Drafts and the result on screen stay. */ +const BULKY_LISTS = [ + 'sourceLibrary', + 'sourceFolders', + 'savedCases', + 'savedAttacks', + 'savedBrainstorms', + 'savedFactoids', + 'savedChats', + 'savedFlowcharts', + 'savedCrossfires', +]; + +function isQuotaError(error: unknown): boolean { + if (!(error instanceof DOMException)) return false; + return error.name === 'QuotaExceededError' || error.name === 'NS_ERROR_DOM_QUOTA_REACHED' || error.code === 22; +} + +function readState(serialized: string): { envelope: { state?: Record }; state: Record } | null { + try { + const envelope = JSON.parse(serialized) as { state?: Record }; + if (!envelope.state || typeof envelope.state !== 'object') return null; + return { envelope, state: envelope.state }; + } catch { + return null; + } +} + +/** Drop source text first. Titles and ids stay, so the library does not flash empty. */ +export function withoutSourceText(serialized: string): string | null { + const parsed = readState(serialized); + if (!parsed || !Array.isArray(parsed.state.sourceLibrary)) return null; + parsed.state.sourceLibrary = parsed.state.sourceLibrary.map((item) => { + if (!item || typeof item !== 'object') return item; + return { ...(item as Record), content: '' }; + }); + return JSON.stringify(parsed.envelope); +} + +/** Last try: forget the bulky copies and keep the open draft. */ +export function withoutBulkyLists(serialized: string): string | null { + const parsed = readState(serialized); + if (!parsed) return null; + for (const key of BULKY_LISTS) delete parsed.state[key]; + return JSON.stringify(parsed.envelope); +} + +/** + * Zustand persist lets a full localStorage throw out of a tool page. On a quota + * error, retry without source text, then without the bulky lists, then keep + * going in memory. + */ +export function resilientSetItem(storage: Pick, key: string, value: string): void { + const attempts = [value, withoutSourceText(value), withoutBulkyLists(value)] + .filter((attempt): attempt is string => typeof attempt === 'string'); + const seen = new Set(); + let lastQuota: unknown; + for (const attempt of attempts) { + if (seen.has(attempt)) continue; + seen.add(attempt); + try { + storage.setItem(key, attempt); + return; + } catch (error) { + if (!isQuotaError(error)) throw error; + lastQuota = error; + } + } + console.warn('[SessionStore] Local storage is full; this change stays in memory.', lastQuota); +} + +const resilientStorage: StateStorage = { + getItem: (name) => localStorage.getItem(name), + setItem: (name, value) => resilientSetItem(localStorage, name, value), + removeItem: (name) => localStorage.removeItem(name), +}; + +export function resilientLocalStorage() { + return createJSONStorage(() => resilientStorage); +} diff --git a/src/lib/stores/session-store.ts b/src/lib/stores/session-store.ts index dcdea8e78..5ec10fcf4 100644 --- a/src/lib/stores/session-store.ts +++ b/src/lib/stores/session-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; +import { persist } from 'zustand/middleware'; +import { resilientLocalStorage } from '@/lib/stores/resilient-local-storage'; import type { Node, Edge } from 'reactflow'; import type { GenerateOutput, GenerateInput } from '../ai/schemas/generate'; import type { AttackOutput, AttackInput } from '../ai/schemas/attack'; @@ -1285,7 +1286,7 @@ export const useSessionStore = create()( }), { name: 'debate-trainer-storage', - storage: createJSONStorage(() => localStorage), + storage: resilientLocalStorage(), // v1 scopes saved work and sources to debates. Drop lists persisted by // older clients: the server replaces them on hydrate, and keeping them // would resurrect purged legacy rows and debate-less sources. diff --git a/src/test/stores/resilient-local-storage.test.ts b/src/test/stores/resilient-local-storage.test.ts new file mode 100644 index 000000000..ebc65535e --- /dev/null +++ b/src/test/stores/resilient-local-storage.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { resilientSetItem, withoutBulkyLists, withoutSourceText } from '@/lib/stores/resilient-local-storage'; + +const saved = JSON.stringify({ + state: { + sourceLibrary: [{ id: 'src-1', title: 'Buses', content: 'a very long article' }], + savedCases: [{ id: 'case-1', data: { contentions: ['cost'] } }], + generateForm: { motion: 'Free buses' }, + generateResult: { title: 'Open case' }, + }, + version: 1, +}); + +describe('resilient local storage', () => { + it('removes source text before it removes saved lists', () => { + const stripped = withoutSourceText(saved); + expect(stripped).toContain('"title":"Buses"'); + expect(stripped).not.toContain('a very long article'); + expect(stripped).toContain('Free buses'); + + const emptied = withoutBulkyLists(saved)!; + const parsed = JSON.parse(emptied) as { state: Record }; + expect(parsed.state.sourceLibrary).toBeUndefined(); + expect(parsed.state.savedCases).toBeUndefined(); + expect(parsed.state.generateForm).toEqual({ motion: 'Free buses' }); + expect(parsed.state.generateResult).toEqual({ title: 'Open case' }); + }); + + it('retries a quota failure and does not throw when every copy is still too big', () => { + const writes: string[] = []; + const storage = { + setItem: (key: string, value: string) => { + writes.push(value); + if (value.includes('a very long article') || value.includes('savedCases')) { + throw new DOMException('quota', 'QuotaExceededError'); + } + }, + }; + resilientSetItem(storage, 'debate-trainer-storage', saved); + expect(writes).toHaveLength(3); + expect(writes[1]).not.toContain('a very long article'); + expect(writes[2]).not.toContain('savedCases'); + expect(writes[2]).toContain('Free buses'); + + const alwaysFull = { + setItem: () => { throw new DOMException('quota', 'QuotaExceededError'); }, + }; + expect(() => resilientSetItem(alwaysFull, 'debate-trainer-storage', saved)).not.toThrow(); + }); + + it('still throws errors that are not a full disk', () => { + const storage = { setItem: () => { throw new Error('blocked'); } }; + expect(() => resilientSetItem(storage, 'debate-trainer-storage', saved)).toThrow('blocked'); + }); +});