Skip to content
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

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
5 changes: 5 additions & 0 deletions .changeset/light-pianos-tie.md
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> {
Copy link
Contributor

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.

Copy link
Member

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.

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
);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we only want to log here during dev.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In svelte-persisted-store there is an onerror option (or similar) that accepts a function. This is nice for people submitting logs to structured log destinations, like Seq. The other options can be to "warn" or to "ignore"? Some of us hate packages soiling the console output.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In svelte-persisted-store there is an onerror option (or similar) that accepts a function. This is nice for people submitting logs to structured log destinations, like Seq. The other options can be to "warn" or to "ignore"? Some of us hate packages soiling the console output.

}

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) {
Copy link
Member

Choose a reason for hiding this comment

The 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?

Copy link
Contributor

Choose a reason for hiding this comment

The 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);
}
);
});
});
1 change: 1 addition & 0 deletions packages/runed/src/lib/utilities/PersistedState/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./PersistedState.svelte.js";
1 change: 1 addition & 0 deletions packages/runed/src/lib/utilities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ export * from "./AnimationFrames/index.js";
export * from "./useIntersectionObserver/index.js";
export * from "./IsFocusWithin/index.js";
export * from "./FiniteStateMachine/index.js";
export * from "./PersistedState/index.js";
49 changes: 49 additions & 0 deletions sites/docs/content/utilities/persisted-state.md
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.
}
}
```
20 changes: 20 additions & 0 deletions sites/docs/src/lib/components/demos/persisted-state.svelte
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>
Loading