Skip to content
Draft
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/eleven-buttons-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solid-primitives/storage": major
---

**`makePersisted`** - simplify setter, Solid 2.0 adaption, simpler types using function overloads
6 changes: 4 additions & 2 deletions packages/storage/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@
},
"peerDependencies": {
"@tauri-apps/plugin-store": "*",
"solid-js": "^1.6.12"
"@solidjs/web": "^2.0.0-beta.7",
"solid-js": "^2.0.0-beta.7"
},
"peerDependenciesMeta": {
"solid-start": {
Expand All @@ -92,6 +93,7 @@
}
},
"devDependencies": {
"solid-js": "^1.9.7"
"solid-js": "2.0.0-beta.7",
"@solidjs/web": "2.0.0-beta.7"
}
}
2 changes: 1 addition & 1 deletion packages/storage/src/cookies.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getRequestEvent, isServer, type RequestEvent } from "solid-js/web";
import { getRequestEvent, isServer, type RequestEvent } from "@solidjs/web";
import { type SyncStorageWithOptions } from "./index.js";
import { addWithOptionsMethod, addClearMethod } from "./tools.js";

Expand Down
100 changes: 46 additions & 54 deletions packages/storage/src/persisted.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import type { Accessor, Setter, Signal } from "solid-js";
import { createUniqueId, untrack } from "solid-js";
import { isServer, isDev } from "solid-js/web";
import type { SetStoreFunction, Store } from "solid-js/store";
import { reconcile } from "solid-js/store";
import type { Signal, StoreSetter, Store } from "solid-js";
import { createUniqueId, latest, untrack, reconcile, DEV } from "solid-js";

export type SyncStorage = {
getItem: (key: string) => string | null;
Expand Down Expand Up @@ -70,17 +67,7 @@
storageOptions?: O;
});

export type SignalInput = Signal<any> | [Store<any>, SetStoreFunction<any>];

export type SignalType<S extends SignalInput> =
S extends Signal<infer T> ? T : S extends [Store<infer T>, SetStoreFunction<infer T>] ? T : never;

export type PersistedState<S extends SignalInput> =
S extends Signal<infer T>
? [get: Accessor<T>, set: Setter<T>, init: Promise<string> | string | null]
: S extends [Store<infer T>, SetStoreFunction<infer T>]
? [get: Store<T>, set: SetStoreFunction<T>, init: Promise<string> | string | null]
: never;
export type PersistedState<S> = S extends [any, any] ? [...S, Promise<string> | string | null] : never;

/**
* Persists a signal, store or similar API
Expand All @@ -98,30 +85,38 @@
* value of the signal or store unless overwritten. Overwriting a signal with `null` or `undefined` will remove the
* item from the storage.
*
* @param {Signal<T> | [get: Store<T>, set: SetStoreFunction<T>]} signal - The signal or store to be persisted.
* @param {Signal<T> | [get: Store<T>, set: St<T>]} signal - The signal or store to be persisted.
* @param {PersistenceOptions<T, O>} options - The options for persistence.
* @returns {PersistedState<T>} - The persisted signal or store.
*/
export function makePersisted<S extends SignalInput>(
signal: S,
options?: PersistenceOptions<SignalType<S>, undefined>,
): PersistedState<S>;
export function makePersisted<S extends SignalInput, O extends Record<string, any>>(
signal: S,
options: PersistenceOptions<SignalType<S>, O>,
): PersistedState<S>;
export function makePersisted<T>(
signal: Signal<T>,
options?: PersistenceOptions<T, undefined>,
): PersistedState<Signal<T>>;
export function makePersisted<T>(
signal: [Store<T>, StoreSetter<T>],
options?: PersistenceOptions<T, undefined>,
): PersistedState<[Store<T>, StoreSetter<T>]>;
export function makePersisted<
T,
O extends Record<string, any>,
>(signal: Signal<T>, options: PersistenceOptions<T, O>): PersistedState<Signal<T>>;
export function makePersisted<
S extends SignalInput,
T,
O extends Record<string, any>,
>(signal: [Store<T>, StoreSetter<T>], options: PersistenceOptions<T, O>): PersistedState<[Store<T>, StoreSetter<T>]>;
export function makePersisted<
T,
O extends Record<string, any> | undefined,
T = SignalType<S>,
S extends Signal<T> | [Store<T>, StoreSetter<T>],
>(
signal: S,
options: PersistenceOptions<T, O> = {} as PersistenceOptions<T, O>,
): PersistedState<S> {
const storage = options.storage || (globalThis.localStorage as Storage | undefined);
const name = options.name || `storage-${createUniqueId()}`;
if (!storage) {
return [signal[0], signal[1], null] as PersistedState<S>;
return [signal[0], signal[1], null] as unknown as PersistedState<S>;
}
const storageOptions = (options as unknown as { storageOptions: O }).storageOptions;
const serialize: (data: T) => string = options.serialize || JSON.stringify.bind(JSON);
Expand All @@ -135,16 +130,16 @@
(signal[1] as any)(() => value);
} catch (e) {
// eslint-disable-next-line no-console
if (isDev) console.warn(e);
if (DEV) console.warn(e);
}
}
: (data: string) => {
try {
const value = deserialize(data);
(signal[1] as any)(reconcile(value));
(signal[1] as any)(reconcile(value, () => undefined));
} catch (e) {
// eslint-disable-next-line no-console
if (isDev) console.warn(e);
if (DEV) console.warn(e);
}
};
let unchanged = true;
Expand All @@ -158,7 +153,7 @@
options.sync[0]((data: PersistenceSyncData) => {
if (
data.key !== name ||
(!isServer && (data.url || globalThis.location.href) !== globalThis.location.href) ||
(!globalThis.window && (data.url || globalThis.location.href) !== globalThis.location.href) ||
data.newValue === serialize(untrack(get))
) {
return;
Expand All @@ -167,28 +162,25 @@
});
}

