Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9404090
upgrade: storage package upgrade for Solid 2.0
atk Apr 21, 2026
ef953e9
Deep changes amongst a number of pritmivies
davedbase Apr 21, 2026
4a2b0ae
Added additional adjustments and migrations for rootless, static-stor…
davedbase Apr 21, 2026
6a2a17e
Remove deprecated HTMLFrameSetElement type
davedbase Apr 21, 2026
383da78
Update to latest solid beta, ensure tests run and ran formatting
davedbase May 2, 2026
0ceb475
Adds Solid 2.0 columns to README page
davedbase May 3, 2026
25d10f8
Oops add different method for checking Solid 2
davedbase May 3, 2026
c85f0ac
port to tanstack start v1
birkskyum Apr 11, 2026
3e602a0
fix import to manifest
birkskyum Apr 11, 2026
d72c4c3
footer
birkskyum Apr 11, 2026
2cb49ea
add vite preview
birkskyum Apr 11, 2026
c0f20d7
improe handling of not found
birkskyum Apr 11, 2026
a96bfb2
footer
birkskyum Apr 11, 2026
3d132af
improve not found
birkskyum Apr 11, 2026
31247bb
clear suspense
birkskyum Apr 11, 2026
9e5eaf5
fix not found
birkskyum Apr 11, 2026
e00599f
filter prerender
birkskyum Apr 11, 2026
aa218ec
relative link for playground
birkskyum Apr 11, 2026
ecb0763
replace solid tippy with floating-ui/dom
birkskyum Apr 13, 2026
37d3d4f
pr feedback
birkskyum Apr 27, 2026
417e029
Format
davedbase Apr 27, 2026
224d8fa
Fix lock
davedbase May 4, 2026
871aabf
Update
davedbase May 4, 2026
153c169
upgrade: storage package upgrade for Solid 2.0
atk Apr 21, 2026
19b9ee8
fix: attempt to fix optimistic (WIP)
atk May 8, 2026
98846a5
fix: optimistic, stores, tests (todo: types)
atk May 13, 2026
8d1ef6f
fix: types
atk May 13, 2026
aea1e1f
feat: update to 2.0.0-beta.12
atk May 13, 2026
2df91dc
Merge branch 'next' into update/v2/storage
atk May 13, 2026
61baf95
Merge branch 'next'
atk May 29, 2026
ba4f617
Merge branch 'next' into update/v2/storage
davedbase May 29, 2026
7719d6f
Merge branch 'next' into update/v2/storage
davedbase Jun 2, 2026
aff1087
Added stories
davedbase Jun 2, 2026
524e2f0
Remove old dev example
davedbase Jun 2, 2026
1c35ccf
Added more stories
davedbase Jun 2, 2026
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
18 changes: 0 additions & 18 deletions packages/storage/dev/index.tsx

This file was deleted.

10 changes: 4 additions & 6 deletions packages/storage/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@
}
},
"scripts": {
"dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts",
"build": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/build.ts",
"vitest": "vitest -c ../../configs/vitest.config.ts",
"test": "pnpm run vitest",
Expand All @@ -82,17 +81,16 @@
},
"peerDependencies": {
"@tauri-apps/plugin-store": "*",
"solid-js": "^1.6.12"
"@solidjs/web": "^2.0.0-beta.14",
"solid-js": "^2.0.0-beta.14"
},
"peerDependenciesMeta": {
"solid-start": {
"optional": true
},
"@tauri-apps/plugin-store": {
"optional": true
}
},
"devDependencies": {
"solid-js": "^1.9.7"
"solid-js": "2.0.0-beta.14",
"@solidjs/web": "2.0.0-beta.14"
}
}
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
146 changes: 77 additions & 69 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 { action, createUniqueId, latest, untrack, reconcile, snapshot, DEV } from "solid-js";
Comment on lines +1 to +2
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does solid-js 2.0.0-beta.14 export reconcile and snapshot from the root "solid-js" package entry?

💡 Result:

No, solid-js version 2.0.0-beta.14 does not export reconcile or snapshot from the root solid-js package entry [1][2][3]. In SolidJS, reconcile and snapshot are utilities associated with the store system and are exported from the solid-js/store module [2][3]. While Solid 2.0 involves significant changes to the package structure—including the removal of explicit solid-js/web and solid-js/store subpaths in favor of a consolidated approach in some contexts—these specific store-related utilities continue to reside within the store-specific API surface, not the root export [1][2]. Developers should continue to import these from solid-js/store [2][3].

Citations:


Fix incorrect imports of reconcile/snapshot from the Solid root

