diff --git a/www/bun.lock b/www/bun.lock index fdc238e..16ac080 100644 --- a/www/bun.lock +++ b/www/bun.lock @@ -44,6 +44,7 @@ "eslint-plugin-react-refresh": "^0.3.4", "postcss": "^8.5.25", "prettier-plugin-tailwindcss": "^0.6.11", + "react-test-renderer": "^18.3.1", "tailwindcss": "^4.1.3", "typescript": "^5.0.2", "vite": "^6.4.3", @@ -643,6 +644,8 @@ "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -681,6 +684,8 @@ "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], @@ -689,8 +694,12 @@ "react-resizable-panels": ["react-resizable-panels@2.1.9", "", { "peerDependencies": { "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ=="], + "react-shallow-renderer": ["react-shallow-renderer@16.15.0", "", { "dependencies": { "object-assign": "^4.1.1", "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0" } }, "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA=="], + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "react-test-renderer": ["react-test-renderer@18.3.1", "", { "dependencies": { "react-is": "^18.3.1", "react-shallow-renderer": "^16.15.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA=="], + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], diff --git a/www/package.json b/www/package.json index 61cb155..8a377a6 100644 --- a/www/package.json +++ b/www/package.json @@ -10,7 +10,8 @@ "dev": "vite", "build": "tsc && vite build", "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview" + "preview": "vite preview", + "test": "bun test" }, "dependencies": { "@codemirror/lang-rust": "^6.0.1", @@ -52,6 +53,7 @@ "eslint-plugin-react-refresh": "^0.3.4", "postcss": "^8.5.25", "prettier-plugin-tailwindcss": "^0.6.11", + "react-test-renderer": "^18.3.1", "tailwindcss": "^4.1.3", "typescript": "^5.0.2", "vite": "^6.4.3" diff --git a/www/src/App.tsx b/www/src/App.tsx index f15428b..e1ddc6d 100644 --- a/www/src/App.tsx +++ b/www/src/App.tsx @@ -15,6 +15,7 @@ import { usePersistedDoc } from './hooks/use-persisted-doc'; import { useValAst } from './hooks/use-val-ast'; import { useValWasm } from './hooks/use-val-wasm'; import { examples } from './lib/examples'; +import { storage } from './lib/storage'; const STORAGE_KEY_CODE = 'val-editor-code'; const STORAGE_KEY_EXAMPLE = 'val-editor-example'; @@ -29,9 +30,10 @@ function App() { ); const [currentExample, setCurrentExample] = useState(() => { - const savedExample = localStorage.getItem(STORAGE_KEY_EXAMPLE); + const savedExample = storage.getItem(STORAGE_KEY_EXAMPLE); - return savedExample && savedExample in examples + return savedExample && + Object.prototype.hasOwnProperty.call(examples, savedExample) ? savedExample : DEFAULT_EXAMPLE; }); @@ -58,11 +60,11 @@ function App() { }, []); useEffect(() => { - localStorage.setItem(STORAGE_KEY_EXAMPLE, currentExample); + storage.setItem(STORAGE_KEY_EXAMPLE, currentExample); }, [currentExample]); const handleExampleChange = (value: string) => { - if (!(value in examples)) { + if (!Object.prototype.hasOwnProperty.call(examples, value)) { return; } @@ -95,6 +97,7 @@ function App() { diff --git a/www/src/contexts/editor-settings-context.ts b/www/src/contexts/editor-settings-context.ts index 995dd22..892a755 100644 --- a/www/src/contexts/editor-settings-context.ts +++ b/www/src/contexts/editor-settings-context.ts @@ -16,6 +16,41 @@ export const defaultSettings: EditorSettings = { tabSize: 2, }; +export function deserializeSettings(value: string): EditorSettings { + const saved: unknown = JSON.parse(value); + + if (saved === null || typeof saved !== 'object' || Array.isArray(saved)) { + return defaultSettings; + } + + const settings = saved as Record; + + return { + fontSize: + typeof settings.fontSize === 'number' && + [12, 14, 16, 18].includes(settings.fontSize) + ? settings.fontSize + : defaultSettings.fontSize, + keybindings: + settings.keybindings === 'default' || settings.keybindings === 'vim' + ? settings.keybindings + : defaultSettings.keybindings, + lineNumbers: + typeof settings.lineNumbers === 'boolean' + ? settings.lineNumbers + : defaultSettings.lineNumbers, + lineWrapping: + typeof settings.lineWrapping === 'boolean' + ? settings.lineWrapping + : defaultSettings.lineWrapping, + tabSize: + typeof settings.tabSize === 'number' && + [2, 4, 8].includes(settings.tabSize) + ? settings.tabSize + : defaultSettings.tabSize, + }; +} + type EditorSettingsContextType = { settings: EditorSettings; updateSettings: (settings: Partial) => void; diff --git a/www/src/hooks/use-persisted-doc.tsx b/www/src/hooks/use-persisted-doc.tsx index cc49687..f005ef9 100644 --- a/www/src/hooks/use-persisted-doc.tsx +++ b/www/src/hooks/use-persisted-doc.tsx @@ -1,17 +1,14 @@ +import { storage } from '@/lib/storage'; import { useCallback, useEffect, useState } from 'react'; export function usePersistedDoc( key: string, fallback: string ): [string, (value: string) => void] { - const [value, setValue] = useState(() => { - const stored = window.localStorage.getItem(key); - - return stored && stored.length > 0 ? stored : fallback; - }); + const [value, setValue] = useState(() => storage.getItem(key) ?? fallback); useEffect(() => { - window.localStorage.setItem(key, value); + storage.setItem(key, value); }, [key, value]); return [value, useCallback((next: string) => setValue(next), [])]; diff --git a/www/src/hooks/use-persisted-state.tsx b/www/src/hooks/use-persisted-state.tsx index 83a77b5..55acffe 100644 --- a/www/src/hooks/use-persisted-state.tsx +++ b/www/src/hooks/use-persisted-state.tsx @@ -1,3 +1,4 @@ +import { storage } from '@/lib/storage'; import { useCallback, useEffect, useState } from 'react'; export function usePersistedState( @@ -5,17 +6,23 @@ export function usePersistedState( initialValue: T, options?: { serialize?: (value: T) => string; - deserialize?: (value: string) => T; + deserialize?: (value: string) => Partial; } ): [T, (action: Partial | ((prevState: T) => Partial)) => void] { const [state, setFullState] = useState(() => { - const savedValue = localStorage.getItem(key); + const savedValue = storage.getItem(key); if (savedValue !== null) { try { - return options?.deserialize + const saved: unknown = options?.deserialize ? options.deserialize(savedValue) : JSON.parse(savedValue); + + return saved !== null && + typeof saved === 'object' && + !Array.isArray(saved) + ? { ...initialValue, ...saved } + : initialValue; } catch (error) { console.warn(`Error reading ${key} from localStorage:`, error); return initialValue; @@ -27,7 +34,7 @@ export function usePersistedState( useEffect(() => { try { - localStorage.setItem( + storage.setItem( key, options?.serialize ? options.serialize(state) : JSON.stringify(state) ); diff --git a/www/src/lib/storage.ts b/www/src/lib/storage.ts new file mode 100644 index 0000000..688a2d7 --- /dev/null +++ b/www/src/lib/storage.ts @@ -0,0 +1,17 @@ +export const storage = { + getItem(key: string): string | null { + try { + return window.localStorage.getItem(key); + } catch { + return null; + } + }, + + setItem(key: string, value: string): void { + try { + window.localStorage.setItem(key, value); + } catch { + return; + } + }, +}; diff --git a/www/src/providers/editor-settings-provider.tsx b/www/src/providers/editor-settings-provider.tsx index d67969c..59144e3 100644 --- a/www/src/providers/editor-settings-provider.tsx +++ b/www/src/providers/editor-settings-provider.tsx @@ -2,10 +2,13 @@ import { EditorSettings, EditorSettingsContext, defaultSettings, + deserializeSettings, } from '@/contexts/editor-settings-context'; import { usePersistedState } from '@/hooks/use-persisted-state'; import { ReactNode } from 'react'; +const persistenceOptions = { deserialize: deserializeSettings }; + export const EditorSettingsProvider = ({ children, }: { @@ -13,7 +16,8 @@ export const EditorSettingsProvider = ({ }) => { const [settings, setSettings] = usePersistedState( 'editor-settings', - defaultSettings + defaultSettings, + persistenceOptions ); const updateSettings = (newSettings: Partial) => { diff --git a/www/tests/persistence.test.tsx b/www/tests/persistence.test.tsx new file mode 100644 index 0000000..6ad2511 --- /dev/null +++ b/www/tests/persistence.test.tsx @@ -0,0 +1,299 @@ +import { afterEach, expect, mock, spyOn, test } from 'bun:test'; +import type { ReactElement } from 'react'; +import { act, create } from 'react-test-renderer'; + +import { + defaultSettings, + useEditorSettings, +} from '../src/contexts/editor-settings-context'; +import { usePersistedDoc } from '../src/hooks/use-persisted-doc'; +import { usePersistedState } from '../src/hooks/use-persisted-state'; +import { EditorSettingsProvider } from '../src/providers/editor-settings-provider'; + +mock.module('../src/lib/examples', () => ({ + examples: { factorial: 'foo', bar: 'baz' }, +})); + +mock.module('../src/hooks/use-val-wasm', () => ({ + useValWasm: () => ({ loaded: false, loading: true, error: undefined }), +})); + +mock.module('val-wasm', () => ({ parse: () => undefined })); + +const { default: App } = await import('../src/App'); + +const windowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window'); +const renderers: ReturnType[] = []; + +afterEach(() => { + act(() => renderers.splice(0).forEach((renderer) => renderer.unmount())); + + if (windowDescriptor) { + Object.defineProperty(globalThis, 'window', windowDescriptor); + } else { + Reflect.deleteProperty(globalThis, 'window'); + } + + mock.restore(); +}); + +class Storage { + values = new Map(); + + constructor(failure?: 'access' | 'read' | 'write') { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + get localStorage() { + if (failure === 'access') { + throw new Error('foo'); + } + + return storage; + }, + matchMedia: () => ({ + matches: false, + addEventListener: mock(), + removeEventListener: mock(), + }), + }, + }); + + const storage = { + getItem: (key: string) => { + if (failure === 'read') { + throw new Error('foo'); + } + + return this.values.get(key) ?? null; + }, + setItem: (key: string, value: string) => { + if (failure === 'write') { + throw new Error('foo'); + } + + this.values.set(key, value); + }, + }; + } +} + +function render(element: ReactElement) { + const renderer = create(null); + renderers.push(renderer); + act(() => renderer.update(element)); + return renderer; +} + +function Doc() { + const [value, setValue] = usePersistedDoc('foo', 'bar'); + return ( + setValue(event.target.value)} /> + ); +} + +function Settings() { + const { settings, updateSettings } = useEditorSettings(); + return ( + + ); +} + +test('documents restore saved content including empty strings', () => { + function check(saved: string | null, expected: string) { + const storage = new Storage(); + if (saved !== null) { + storage.values.set('foo', saved); + } + + const renderer = render(); + expect(renderer.root.findByType('input').props.value).toBe(expected); + expect(storage.values.get('foo')).toBe(expected); + } + + check(null, 'bar'); + check('', ''); + check('baz', 'baz'); +}); + +test('document edits are saved and survive remounting', () => { + const storage = new Storage(); + const renderer = render(); + + act(() => + renderer.root.findByType('input').props.onChange({ target: { value: '' } }) + ); + + expect(storage.values.get('foo')).toBe(''); + act(() => renderer.unmount()); + expect(render().root.findByType('input').props.value).toBe(''); +}); + +test.each(['access', 'read', 'write'] as const)( + 'documents and settings remain editable after storage %s errors', + (failure) => { + const storage = new Storage(failure); + const savedSettings = { ...defaultSettings, fontSize: 12 }; + storage.values.set('foo', 'foo'); + storage.values.set('editor-settings', JSON.stringify(savedSettings)); + const renderer = render( + + + + + ); + + expect(renderer.root.findByType('input').props.value).toBe( + failure === 'write' ? 'foo' : 'bar' + ); + expect( + JSON.parse(renderer.root.findByType('button').children.join('')) + ).toEqual(failure === 'write' ? savedSettings : defaultSettings); + + act(() => { + renderer.root + .findByType('input') + .props.onChange({ target: { value: 'baz' } }); + renderer.root.findByType('button').props.onClick(); + }); + + expect(renderer.root.findByType('input').props.value).toBe('baz'); + expect( + JSON.parse(renderer.root.findByType('button').children.join('')) + ).toEqual({ ...defaultSettings, fontSize: 16 }); + + if (failure === 'read') { + expect(storage.values.get('foo')).toBe('baz'); + expect( + JSON.parse(storage.values.get('editor-settings') ?? 'null') + ).toEqual({ + ...defaultSettings, + fontSize: 16, + }); + } else { + expect(storage.values.get('foo')).toBe('foo'); + expect( + JSON.parse(storage.values.get('editor-settings') ?? 'null') + ).toEqual(savedSettings); + } + } +); + +test('saved objects receive new defaults and support partial updates', () => { + const storage = new Storage(); + storage.values.set('foo', '{"foo":3}'); + + function Foo() { + const [state, setState] = usePersistedState('foo', { foo: 1, bar: 2 }); + return ( + + ); + } + + const renderer = render(); + expect(JSON.parse(storage.values.get('foo') ?? 'null')).toEqual({ + foo: 3, + bar: 2, + }); + act(() => renderer.root.findByType('button').props.onClick()); + expect(JSON.parse(storage.values.get('foo') ?? 'null')).toEqual({ + foo: 3, + bar: 4, + }); +}); + +test('invalid saved object shapes fall back to defaults', () => { + spyOn(console, 'warn').mockImplementation(() => undefined); + + function Foo() { + const [state] = usePersistedState('foo', { bar: 1 }); + return {JSON.stringify(state)}; + } + + function check(saved: string) { + const storage = new Storage(); + storage.values.set('foo', saved); + render(); + expect(JSON.parse(storage.values.get('foo') ?? 'null')).toEqual({ bar: 1 }); + } + + ['foo', 'null', '[]', '1', 'true', '"bar"'].forEach(check); +}); + +test('saved editor settings validate fields and restore defaults', () => { + spyOn(console, 'warn').mockImplementation(() => undefined); + + function check(saved: string, expected = defaultSettings) { + const storage = new Storage(); + storage.values.set('editor-settings', saved); + const renderer = render( + + + + ); + expect( + JSON.parse(renderer.root.findByType('button').children.join('')) + ).toEqual(expected); + expect(JSON.parse(storage.values.get('editor-settings') ?? 'null')).toEqual( + expected + ); + } + + ['foo', 'null', '[]', '1', 'true', '"bar"'].forEach((saved) => check(saved)); + check('{"fontSize":16}', { ...defaultSettings, fontSize: 16 }); + check( + '{"fontSize":18,"keybindings":"vim","lineNumbers":false,"lineWrapping":false,"tabSize":8}', + { + fontSize: 18, + keybindings: 'vim', + lineNumbers: false, + lineWrapping: false, + tabSize: 8, + } + ); + check( + '{"fontSize":"18","keybindings":null,"lineNumbers":"false","lineWrapping":0,"tabSize":"8"}' + ); + check('{"fontSize":0,"keybindings":"foo","tabSize":-1}'); + check('{"fontSize":1e400,"tabSize":2.5}'); + check('{"fontSize":12,"tabSize":4,"foo":true}', { + ...defaultSettings, + fontSize: 12, + tabSize: 4, + }); +}); + +test.each(['access', 'read', 'write'] as const)( + 'the app renders after storage %s errors', + (failure) => { + new Storage(failure); + expect( + render( + + + + ).toJSON() + ).not.toBeNull(); + } +); + +test('only saved examples present in the example list are restored', () => { + function check(saved: string, expected: string) { + const storage = new Storage(); + storage.values.set('val-editor-example', saved); + render( + + + + ); + expect(storage.values.get('val-editor-example')).toBe(expected); + } + + check('bar', 'bar'); + check('baz', 'factorial'); + check('__proto__', 'factorial'); +});