const getter = typeof signal[0] === "function" ? signal[0] as () => T : () => signal[0] as T;
return [
signal[0],
typeof signal[0] === "function"
? (value?: T | ((prev: T) => T)) => {
const output = (signal[1] as Setter<T>)(value as any);
const serialized: string | null | undefined =
value != null ? serialize(output) : (value as null | undefined);
options.sync?.[1](name, serialized);
if (serialized != null) storage.setItem(name, serialized, storageOptions);
else storage.removeItem(name, storageOptions);
unchanged = false;
return output;
}
: (...args: any[]) => {
(signal[1] as any)(...args);
const value = serialize(untrack(() => signal[0]));
options.sync?.[1](name, value);
storage.setItem(name, value, storageOptions);
unchanged = false;
},
signal[0],
(value: any) => untrack(() => {
const output = signal[1](value);
const next = latest(getter);

Check failure on line 170 in packages/storage/src/persisted.ts

View workflow job for this annotation

GitHub Actions / build-test

packages/storage/test/persisted.test.ts > makePersisted > persists a signal in an async storage

TypeError: latest is not a function ❯ packages/storage/src/persisted.ts:170:20 ❯ Module.untrack node_modules/.pnpm/solid-js@1.9.7/node_modules/solid-js/dist/dev.js:469:58 ❯ packages/storage/src/persisted.ts:168:21 ❯ packages/storage/test/persisted.test.ts:119:5

Check failure on line 170 in packages/storage/src/persisted.ts

View workflow job for this annotation

GitHub Actions / build-test

packages/storage/test/persisted.test.ts > makePersisted > removes a nulled signal's storage item

TypeError: latest is not a function ❯ packages/storage/src/persisted.ts:170:20 ❯ Module.untrack node_modules/.pnpm/solid-js@1.9.7/node_modules/solid-js/dist/dev.js:469:58 ❯ packages/storage/src/persisted.ts:168:21 ❯ packages/storage/test/persisted.test.ts:97:5

Check failure on line 170 in packages/storage/src/persisted.ts

View workflow job for this annotation

GitHub Actions / build-test

packages/storage/test/persisted.test.ts > makePersisted > persists a signal

TypeError: latest is not a function ❯ packages/storage/src/persisted.ts:170:20 ❯ Module.untrack node_modules/.pnpm/solid-js@1.9.7/node_modules/solid-js/dist/dev.js:469:58 ❯ packages/storage/src/persisted.ts:168:21 ❯ packages/storage/test/persisted.test.ts:60:5
if (value == null) {
storage.removeItem(name, storageOptions);
options.sync?.[1](name, null);
} else {
const serialized = serialize(next);
storage.setItem(name, serialized, storageOptions);
options.sync?.[1](name, serialized);
}
unchanged = false;
return output;
}),
init,
] as PersistedState<S>;
] as unknown as PersistedState<S>;
}

/**
Expand Down Expand Up @@ -222,7 +214,7 @@
/**
* wsSync - syncronize persisted storage via web socket
*/
export const wsSync = (ws: WebSocket, warnOnError: boolean = isDev): PersistenceSyncAPI => [
export const wsSync = (ws: WebSocket, warnOnError: boolean = !!DEV): PersistenceSyncAPI => [
(subscriber: PersistenceSyncCallback) =>
ws.addEventListener("message", (ev: MessageEvent) => {
try {
Expand All @@ -241,7 +233,7 @@
key,
newValue,
timeStamp: +new Date(),
...(isServer ? {} : { url: location.href }),
...(globalThis.window ? { url: location.href } : {}),
}),
),
];
Expand Down
44 changes: 32 additions & 12 deletions packages/storage/test/persisted.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import { createSignal } from "solid-js";
import { createStore } from "solid-js/store";
import { action, createSignal, createStore, createOptimistic, latest, refresh, type Signal } from "solid-js";
import { makePersisted } from "../src/persisted.js";
import { type AsyncStorage } from "../src/index.js";

Expand Down Expand Up @@ -60,33 +59,54 @@
});
setSignal("persisted");
expect(mockStorage.getItem("test1")).toBe('"persisted"');
expect(signal()).toBe("persisted");
expect(latest(signal)).toBe("persisted");
});

