Skip to content
Merged
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/short-memes-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solid-primitives/storage": minor
---

types and resource usage
6 changes: 5 additions & 1 deletion packages/storage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,17 @@ Instead of wrapping the resource itself, it is far simpler to use the `storage`
persisted signal or [deep signal](../resource/#createdeepsignal):

```ts
const [resource] = createResource(fetcher, { storage: makePersisted(createSignal()) });
const [resource] = createResource(fetcher, {
storage: value => makePersisted(createSignal(value)),
});
```

If you are using an asynchronous storage to persist the state of a resource, it might receive an update due to being
initialized from the storage before or after the fetcher resolved. If the initialization resolves after the fetcher, its
result is discarded not to overwrite more current data.

If instead of a signal you want to use a store, consider using the `makeDeepSignal` primitive from the `resource` package.

### Using `makePersisted` with Suspense

In case you are using an asynchronous storage and want the initialisation mesh into Suspense instead of mixing it with Show, we provide the output of the initialisation as third part of the returned tuple:
Expand Down
77 changes: 34 additions & 43 deletions packages/storage/src/persisted.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Accessor, Setter, Signal } from "solid-js";
import type { 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";
Expand Down Expand Up @@ -70,17 +70,7 @@ export type PersistenceOptions<T, O extends Record<string, any> | undefined> = {
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 & { 2: Promise<string> | string | null };

/**
* Persists a signal, store or similar API
Expand All @@ -102,26 +92,27 @@ export type PersistedState<S extends SignalInput> =
* @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>>(
export function makePersisted<T, S extends Signal<T> | [Store<T>, SetStoreFunction<T>]>(
signal: S,
options: PersistenceOptions<SignalType<S>, O>,
options?: PersistenceOptions<T, undefined>,
): PersistedState<S>;
export function makePersisted<
S extends SignalInput,
T,
S extends Signal<T> | [Store<T>, SetStoreFunction<T>],
O extends Record<string, any>,
>(signal: S, options: PersistenceOptions<T, O>): PersistedState<S>;
export function makePersisted<
T,
S extends Signal<T> | [Store<T>, SetStoreFunction<T>],
O extends Record<string, any> | undefined,
T = SignalType<S>,
>(
signal: S,
options: PersistenceOptions<T, O> = {} as PersistenceOptions<T, O>,
): PersistedState<S> {
Comment on lines +95 to 111

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate persisted.ts and inspect PersistenceOptions definition for context
fd persisted.ts packages/storage/src | xargs -I{} sed -n '1,120p' {}

Repository: solidjs-community/solid-primitives

Length of output: 2020


🌐 Web query:

TypeScript infer type parameter from another type parameter constraint S extends Signal<T> inference from constraint only

💡 Result:

TypeScript does not support direct inference of a type parameter (e.g., T) solely from the constraint of another type parameter (e.g., S extends Signal) when invoking a function [1]. In TypeScript, type parameters are primarily inferred from the actual types of the function's arguments, not from the constraints defined on the generic parameters themselves [1]. Because constraints are used as checks rather than as inference sites, defining a function like function example<T, S extends Signal>(signal: S) {... } often results in T failing to be inferred correctly or defaulting to unknown [1]. To work around this limitation, you can use the following common techniques: 1. Indexed Access Types: If the type T can be extracted from the structure of S, use indexed access types instead of a secondary type parameter. For example, if Signal has a property that holds the value, you can define the type as S['value'] or create a utility type to extract it [2][3]. 2. Conditional Types with infer: Use a conditional type to extract the inner type T from the signal type S [4][5]. type GetSignalValue = S extends Signal? T: never; 3. Intersection Types at Usage: If you must rely on inference, you can sometimes improve it by using an intersection type in the function parameter to provide an additional "inference site" [1]. function example<S extends Signal>(signal: S & Signal) {... } 4. Avoid Dependent Generics: Where possible, refactor your code to remove the circularity or dependency between the generics, or accept that you may need to explicitly provide the type arguments when calling the function [6][1][3]. This behavior is a known limitation of the TypeScript compiler [1][7], and while various workarounds exist, no direct feature exists to infer generic constraints themselves as primary inference sources [1].

Citations:


🌐 Web query:

TypeScript infer from generic constraint S extends Signal<T> example compiler behavior

💡 Result:

In TypeScript, the ability to infer a type from a generic constraint (such as S extends Signal) depends significantly on whether you are using the infer keyword within a conditional type or attempting to perform inference at the generic parameter level. ### Conditional Types and infer When you use infer inside a conditional type, it is specifically designed to extract parts of a type during type matching [1][2][3]. Since TypeScript 4.7, you can use the infer T extends Constraint syntax to combine inference and constraint checking in a single step [1]. Example: ts // Extract T from a Signal<T> type type GetSignalValue<S> = S extends Signal<infer T> ? T : never; // Using infer with an explicit constraint (TS 4.7+) type FirstString<T> = T extends [infer H extends string, ...any[]] ? H : never; This behavior is stable and well-supported [1]. ### Generic Constraints and Inference Limitations Attempting to infer type parameters directly from generic constraints (e.g., <S extends Signal<T>>) in function signatures or type definitions often encounters limitations because TypeScript does not automatically "reverse" generic constraints to infer the underlying type parameters (like T) [4][5]. 1. Inference Does Not Flow "Backward": When you define a generic type with a constraint like <S extends Signal<T>, T>, TypeScript often cannot automatically deduce T simply from the presence of S unless T is explicitly included and constrained in the signature [5]. 2. unknown Results: In complex signatures—particularly those involving callbacks or signals—TypeScript may default to unknown for inferred types because it cannot determine the specific type arguments without explicit help or simplified structural signatures [6][5]. 3. Compiler Workarounds: - Manual Specification: Forcing the user to specify both generics (e.g., <S, T>(signal: S & Signal<T>)) is the most reliable way to avoid unknown inference [5]. - NoInfer Utility: To control inference and prevent TypeScript from over-widening types, developers often use the NoInfer<T> utility type, which blocks inference for specific parts of a signature [6][7]. ### Summary of Compiler Behavior The compiler treats infer as a structural matching tool within conditional types [1][2]. However, generic constraints (e.g., S extends Signal<T>) are treated as bounds on existing types rather than sources for new inferences [4][5]. If you need to extract T from S, you should use a conditional type mapping (e.g., S extends Signal<infer T> ? T : never) rather than relying on the generic constraint definition itself to perform the extraction [1][2].

Citations:


Infer T from signal, not from the S constraint.

T has no direct inference site here, so options.serialize / deserialize / sync can end up typed as unknown instead of the signal element type. Extract T from S in the signature if you want those callbacks checked against the actual value type.

🤖 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 95 - 111, The makePersisted
overloads are not inferring T from the signal value, so PersistenceOptions
callbacks can fall back to unknown. Update the makePersisted signature in
persisted.ts to derive T from S using the signal/store element type instead of
the current generic constraint, and keep the overloads aligned so
options.serialize, deserialize, and sync are checked against the actual signal
type.

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 Object.assign(signal, { 2: null });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
const storageOptions = (options as unknown as { storageOptions: O }).storageOptions;
const serialize: (data: T) => string = options.serialize || JSON.stringify.bind(JSON);
Expand Down Expand Up @@ -167,28 +158,28 @@ export function makePersisted<
});
}

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;
},
init,
] as PersistedState<S>;
return Object.assign([], signal, {
1:
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 = untrack(() => serialize(signal[0] as T));
options.sync?.[1](name, value);
storage.setItem(name, value, storageOptions);
unchanged = false;
},
2: init,
}) as PersistedState<S>;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/storage/test/persisted.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ describe("makePersisted", () => {

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