diff --git a/src/lib/utils/localstorage-writable.ts b/src/lib/utils/localstorage-writable.ts new file mode 100644 index 00000000..49f2a238 --- /dev/null +++ b/src/lib/utils/localstorage-writable.ts @@ -0,0 +1,55 @@ +import { browser } from '$app/environment' +import { type Updater, type Writable, get, writable } from 'svelte/store' + +export type LocalStorageWritable = Writable & { + wipe: () => void +} + +/** + * A standard svelte-compatible writable with persistent values backed up in localStorage + * @param localStorageKey The key used for local-storage + * @param defaultValue The default value in case there is none found in localstorage + * @returns A standard writable that persists all value changes to localstorage + */ +export const localStorageWritable = ( + localStorageKey: string, + defaultValue?: T +): LocalStorageWritable => { + const storedValue = browser ? localStorage.getItem(localStorageKey) : null + const localStorageValue = (() => { + try { + return storedValue !== null ? JSON.parse(storedValue) : null + } catch { + return null + } + })() + + const initialValue: T | null = localStorageValue ?? defaultValue ?? null + + const { set, subscribe } = writable(null) + + const setAndStore = (value: T | null) => { + if (browser) { + localStorage.setItem(localStorageKey, JSON.stringify(value)) + } + + set(value) + } + + const wipe = () => { + if (browser) { + localStorage.removeItem(localStorageKey) + } + + set(null) + } + + setAndStore(initialValue) + + return { + subscribe, + set: setAndStore, + wipe, + update: (updater: Updater) => setAndStore(updater(get({ subscribe }))) + } +} diff --git a/src/lib/utils/localstorage-writable.unit.test.ts b/src/lib/utils/localstorage-writable.unit.test.ts new file mode 100644 index 00000000..6db80107 --- /dev/null +++ b/src/lib/utils/localstorage-writable.unit.test.ts @@ -0,0 +1,157 @@ +import { localStorageWritable } from './localstorage-writable' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { get } from 'svelte/store' + +const mockedBrowser = vi.hoisted(() => { + return vi.fn() +}) + +vi.mock('$app/environment', () => { + return { + get browser() { + return mockedBrowser() + } + } +}) + +const localStorageMockFactory = () => { + const store: Record = {} + + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { + store[key] = value + }, + removeItem: (key: string) => { + delete store[key] + } + } +} + +describe('localStorageWritable', () => { + beforeEach(() => { + mockedBrowser.mockReturnValue(true) + vi.stubGlobal('localStorage', localStorageMockFactory()) + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + it('should initialize with default value if no value in localStorage', () => { + const defaultValue = 'default' + + const store = localStorageWritable('testKey', defaultValue) + + expect(get(store)).toBe(defaultValue) + expect(localStorage.getItem('testKey')).toBe(JSON.stringify(defaultValue)) + }) + + it('should initialize with value from localStorage if present', () => { + localStorage.setItem('testKey', JSON.stringify('storedValue')) + + const store = localStorageWritable('testKey') + + expect(get(store)).toBe('storedValue') + }) + + it('should prioritize value from local storage over default', () => { + localStorage.setItem('testKey', JSON.stringify('storedValue')) + + const store = localStorageWritable('testKey', 'defaultValue') + + expect(get(store)).toBe('storedValue') + expect(localStorage.getItem('testKey')).toBe(JSON.stringify('storedValue')) + }) + + it('should store value in localStorage when set', () => { + const store = localStorageWritable('testKey') + store.set('newValue') + + expect(localStorage.getItem('testKey')).toBe(JSON.stringify('newValue')) + expect(get(store)).toBe('newValue') + }) + + it('should remove value from localStorage when wipe is called', () => { + localStorage.setItem('testKey', JSON.stringify('storedValue')) + + const store = localStorageWritable('testKey') + store.wipe() + + expect(localStorage.getItem('testKey')).toBe(null) + expect(get(store)).toBe(null) + }) + + it('should update the value correctly using update method', () => { + const store = localStorageWritable('testKey', 1) + store.update((n) => n! + 1) + + expect(localStorage.getItem('testKey')).toBe(JSON.stringify(2)) + expect(get(store)).toBe(2) + }) + + it('should initialize with default value if non-valid JSON is present in localStorage', () => { + localStorage.setItem('testKey', 'invalid-json') + + const defaultValue = 'default' + const store = localStorageWritable('testKey', defaultValue) + + expect(get(store)).toBe(defaultValue) + expect(localStorage.getItem('testKey')).toBe(JSON.stringify(defaultValue)) + }) + + it('should initialize with null if no default value and non-valid JSON is present in localStorage', () => { + localStorage.setItem('testKey', 'invalid-json') + + const store = localStorageWritable('testKey') + + expect(get(store)).toBe(null) + expect(localStorage.getItem('testKey')).toBe(JSON.stringify(null)) + }) +}) + +describe('localStorageWritable with no browser', () => { + beforeEach(() => { + mockedBrowser.mockReturnValue(false) + vi.stubGlobal('localStorage', undefined) + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + it('should initialize with default value', () => { + const defaultValue = 'default' + const store = localStorageWritable('testKey', defaultValue) + + expect(get(store)).toBe(defaultValue) + }) + + it('should initialize with null', () => { + const store = localStorageWritable('testKey') + + expect(get(store)).toBe(null) + }) + + it('should set value', () => { + const store = localStorageWritable('testKey') + store.set('newValue') + + expect(get(store)).toBe('newValue') + }) + + it('should remove value', () => { + const store = localStorageWritable('testKey') + store.set('newValue') + store.wipe() + + expect(get(store)).toBe(null) + }) + + it('should update the value correctly using update method', () => { + const store = localStorageWritable('testKey', 1) + store.update((n) => n! + 1) + + expect(get(store)).toBe(2) + }) +})