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

feat: add PersistedState #113

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,169 @@
import { addEventListener } from "$lib/internal/utils/event.js";

type Serializer<T> = {
serialize: (value: T) => string;
deserialize: (value: string) => T;
};

type GetValueFromStorageResult<T> =
| {
found: true;
value: T;
}
| {
found: false;
value: null;
};
function getValueFromStorage<T>({
key,
storage,
serializer,
}: {
key: string;
storage: Storage | null;
serializer: Serializer<T>;
}): GetValueFromStorageResult<T> {
if (!storage) {
return { found: false, value: null };
}

const value = storage.getItem(key);
if (value === null) {
return { found: false, value: null };
}

try {
return {
found: true,
value: serializer.deserialize(value),
};
} catch (e) {
console.error(`Error when parsing ${value} from persisted store "${key}"`, e);
return {
found: false,
value: null,
};
}
}

function setValueToStorage<T>({
key,
value,
storage,
serializer,
}: {
key: string;
value: T;
storage: Storage | null;
serializer: Serializer<T>;
}) {
if (!storage) {
return;
}

try {
storage.setItem(key, serializer.serialize(value));
} catch (e) {
console.error(`Error when writing value from persisted store "${key}" to ${storage}`, e);
}
}

type StorageType = "local" | "session";

function getStorage(storageType: StorageType): Storage | null {
if (typeof window === "undefined") {
return null;
}

const storageByStorageType = {
local: localStorage,
session: sessionStorage,
} satisfies Record<StorageType, Storage>;

return storageByStorageType[storageType];
}

type PersistedStateOptions<T> = {
/** The storage type to use. Defaults to `local`. */
storage?: StorageType;
/** The serializer to use. Defaults to `JSON.stringify` and `JSON.parse`. */
serializer?: Serializer<T>;
/** Whether to sync with the state changes from other tabs. Defaults to `true`. */
syncTabs?: 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 PersistedState<T> {
#current = $state() as T;
#key: string;
#storage: Storage | null;
#serializer: Serializer<T>;

constructor(key: string, initialValue: T, options: PersistedStateOptions<T> = {}) {
const {
storage: storageType = "local",
serializer = { serialize: JSON.stringify, deserialize: JSON.parse },
syncTabs = true,
} = options;

this.#key = key;
this.#storage = getStorage(storageType);
this.#serializer = serializer;

const valueFromStorage = getValueFromStorage({
key: this.#key,
storage: this.#storage,
serializer: this.#serializer,
});

this.#current = valueFromStorage.found ? valueFromStorage.value : initialValue;

$effect(() => {
setValueToStorage({
key: this.#key,
value: this.#current,
storage: this.#storage,
serializer: this.#serializer,
});
});

$effect(() => {
if (!syncTabs) {
return;
}

return addEventListener(window, "storage", this.#handleStorageEvent.bind(this));
});
}

#handleStorageEvent(event: StorageEvent) {
if (event.key !== this.#key || !this.#storage) {
return;
}

const valueFromStorage = getValueFromStorage({
key: this.#key,
storage: this.#storage,
serializer: this.#serializer,
});

if (valueFromStorage.found) {
this.#current = valueFromStorage.value;
}
}

get current(): T {
return this.#current;
}

set current(newValue: T) {
this.#current = newValue;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect } from "vitest";

import { PersistedState } 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 PersistedState(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 PersistedState(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 PersistedState(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 PersistedState(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 PersistedState(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 PersistedState(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) => value.toISOString(),
deserialize: (value: string) => new Date(value),
};
const persistedState = new PersistedState(key, date, { serializer });
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 PersistedState(key, initialValue, { syncTabs: 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: PersistedState
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

`PersistedState` allows for syncing and persisting state across browser sessions using
`localStorage` or `sessionStorage`. Initialize `PersistedState` by providing a unique key and an
initial value for the state.

```svelte
<script lang="ts">
import { PersistedState } from "runed";

const count = new PersistedState("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>
```

`PersistedState` 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 { PersistedState } 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 PersistedState("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>