-
-
Notifications
You must be signed in to change notification settings - Fork 29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[WIP] refactor: Persisted
state with storage adapter
#118
base: main
Are you sure you want to change the base?
Changes from all commits
08c9f37
cbe3540
0a00998
82a4f94
63cc283
603685f
1f2ba1c
00c7ead
41f9495
df65dd4
312f252
2d54d08
e1b2947
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"runed": minor | ||
--- | ||
|
||
Add `PersistedState` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,198 @@ | ||
import { untrack } from "svelte"; | ||
import { BROWSER, DEV } from "esm-env"; | ||
import { addEventListener } from "$lib/internal/utils/event.js"; | ||
|
||
type Serializer<T> = { | ||
serialize: (value: T) => string; | ||
deserialize: (value: string) => T; | ||
}; | ||
|
||
type GetItemResult<T> = | ||
| { | ||
found: false; | ||
value: null; | ||
} | ||
| { | ||
found: true; | ||
value: T; | ||
}; | ||
|
||
interface StorageAdapter<T> { | ||
getItem: (key: string) => Promise<GetItemResult<T>>; | ||
setItem: (key: string, value: T) => Promise<void>; | ||
subscribe?: (callback: (key: string, newValue: GetItemResult<T>) => void) => () => void; | ||
} | ||
|
||
export class WebStorageAdapter<T> implements StorageAdapter<T> { | ||
#storage: Storage; | ||
#serializer: Serializer<T>; | ||
|
||
constructor({ | ||
storage, | ||
serializer = { | ||
serialize: JSON.stringify, | ||
deserialize: JSON.parse, | ||
}, | ||
}: { | ||
storage: Storage; | ||
serializer?: Serializer<T>; | ||
}) { | ||
this.#storage = storage; | ||
this.#serializer = serializer; | ||
} | ||
|
||
async getItem(key: string): Promise<GetItemResult<T>> { | ||
const value = this.#storage.getItem(key); | ||
return value !== null | ||
? { found: true, value: this.#serializer.deserialize(value) } | ||
: { found: false, value: null }; | ||
} | ||
|
||
async setItem(key: string, value: T): Promise<void> { | ||
const serializedValue = this.#serializer.serialize(value); | ||
this.#storage.setItem(key, serializedValue); | ||
} | ||
|
||
subscribe(callback: (key: string, newValue: GetItemResult<T>) => void): () => void { | ||
const listener = (event: StorageEvent) => { | ||
if (event.key === null) return; | ||
|
||
const result: GetItemResult<T> = | ||
event.newValue !== null | ||
? { found: true, value: this.#serializer.deserialize(event.newValue) } | ||
: { found: false, value: null }; | ||
|
||
callback(event.key, result); | ||
}; | ||
|
||
const unsubscribe = addEventListener(window, "storage", listener.bind(this)); | ||
|
||
return () => { | ||
unsubscribe(); | ||
}; | ||
} | ||
} | ||
|
||
async function setValueToStorage<T>({ | ||
key, | ||
value, | ||
storage, | ||
}: { | ||
key: string; | ||
value: T; | ||
storage: StorageAdapter<T> | null; | ||
}) { | ||
if (!storage) return; | ||
|
||
try { | ||
await storage.setItem(key, value); | ||
} catch (e) { | ||
if (!DEV) return; | ||
console.error( | ||
`Error when writing value from persisted store "${key}" to ${storage.constructor.name}`, | ||
e | ||
); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we only want to log here during dev. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In |
||
} | ||
|
||
function getWebStorageAdapterForStorageType<T>(storageType: StorageType): StorageAdapter<T> | null { | ||
if (!BROWSER) return null; | ||
|
||
const webStorageAdapterByStorageType = { | ||
local: new WebStorageAdapter<T>({ storage: localStorage }), | ||
session: new WebStorageAdapter<T>({ storage: sessionStorage }), | ||
}; | ||
|
||
return webStorageAdapterByStorageType[storageType]; | ||
} | ||
|
||
type StorageType = "local" | "session"; | ||
|
||
type PersistedStateOptions<T> = { | ||
/** | ||
* The storage type to use. | ||
* @defaultValue `local` | ||
*/ | ||
storage?: StorageType | StorageAdapter<T>; | ||
/** | ||
* Whether to synchronize the state with changes detected via the `subscribe` callback. | ||
* This allows the state to be updated in response to changes originating from various sources, | ||
* including other browser tabs or sessions when using web storage like localStorage or | ||
* sessionStorage. | ||
* | ||
* @defaultValue `true` | ||
*/ | ||
syncChanges?: boolean; | ||
}; | ||
|
||
/** | ||
* Creates reactive state that is persisted and synchronized across browser sessions and tabs using Web Storage. | ||
* @param key The unique key used to store the state in the storage. | ||
* @param initialValue The initial value of the state if not already present in the storage. | ||
* @param options Configuration options including storage type, serializer for complex data types, and whether to sync state changes across tabs. | ||
* | ||
* @see {@link https://runed.dev/docs/utilities/persisted-state} | ||
*/ | ||
export class Persisted<T> { | ||
#current = $state() as T; | ||
#isInitialized = $state(false); | ||
#initialValue: T; | ||
#key: string; | ||
#storageAdapter: StorageAdapter<T> | null; | ||
|
||
constructor(key: string, initialValue: T, options: PersistedStateOptions<T> = {}) { | ||
const { storage = "local", syncChanges = true } = options; | ||
|
||
this.#key = key; | ||
this.#initialValue = initialValue; | ||
this.#storageAdapter = | ||
typeof storage === "string" ? getWebStorageAdapterForStorageType(storage) : storage; | ||
|
||
$effect(() => { | ||
if (!this.#isInitialized) return; | ||
|
||
setValueToStorage({ | ||
key: this.#key, | ||
value: this.#current, | ||
storage: this.#storageAdapter, | ||
}); | ||
}); | ||
|
||
if (syncChanges) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we make this a reactive option, so synchronization can be turned on/off on the fly? Is there any value in having this capability? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I haven't stumbled upon such a scenario in a real application. However, sounds interesting. Maybe is good for those creative thinkers out there that may use local storage to sync multiple windows. This could be used from debugging to fancier connect/disconnect windows. |
||
$effect(() => { | ||
return untrack(() => { | ||
if (!this.#storageAdapter?.subscribe) return; | ||
|
||
const unsubscribe = this.#storageAdapter | ||
.subscribe(async (key, newValue) => { | ||
if (key !== this.#key || !newValue.found) return; | ||
this.#current = newValue.value; | ||
}) | ||
.bind(this); | ||
|
||
return unsubscribe; | ||
}); | ||
}); | ||
} | ||
|
||
this.init(); | ||
} | ||
|
||
async init() { | ||
if (!this.#storageAdapter) return; | ||
|
||
const valueFromStorage = await this.#storageAdapter.getItem(this.#key); | ||
|
||
this.#current = valueFromStorage.found ? valueFromStorage.value : this.#initialValue; | ||
this.#isInitialized = true; | ||
} | ||
|
||
get current(): T { | ||
return this.#isInitialized ? this.#current : this.#initialValue; | ||
} | ||
|
||
set current(newValue: T) { | ||
this.#current = newValue; | ||
this.#isInitialized ||= true; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,112 @@ | ||
import { describe, expect } from "vitest"; | ||
|
||
import { Persisted, WebStorageAdapter } from "./index.js"; | ||
import { testWithEffect } from "$lib/test/util.svelte.js"; | ||
|
||
const key = "test-key"; | ||
const initialValue = "test-value"; | ||
const existingValue = "existing-value"; | ||
|
||
describe("PersistedState", () => { | ||
beforeEach(() => { | ||
localStorage.clear(); | ||
sessionStorage.clear(); | ||
}); | ||
|
||
describe("localStorage", () => { | ||
testWithEffect("uses initial value if no persisted value is found", () => { | ||
const persistedState = new Persisted(key, initialValue); | ||
expect(persistedState.current).toBe(initialValue); | ||
}); | ||
|
||
testWithEffect("uses persisted value if it is found", async () => { | ||
localStorage.setItem(key, JSON.stringify(existingValue)); | ||
const persistedState = new Persisted(key, initialValue); | ||
await new Promise((resolve) => setTimeout(resolve, 0)); | ||
expect(persistedState.current).toBe(existingValue); | ||
}); | ||
|
||
testWithEffect("updates localStorage when current value changes", async () => { | ||
const persistedState = new Persisted(key, initialValue); | ||
expect(persistedState.current).toBe(initialValue); | ||
persistedState.current = "new-value"; | ||
expect(persistedState.current).toBe("new-value"); | ||
await new Promise((resolve) => setTimeout(resolve, 0)); | ||
expect(localStorage.getItem(key)).toBe(JSON.stringify("new-value")); | ||
}); | ||
}); | ||
|
||
describe("sessionStorage", () => { | ||
testWithEffect("uses initial value if no persisted value is found", () => { | ||
const persistedState = new Persisted(key, initialValue, { storage: "session" }); | ||
expect(persistedState.current).toBe(initialValue); | ||
}); | ||
|
||
testWithEffect("uses persisted value if it is found", async () => { | ||
sessionStorage.setItem(key, JSON.stringify(existingValue)); | ||
const persistedState = new Persisted(key, initialValue, { storage: "session" }); | ||
await new Promise((resolve) => setTimeout(resolve, 0)); | ||
expect(persistedState.current).toBe(existingValue); | ||
}); | ||
|
||
testWithEffect("updates sessionStorage when current value changes", async () => { | ||
const persistedState = new Persisted(key, initialValue, { storage: "session" }); | ||
expect(persistedState.current).toBe(initialValue); | ||
persistedState.current = "new-value"; | ||
expect(persistedState.current).toBe("new-value"); | ||
await new Promise((resolve) => setTimeout(resolve, 0)); | ||
expect(sessionStorage.getItem(key)).toBe(JSON.stringify("new-value")); | ||
}); | ||
}); | ||
|
||
describe("serializer", () => { | ||
testWithEffect("uses provided serializer", async () => { | ||
const isoDate = "2024-01-01T00:00:00.000Z"; | ||
const date = new Date(isoDate); | ||
|
||
const serializer = { | ||
serialize: (value: Date) => { | ||
return value.toISOString(); | ||
}, | ||
deserialize: (value: string) => { | ||
return new Date(value); | ||
}, | ||
}; | ||
const persistedState = new Persisted(key, date, { | ||
storage: new WebStorageAdapter({ | ||
storage: localStorage, | ||
serializer, | ||
}), | ||
}); | ||
// persistedState.current = date; | ||
// await new Promise((resolve) => setTimeout(resolve, 50)); | ||
expect(persistedState.current).toBe(date); | ||
await new Promise((resolve) => setTimeout(resolve, 0)); | ||
|
||
expect(persistedState.current).toBe(date); | ||
expect(localStorage.getItem(key)).toBe(isoDate); | ||
}); | ||
}); | ||
|
||
describe.skip("syncTabs", () => { | ||
testWithEffect("updates persisted value when local storage changes independently", async () => { | ||
// TODO: figure out why this test is failing even though it works in the browser. maybe jsdom doesn't emit storage events? | ||
// expect(true).toBe(true); | ||
// const persistedState = new PersistedState(key, initialValue); | ||
// localStorage.setItem(key, JSON.stringify("new-value")); | ||
// await new Promise((resolve) => setTimeout(resolve, 0)); | ||
// expect(persistedState.current).toBe("new-value"); | ||
}); | ||
|
||
// TODO: this test passes, but likely only because the storage event is not being emitted either way from jsdom | ||
testWithEffect( | ||
"does not update persisted value when local storage changes independently if syncTabs is false", | ||
async () => { | ||
const persistedState = new Persisted(key, initialValue, { syncChanges: false }); | ||
localStorage.setItem(key, JSON.stringify("new-value")); | ||
await new Promise((resolve) => setTimeout(resolve, 0)); | ||
expect(persistedState.current).toBe(initialValue); | ||
} | ||
); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./PersistedState.svelte.js"; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
--- | ||
title: Persisted | ||
description: | ||
Create reactive state that is persisted and synchronized across browser sessions and tabs using | ||
Web Storage. | ||
category: State | ||
--- | ||
|
||
<script> | ||
import Demo from '$lib/components/demos/persisted-state.svelte'; | ||
</script> | ||
|
||
## Demo | ||
|
||
<Demo /> | ||
|
||
## Usage | ||
|
||
`Persisted` allows for syncing and persisting state across browser sessions using `localStorage` or | ||
`sessionStorage`. Initialize `Persisted` by providing a unique key and an initial value for the | ||
state. | ||
|
||
```svelte | ||
<script lang="ts"> | ||
import { Persisted } from "runed"; | ||
|
||
const count = new Persisted("count", 0); | ||
</script> | ||
|
||
<div> | ||
<button onclick={() => count.current++}>Increment</button> | ||
<button onclick={() => count.current--}>Decrement</button> | ||
<button onclick={() => (count.current = 0)}>Reset</button> | ||
<p>Count: {count.current}</p> | ||
</div> | ||
``` | ||
|
||
`Persisted` also includes an `options` object. | ||
|
||
```ts | ||
{ | ||
storage: 'session', // Specifies whether to use local or session storage. Default is 'local'. | ||
syncTabs: false, // Indicates if changes should sync across tabs. Default is true. | ||
serializer: { | ||
serialize: superjson.stringify, // Custom serialization function. Default is JSON.stringify. | ||
deserialize: superjson.parse // Custom deserialization function. Default is JSON.parse. | ||
} | ||
} | ||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
<script lang="ts"> | ||
import { Persisted } from "runed"; | ||
import Button from "$lib/components/ui/button/button.svelte"; | ||
import DemoContainer from "$lib/components/demo-container.svelte"; | ||
import Callout from "$lib/components/callout.svelte"; | ||
|
||
const count = new Persisted("persisted-state-demo-count", 0); | ||
</script> | ||
|
||
<DemoContainer> | ||
<Button variant="brand" size="sm" onclick={() => count.current++}>Increment</Button> | ||
<Button variant="brand" size="sm" onclick={() => count.current--}>Decrement</Button> | ||
<Button variant="subtle" size="sm" onclick={() => (count.current = 0)}>Reset</Button> | ||
<pre class="my-4 bg-transparent p-0 font-mono">Count: {`${count.current}`}</pre> | ||
|
||
<Callout> | ||
You can refresh this page and/or open it in another tab to see the count state being persisted | ||
and synchronized across sessions and tabs. | ||
</Callout> | ||
</DemoContainer> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this type should be exported since it is part of the public options.
Also, what about making the contract asynchronous? This way one could write a storage adapter for IndexedDB.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@webJose totally agree. This PR is a bit old as we were dealing with a bunch of rapid Svelte changes at the time. I'd like to be able to support generic storage adapters and asynchronous contracts to support IndexedDB or any other storage solution one could think of.