packages/storage/src/persisted.ts imports reconcile and snapshot from "solid-js" (lines 1-2), but solid-js@2.0.0-beta.14 does not export reconcile/snapshot from the package root—these are store-related APIs exposed from solid-js/store. Update the imports to use the correct module to avoid build-time resolution errors.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/storage/src/persisted.ts` around lines 1 - 2, The import list
currently pulls reconcile and snapshot from "solid-js" but those APIs are
exported from "solid-js/store"; update the module imports so that reconcile and
snapshot are imported from "solid-js/store" while keeping Signal, StoreSetter,
Store and the other runtime helpers (action, createUniqueId, latest, untrack,
DEV) from "solid-js" as appropriate; adjust the import statements to reference
reconcile and snapshot from "solid-js/store" (look for uses of reconcile and
snapshot in this file to verify no other changes are needed).


export type SyncStorage = {
getItem: (key: string) => string | null;
Expand Down Expand Up @@ -58,70 +55,81 @@ export type PersistenceSyncAPI = [
update: (key: string, value: string | null | undefined) => void,
];

export type PersistenceOptions<T, O extends Record<string, any> | undefined> = {
export type PersistenceOptions<
S extends Signal<any> | [Store<any>, StoreSetter<any>] | readonly [Store<any>, StoreSetter<any>],
O extends Record<string, any> | undefined,
T = S extends Signal<infer T> ? T : S extends [Store<infer T>, StoreSetter<infer T>] ? T : never
> = {
name?: string;
serialize?: (data: T) => string;
deserialize?: (data: string) => T;
sync?: PersistenceSyncAPI;
action?: (signal: S) => Parameters<typeof action>[0];
} & (undefined extends O
? { storage?: SyncStorage | AsyncStorage }
: {
storage: SyncStorageWithOptions<O> | AsyncStorageWithOptions<O>;
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> = S extends [any, any] ? [...S, Promise<string> | string | null] : 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 StoreTuple<T> = [Store<T>, StoreSetter<T>];

/**
* Persists a signal, store or similar API
* ```ts
* const [getter, setter] = makePersisted(createSignal("data"), options);
* const options = {
* storage: cookieStorage, // can be any synchronous or asynchronous storage
* storageOptions: { ... }, // for storages with options, otherwise not needed
* name: "solid-data", // optional
* serialize: (value: string) => value, // optional
* deserialize: (data: string) => data, // optional
* };
* ```
* Can be used with `createSignal` or `createStore`. The initial value from the storage will overwrite the initial
* value of the signal or store unless overwritten. Overwriting a signal with `null` or `undefined` will remove the
* item from the storage.
* ```ts
* const [getter, setter] = makePersisted(createSignal("data"), options);
* const options = {
* storage: cookieStorage, // can be any synchronous or asynchronous storage
* storageOptions: { ... }, // for storages with options, otherwise not needed
* name: "solid-data", // optional
* serialize: (value: string) => value, // optional
* deserialize: (data: string) => data, // optional
* action: (setter: Setter<T>) => Setter<T> // optional, to be put inside action
* };
* ```
* Can be used with `createSignal`, `createStore`, `createOptimistic`, or `createOptimisticStore` (for the latter
* two, use options.action to wrap the setter). The initial value from the storage will overwrite the initial
* 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 {PersistenceOptions<T, O>} options - The options for persistence.
* @param {Signal<T> | [get: Store<T>, set: StoreSetter<T>]} signal - The signal or store to be persisted.
* @param {PersistenceOptions<Signal<T> | [get: Store<T>, set StoreSetter<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<Signal<T>, undefined>,
): PersistedState<Signal<T>>;
export function makePersisted<T>(
signal: StoreTuple<T>,
options?: PersistenceOptions<StoreTuple<T>, undefined>,
): PersistedState<StoreTuple<T>>;
export function makePersisted<
T,
O extends Record<string, any>
>(signal: Signal<T>, options: PersistenceOptions<Signal<T>, O>): PersistedState<Signal<T>>;
export function makePersisted<
S extends SignalInput,
T,
O extends Record<string, any>
>(signal: StoreTuple<T>, options: PersistenceOptions<StoreTuple<T>, O>): PersistedState<StoreTuple<T>>;
export function makePersisted<
T,
O extends Record<string, any> | undefined,
T = SignalType<S>,
S extends Signal<T> | StoreTuple<T>,
>(
signal: S,
options: PersistenceOptions<T, O> = {} as PersistenceOptions<T, O>,
options: PersistenceOptions<S, O> = {} as PersistenceOptions<S, O>,
): PersistedState<S> {
const storage = options.storage || (globalThis.localStorage as Storage | undefined);
const name = options.name || `storage-${createUniqueId()}`;
const actionFn = options.action && options.action(signal);
if (actionFn) {
(signal as any)[1] = action(actionFn) as unknown as S[1];
}
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);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I was confused by the bind, neither method uses this...so isn't this just allocating a pointless closure? Maybe I missed something? lol

Expand All @@ -135,16 +143,16 @@ export function makePersisted<
(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);
}
};
Comment on lines 149 to 157
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What is the second argument signature of reconcile in solid-js 2.0 (ReconcileOptions)?

💡 Result:

In SolidJS 2.0, the reconcile function accepts a second, optional argument that is an options object [1]. The signature for this options object is as follows [1]: { key?: string | null; merge?: boolean; } The key property specifies the property name used to match items in arrays during reconciliation (e.g., "id"), while the merge property determines whether reconciliation pushes updates deeper into the structure (true) or prefers replacing non-matching branches (false) [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '120,190p' packages/storage/src/persisted.ts | nl -ba

Repository: solidjs-community/solid-primitives

Length of output: 121


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== persisted.ts (approx lines 130-180) ==="
sed -n '130,180p' packages/storage/src/persisted.ts | awk '{printf "%5d  %s\n", NR+129, $0}'

echo
echo "=== reconcile usages in persisted.ts ==="
rg -n "reconcile\\(" packages/storage/src/persisted.ts

echo
echo "=== reconcile imports/typing in persisted.ts ==="
rg -n "import .*reconcile|from .*reconcile|type .*Reconcile|ReconcileOptions" packages/storage/src/persisted.ts

Repository: solidjs-community/solid-primitives

Length of output: 2573


🌐 Web query:

solid-js reconcile(value, options) implementation expects ReconcileOptions { key?, merge? } what happens if options is a function

💡 Result:

In SolidJS, the reconcile store utility officially expects the options argument to be an object with the properties key (string | null) and merge (boolean) [1]. It does not support receiving a function as the options argument [1]. If you pass a function where an options object is expected, the implementation will attempt to access the key and merge properties directly on that function (e.g., function.key, function.merge). Since these properties are typically undefined on a function object, they will be treated as their default values, effectively ignoring the function and proceeding with default reconciliation behavior [1]. It is worth noting that some third-party libraries, such as TanStack Query, have introduced their own custom reconcile options or patterns that allow providing a function to define custom reconciliation logic (e.g., (oldData, newData) => reconciledData) [2]. However, this is specific to those libraries' implementations and is distinct from the official solid-js/store reconcile utility [1][2].

Citations:


Fix reconcile second argument type in persisted storage setter

reconcile from solid-js expects its second argument to be a ReconcileOptions object ({ key?, merge? }). Passing () => undefined is an unsupported value and won’t apply meaningful options (its .key/.merge will be read as undefined, so defaults are used). Remove the second argument or pass real options.

Proposed change
-            (signal[1] as any)(reconcile(value, () => undefined));
+            (signal[1] as any)(reconcile(value));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
: (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);
}
};
: (data: string) => {
try {
const value = deserialize(data);
(signal[1] as any)(reconcile(value));
} catch (e) {
// eslint-disable-next-line no-console
if (DEV) console.warn(e);
}
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/storage/src/persisted.ts` around lines 149 - 157, The persisted
storage setter passes an invalid second argument to solid-js reconcile—replace
reconcile(value, () => undefined) with a valid call (either remove the second
argument and use reconcile(value) or provide a proper ReconcileOptions object
like { key?, merge? }) so the reconcile API is used correctly; update the setter
where deserialize(...) is reconciled and the state is set via (signal[1] as any)
to use the corrected reconcile invocation.

