feat: Add shared runes-based localStorage persistence module
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
219d8af74f
commit
ca96ca8771
@@ -0,0 +1,54 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { persisted, readJSON, writeJSON } from './persist.svelte';
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe('readJSON', () => {
|
||||
it('returns the fallback when the key is missing', () => {
|
||||
expect(readJSON('missing', 'fallback')).toBe('fallback');
|
||||
});
|
||||
|
||||
it('returns the parsed value when present', () => {
|
||||
window.localStorage.setItem('key', '{"a":1}');
|
||||
expect(readJSON<{ a: number }>('key', { a: 0 })).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('returns the fallback when the stored value is corrupt', () => {
|
||||
window.localStorage.setItem('corrupt', '{oops');
|
||||
expect(readJSON('corrupt', 'fallback')).toBe('fallback');
|
||||
// The previous persistence layer could write the literal string "undefined".
|
||||
window.localStorage.setItem('legacy', 'undefined');
|
||||
expect(readJSON('legacy', 'fallback')).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeJSON', () => {
|
||||
it('round-trips values through localStorage as JSON', () => {
|
||||
writeJSON('key', { nested: { value: 2 } });
|
||||
expect(window.localStorage.getItem('key')).toBe('{"nested":{"value":2}}');
|
||||
expect(readJSON('key', {})).toEqual({ nested: { value: 2 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('persisted', () => {
|
||||
it('initialises from storage when a value exists', () => {
|
||||
window.localStorage.setItem('counter', '5');
|
||||
const counter = persisted('counter', 0);
|
||||
expect(counter.value).toBe(5);
|
||||
});
|
||||
|
||||
it('uses the initial value when storage is empty, without writing it', () => {
|
||||
const counter = persisted('counter', 7);
|
||||
expect(counter.value).toBe(7);
|
||||
expect(window.localStorage.getItem('counter')).toBeNull();
|
||||
});
|
||||
|
||||
it('persists on assignment and exposes the new value', () => {
|
||||
const counter = persisted('counter', 0);
|
||||
counter.value = 42;
|
||||
expect(counter.value).toBe(42);
|
||||
expect(window.localStorage.getItem('counter')).toBe('42');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Runes-based localStorage persistence, shared by the persisted state in
|
||||
* `state.svelte.ts`, `migrations.svelte.ts`, `promo.svelte.ts` and History.
|
||||
*
|
||||
* Values are stored as plain JSON. Reads of missing or corrupt values fall
|
||||
* back to the provided default, so values written by older versions of the
|
||||
* editor (which serialized plain objects to the same JSON shape) stay loadable.
|
||||
*/
|
||||
|
||||
const hasStorage = (): boolean => typeof window !== 'undefined' && !!window.localStorage;
|
||||
|
||||
export const readJSON = <T>(key: string, fallback: T): T => {
|
||||
if (!hasStorage()) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
return raw === null ? fallback : (JSON.parse(raw) as T);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
export const writeJSON = (key: string, value: unknown): void => {
|
||||
if (hasStorage()) {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
export interface Persisted<T> {
|
||||
value: T;
|
||||
}
|
||||
|
||||
// A localStorage-backed reactive value. Reads on init, writes on every set.
|
||||
export const persisted = <T>(key: string, initial: T): Persisted<T> => {
|
||||
let value = $state<T>(readJSON(key, initial));
|
||||
return {
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
set value(next: T) {
|
||||
value = next;
|
||||
writeJSON(key, next);
|
||||
}
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user