Skip to content

Commit 783357d

Browse files
committed
chore: Clean up persisted state utility
1 parent b86361c commit 783357d

4 files changed

Lines changed: 84 additions & 54 deletions

File tree

src/lib/state/classes/local-storage-state.svelte.ts

Lines changed: 0 additions & 36 deletions
This file was deleted.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { browser } from '$app/environment';
2+
3+
export type StorageType = 'local' | 'session';
4+
5+
export interface LocalStorageStateOptions {
6+
/** Which Web Storage area to persist to. Defaults to `'local'`. */
7+
storage?: StorageType;
8+
}
9+
10+
const mockStorage: Storage = {
11+
length: 0,
12+
clear: () => {},
13+
getItem: () => null,
14+
key: () => null,
15+
removeItem: () => {},
16+
setItem: () => {},
17+
};
18+
19+
export class PersistedState<T> {
20+
readonly #key: string;
21+
readonly defaultValue: T;
22+
readonly #storage: Storage;
23+
#value: T;
24+
25+
constructor(key: string, defaultValue: T, options: LocalStorageStateOptions = {}) {
26+
this.#key = key;
27+
this.defaultValue = defaultValue;
28+
this.#storage = browser
29+
? options.storage === 'session'
30+
? sessionStorage
31+
: localStorage
32+
: mockStorage;
33+
34+
this.#value = $state<T>(this.deserialize(this.#storage.getItem(key)));
35+
36+
// Only localStorage fires storage events across tabs; sessionStorage is tab-scoped.
37+
if (browser && options.storage !== 'session') {
38+
window.addEventListener('storage', this.#onStorage);
39+
}
40+
}
41+
42+
#onStorage = (event: StorageEvent) => {
43+
if (event.storageArea !== this.#storage) return;
44+
if (event.key !== this.#key) return;
45+
this.#update(this.deserialize(event.newValue));
46+
};
47+
48+
#update(value: T) {
49+
this.#value = value;
50+
this.onChange(value);
51+
}
52+
53+
get value() {
54+
return this.#value;
55+
}
56+
set value(value: T) {
57+
this.#update(value);
58+
this.#storage.setItem(this.#key, this.serialize(value));
59+
}
60+
61+
reset() {
62+
this.#update(this.defaultValue);
63+
this.#storage.removeItem(this.#key);
64+
}
65+
66+
/** Hook called whenever the value changes, regardless of source (setter, reset, or storage event). */
67+
protected onChange(_value: T): void {}
68+
69+
protected serialize(value: T): string {
70+
return JSON.stringify(value);
71+
}
72+
73+
protected deserialize(raw: string | null): T {
74+
if (raw === null) return this.defaultValue;
75+
return JSON.parse(raw) as T;
76+
}
77+
}

src/lib/state/color-scheme-state.svelte.ts

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { isString } from '$lib/typeguards';
2-
import { LocalStorageState } from './classes/local-storage-state.svelte';
2+
import { PersistedState } from './classes/persisted-state.svelte';
33

44
export enum ColorScheme {
55
Dark = 'dark',
@@ -50,21 +50,16 @@ function setDarkMode(preference: ColorScheme) {
5050
document.documentElement.classList.toggle('dark', resolveDarkMode(preference));
5151
}
5252

53-
class ColorSchemeState extends LocalStorageState<ColorScheme> {
53+
class ColorSchemeState extends PersistedState<ColorScheme> {
5454
constructor() {
5555
super('theme', ColorScheme.System);
5656
}
5757

58-
override set value(v: ColorScheme) {
59-
super.value = v;
60-
setDarkMode(v);
58+
protected override onChange(v: ColorScheme) {
59+
setDarkMode(isColorSchemeEnum(v) ? v : this.defaultValue);
6160
}
6261

63-
override get value() {
64-
return super.value;
65-
}
66-
67-
protected override deserialize(raw: string): ColorScheme {
62+
protected override deserialize(raw: string | null): ColorScheme {
6863
return isColorSchemeEnum(raw) ? raw : this.defaultValue;
6964
}
7065

@@ -88,10 +83,4 @@ export function initializeColorScheme() {
8883
window
8984
.matchMedia('(prefers-color-scheme: dark)')
9085
.addEventListener('change', handleMediaQueryChange);
91-
92-
window.addEventListener('storage', (event) => {
93-
if (event.key !== 'theme') return;
94-
95-
setDarkMode(isColorSchemeEnum(event.newValue) ? event.newValue : ColorScheme.System);
96-
});
9786
}

src/routes/+layout.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import Sidebar from './Sidebar.svelte';
1212
import '../app.css';
1313
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
14-
import { LocalStorageState } from '$lib/state/classes/local-storage-state.svelte';
14+
import { PersistedState } from '$lib/state/classes/persisted-state.svelte';
1515
import DialogManager from '$lib/components/dialog-manager/dialog-manager.svelte';
1616
1717
interface Props {
@@ -23,7 +23,7 @@
2323
let meta = $derived(buildMetaData(page.url));
2424
2525
const mobile = new IsMobile();
26-
const sidebarOpen = new LocalStorageState('sidebarOpen', false);
26+
const sidebarOpen = new PersistedState('sidebarOpen', false);
2727
const isOpen = $derived(mobile.current ? false : sidebarOpen.value);
2828
</script>
2929

0 commit comments

Comments
 (0)