// currently, optimistic is broken
it.skip("persists an optimistic signal", async () => {
const DataServer = {
data: "server",
get: () => Promise.resolve(DataServer.data),
set: (next: string) => new Promise((res) => setTimeout(() => res(DataServer.data = next), 50)),
};
const [optimistic, updateOptimistic] = createOptimistic(() => DataServer.get(), "initial");
const setOptimistic = action(function*(data) {
updateOptimistic(data);
yield DataServer.set(data);
});
const [signal, setSignal] = makePersisted(
[optimistic, setOptimistic],
{ storage: mockStorage, name: "test1" }
);
await setSignal("persisted");
expect(mockStorage.getItem("test1")).toBe('"persisted"');
expect(latest(signal)).toBe("persisted")
})

it("reads the persisted value from a synchronous storage into the signal", () => {
mockStorage.setItem("test2", '"persistence"');
const [signal] = makePersisted(createSignal(), { storage: mockStorage, name: "test2" });
expect(signal()).toBe("persistence");
expect(latest(signal)).toBe("persistence");

Check failure on line 89 in packages/storage/test/persisted.test.ts

View workflow job for this annotation

GitHub Actions / build-test

packages/storage/test/persisted.test.ts > makePersisted > reads the persisted value from a synchronous storage into the signal

TypeError: latest is not a function ❯ packages/storage/test/persisted.test.ts:89:12
});

it("removes a nulled signal's storage item", () => {
const [signal, setSignal] = makePersisted(createSignal(), {
const [signal, setSignal] = makePersisted(createSignal<string>(), {
storage: mockStorage,
name: "test3",
});
setSignal("test");
expect(mockStorage.getItem("test3")).toBe('"test"');
expect(signal()).toBe("test");
expect(latest(signal)).toBe("test");
setSignal(undefined);
expect(mockStorage.getItem("test3")).toBeNull();
});

it("persists a store", () => {
const [store, setStore] = makePersisted(createStore({ test: "test" }), {

Check failure on line 105 in packages/storage/test/persisted.test.ts

View workflow job for this annotation

GitHub Actions / build-test

packages/storage/test/persisted.test.ts > makePersisted > persists a store

TypeError: createStore is not a function ❯ packages/storage/test/persisted.test.ts:105:45
storage: mockStorage,
name: "test4",
});
setStore("test", "persisted");
setStore((s) => { s.test = "persisted" });
expect(store.test).toBe("persisted");
expect(mockStorage.getItem("test4")).toBe(JSON.stringify({ test: "persisted" }));
});
Expand All @@ -97,7 +117,7 @@
name: "test5",
});
setSignal("async");
expect(signal()).toBe("async");
expect(latest(signal)).toBe("async");
expect(await mockAsyncStorage.getItem("test5")).toBe('"async"');
});

Expand All @@ -108,7 +128,7 @@
name: "test6",
});
await Promise.resolve();
expect(signal()).toBe("predefined");
expect(latest(signal)).toBe("predefined");

Check failure on line 131 in packages/storage/test/persisted.test.ts

View workflow job for this annotation

GitHub Actions / build-test

packages/storage/test/persisted.test.ts > makePersisted > reads the persisted value from an asynchronous storage into the signal

TypeError: latest is not a function ❯ packages/storage/test/persisted.test.ts:131:12
setSignal("overwritten");
await Promise.resolve();
expect(await mockAsyncStorage.getItem("test6")).toBe('"overwritten"');
Expand All @@ -125,15 +145,15 @@
storage: slowMockAsyncStorage,
name: "test7",
});
expect(signal()).toBe("init");
expect(latest(signal)).toBe("init");

Check failure on line 148 in packages/storage/test/persisted.test.ts

View workflow job for this annotation

GitHub Actions / build-test

packages/storage/test/persisted.test.ts > makePersisted > does not overwrite a written value from a slower async storage

TypeError: latest is not a function ❯ packages/storage/test/persisted.test.ts:148:12
setSignal("overwritten");
resolve("persisted");
expect(signal()).toBe("overwritten");
expect(latest(signal)).toBe("overwritten");
});

it("exposes the initial value as third part of the return tuple", () => {
const anotherMockAsyncStorage = { ...mockAsyncStorage };
const promise = Promise.resolve("init");
const promise = Promise.resolve('"init"');
anotherMockAsyncStorage.getItem = () => promise;
const [_signal, _setSignal, init] = makePersisted(createSignal("default"), {
storage: anotherMockAsyncStorage,
Expand Down
Loading
Loading