let unchanged = true;
Expand All @@ -158,7 +166,7 @@ export function makePersisted<
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 +175,28 @@ export function makePersisted<
});
}

const getter = typeof signal[0] === "function" ? signal[0] as () => T : () => snapshot(signal[0] as T);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You're going a few of these evaluations on line 140, 166 and 179. Maybe just have const isSignal = ...?

const persist = () => {
const next = untrack(() => latest(getter));
if (next == 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);
}
};
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);
persist();
unchanged = false;
return output instanceof Promise ? output.then((result) => (persist(), result)) : output;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

persist() is called synchronously to write the optimistic value, then again in .then() on line 197. There is no catch so if the action rejects, Solid rolls back the in-memory signal, but storage is never re-synced. It permanently holds the pre-rejection optimistic value. On the next page load the signal hydrates with stale data?

}),
init,
] as PersistedState<S>;
] as unknown as PersistedState<S>;
}

/**
Expand Down Expand Up @@ -222,7 +230,7 @@ export const messageSync = (channel: Window | BroadcastChannel = window): Persis
/**
* 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 +249,7 @@ export const wsSync = (ws: WebSocket, warnOnError: boolean = isDev): Persistence
key,
newValue,
timeStamp: +new Date(),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Isn't Date.now() more the idiomatic form because it avoids creating an unnecessary date object?

...(isServer ? {} : { url: location.href }),
...(globalThis.window ? { url: location.href } : {}),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I still am not sure why we wouldn't just use isServer?

}),
),
];
Expand Down
Loading