From 7bcc221bbb5a6803501bd45cf605a0b6415e2377 Mon Sep 17 00:00:00 2001 From: Alexandre Tuleu Date: Mon, 14 Aug 2023 14:51:15 +0200 Subject: [PATCH] Creates a persistentStore --- src/lib/persistentStore.test.ts | 88 +++++++++++++++++++++++++++++++++ src/lib/persistentStore.ts | 74 +++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 src/lib/persistentStore.test.ts create mode 100644 src/lib/persistentStore.ts diff --git a/src/lib/persistentStore.test.ts b/src/lib/persistentStore.test.ts new file mode 100644 index 0000000..676c849 --- /dev/null +++ b/src/lib/persistentStore.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach, beforeAll, afterAll, afterEach } from 'vitest'; +import { __setMocked, persistentWritable } from './persistentStore'; +import { get } from 'svelte/store'; + +describe('persistentWritable', () => { + let _storage: Record = {}; + const storageMock = { + setItem: vi.fn(), + getItem: vi.fn() + }; + + beforeEach(() => { + vi.resetAllMocks(); + vi.stubGlobal('localStorage', storageMock); + _storage = {}; + storageMock.setItem.mockImplementation((key: string, value: string) => { + _storage[key] = value; + }); + storageMock.getItem.mockImplementation((key: string) => { + const res = _storage[key]; + if (res) { + return res; + } + return null; + }); + }); + + describe('in browser', () => { + beforeAll(() => { + __setMocked(true); + }); + + afterAll(() => { + __setMocked(false); + }); + + it('should not create multiple stores', () => { + const firstStore = persistentWritable('browser-multiple', 0); + + const secondStore = persistentWritable('browser-multiple', 1); + + expect(firstStore).toStrictEqual(secondStore); + expect(get(secondStore)).toEqual(0); + + expect(storageMock.getItem).toHaveBeenCalledOnce(); + expect(storageMock.getItem.mock.calls[0]).toEqual(['browser-multiple']); + expect(storageMock.setItem).toHaveBeenCalledTimes(0); + }); + + it('should update localStorage', () => { + const store = persistentWritable('browser-update', 'foo'); + expect(storageMock.getItem).toHaveBeenCalledOnce(); + store.set('bar'); + expect(storageMock.setItem.mock.calls[0]).toEqual(['browser-update', '"bar"']); + expect(_storage['browser-update']).toEqual('"bar"'); + }); + + it('should initialize from localStorage', () => { + _storage['browser-existing'] = '"bar"'; + const store = persistentWritable('browser-existing', 'foo'); + expect(get(store)).toEqual('bar'); + }); + }); + + describe('in server', () => { + beforeAll(() => { + __setMocked(false); + }); + + afterEach(() => { + expect(storageMock.setItem).toHaveBeenCalledTimes(0); + expect(storageMock.getItem).toHaveBeenCalledTimes(0); + }); + + it('should not create multiple stores', () => { + const firstStore = persistentWritable('server-multiple', 0); + const secondStore = persistentWritable('server-multiple', 0); + expect(firstStore).toStrictEqual(secondStore); + expect(get(secondStore)).toEqual(0); + }); + + it('should not update localStorage', () => { + const store = persistentWritable('server-update', 'foo'); + store.set('foobar'); + expect(get(store)).toEqual('foobar'); + }); + }); +}); diff --git a/src/lib/persistentStore.ts b/src/lib/persistentStore.ts new file mode 100644 index 0000000..504c1b3 --- /dev/null +++ b/src/lib/persistentStore.ts @@ -0,0 +1,74 @@ +import { browser } from '$app/environment'; +import { get, writable, type Writable } from 'svelte/store'; + +interface Storage { + getItem: (key: string) => string | null; + setItem: (key: string, value: string) => void; +} + +function initialValueFromStorage(storage: Storage, key: string, initialValue: Type): Type { + const serialized = storage.getItem(key); + if (serialized == null) { + return initialValue; + } + try { + return JSON.parse(serialized); + } catch (err) { + console.error(`could not parse '${serialized}' from storage: ${err}`); + return initialValue; + } +} + +function updateStorage(storage: Storage, key: string, value: Type) { + storage.setItem(key, JSON.stringify(value)); +} + +interface Options { + storage?: 'local' | 'session'; +} + +const stores: Record> = {}; + +let _mocked = false; + +// Only use it in unit tests. +export function __setMocked(mocked: boolean) { + _mocked = mocked; +} + +export function persistentWritable( + key: string, + initialValue: Type, + options?: Options +): Writable { + if (stores[key]) { + return stores[key] as Writable; + } + + let storage: Storage | undefined = undefined; + if (browser || _mocked) { + storage = (options?.storage || 'local') == 'local' ? localStorage : sessionStorage; + initialValue = initialValueFromStorage(storage, key, initialValue); + } + + const store: Writable = writable(initialValue); + + stores[key] = { + set(value: Type) { + if (storage) { + updateStorage(storage, key, value); + } + store.set(value); + }, + update(updater: (value: Type) => Type) { + const value = updater(get(store)); + if (storage) { + updateStorage(storage, key, value); + } + store.set(value); + }, + subscribe: store.subscribe + }; + + return stores[key] as Writable; +}