Refactor/reacrhitecture to fsd+ #49

Merged
ilia merged 70 commits from refactor/reacrhitecture-to-fsd+ into main 2026-06-03 09:55:47 +00:00
3 changed files with 62 additions and 61 deletions
Showing only changes of commit ded9606c30 - Show all commits
@@ -51,6 +51,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
let mockPersistentStore: { let mockPersistentStore: {
value: TypographySettings; value: TypographySettings;
clear: () => void; clear: () => void;
destroy: () => void;
}; };
const createMockPersistentStore = (initialValue: TypographySettings) => { const createMockPersistentStore = (initialValue: TypographySettings) => {
@@ -70,6 +71,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
letterSpacing: DEFAULT_LETTER_SPACING, letterSpacing: DEFAULT_LETTER_SPACING,
}; };
}, },
destroy() {},
}; };
}; };
@@ -535,6 +537,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
mockStorage = v; mockStorage = v;
}, },
clear: clearSpy, clear: clearSpy,
destroy() {},
}; };
const manager = new TypographySettingsStore( const manager = new TypographySettingsStore(
@@ -1,66 +1,15 @@
/** /**
* Persistent localStorage-backed reactive state * Reactive localStorage-backed state. Loads on init, saves on change via an
* $effect.root. Falls back to the default on SSR (no localStorage) and on JSON
* parse errors; swallows quota/write errors with a warning.
* *
* Creates reactive state that automatically syncs with localStorage. * Owners that create this outside a component must call destroy() to dispose
* Values persist across browser sessions and are restored on page load. * the save effect.
* *
* Handles edge cases: * @param key - localStorage key
* - SSR safety (no localStorage on server) * @param defaultValue - value used when nothing is stored
* - JSON parse errors (falls back to default)
* - Storage quota errors (logs warning, doesn't crash)
*
* @example
* ```ts
* // Store user preferences
* const preferences = createPersistentStore('user-prefs', {
* theme: 'dark',
* fontSize: 16,
* sidebarOpen: true
* });
*
* // Access reactive state
* $: currentTheme = preferences.value.theme;
*
* // Update (auto-saves to localStorage)
* preferences.value.theme = 'light';
*
* // Clear stored value
* preferences.clear();
* ```
*/
/**
* Creates a reactive store backed by localStorage
*
* The value is loaded from localStorage on initialization and automatically
* saved whenever it changes. Uses Svelte 5's $effect for reactive sync.
*
* @param key - localStorage key for storing the value
* @param defaultValue - Default value if no stored value exists
* @returns Persistent store with getter/setter and clear method
*
* @example
* ```ts
* // Simple value
* const counter = createPersistentStore('counter', 0);
* counter.value++;
*
* // Complex object
* interface Settings {
* theme: 'light' | 'dark';
* fontSize: number;
* }
* const settings = createPersistentStore<Settings>('app-settings', {
* theme: 'light',
* fontSize: 16
* });
* ```
*/ */
export function createPersistentStore<T>(key: string, defaultValue: T) { export function createPersistentStore<T>(key: string, defaultValue: T) {
/**
* Load value from localStorage or return default
* Safely handles missing keys, parse errors, and SSR
*/
const loadFromStorage = (): T => { const loadFromStorage = (): T => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return defaultValue; return defaultValue;
@@ -76,9 +25,13 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
let value = $state<T>(loadFromStorage()); let value = $state<T>(loadFromStorage());
// Sync to storage whenever value changes /**
// Wrapped in $effect.root to prevent memory leaks * Sync to storage whenever value changes. The effect lives in an
$effect.root(() => { * $effect.root so it outlives any component; the returned disposer is kept
* and run by destroy(), because an $effect.root with no disposer leaks for
* the life of the process.
*/
const dispose = $effect.root(() => {
$effect(() => { $effect(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
@@ -113,6 +66,15 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
} }
value = defaultValue; value = defaultValue;
}, },
/**
* Dispose the storage-sync effect. Owners that create a store outside a
* component (e.g. a singleton store class) must call this to avoid
* leaking the underlying $effect.root.
*/
destroy() {
dispose();
},
}; };
} }
@@ -1,6 +1,7 @@
/** /**
* @vitest-environment jsdom * @vitest-environment jsdom
*/ */
import { flushSync } from 'svelte';
import { import {
afterEach, afterEach,
beforeEach, beforeEach,
@@ -376,4 +377,39 @@ describe('createPersistentStore', () => {
expect(store.value[0].name).toBe('First'); expect(store.value[0].name).toBe('First');
}); });
}); });
describe('Lifecycle', () => {
it('persists value changes via the sync effect', () => {
const store = createPersistentStore(testKey, 'a');
const spy = vi.spyOn(mockLocalStorage, 'setItem');
store.value = 'b';
flushSync();
expect(spy).toHaveBeenCalledWith(testKey, JSON.stringify('b'));
});
it('stops persisting after destroy()', () => {
const store = createPersistentStore(testKey, 'a');
flushSync();
store.destroy();
const spy = vi.spyOn(mockLocalStorage, 'setItem');
store.value = 'c';
flushSync();
expect(spy).not.toHaveBeenCalled();
// reading still works after disposal
expect(store.value).toBe('c');
});
it('destroy() is safe to call repeatedly', () => {
const store = createPersistentStore(testKey, 'a');
expect(() => {
store.destroy();
store.destroy();
}).not.toThrow();
});
});
}); });