Skip to content

fix(storage): types#818

Open
atk wants to merge 2 commits into
mainfrom
storage-types-and-resource-usage
Open

fix(storage): types#818
atk wants to merge 2 commits into
mainfrom
storage-types-and-resource-usage

Conversation

@atk

@atk atk commented Oct 27, 2025

Copy link
Copy Markdown
Member

docs(storage): resource usage

This simplifies the types considerably and corrects the documentation on how to use a persisted state with createResource.

Summary by CodeRabbit

  • Bug Fixes

    • Improved persisted storage behavior and typings for signals and stores, including better handling when storage is unavailable.
    • Fixed the initial value handling for async storage-backed persistence.
  • Documentation

    • Updated the storage docs with a clearer example for using persistence with resources.
    • Added guidance for using a deep signal when store-like behavior is needed.

docs(storage): resource usage
@changeset-bot

changeset-bot Bot commented Oct 27, 2025

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a4ee095

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@solid-primitives/storage Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@thetarnav thetarnav left a comment

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 really like using Object.assign for some reason.
I find it much harder to read and understand.
What's the idea behind it?

@atk

atk commented Oct 28, 2025

Copy link
Copy Markdown
Member Author

The idea is mostly about TS, because it lets you keep the original type information, which is impossible if you generate the tuple yourself.

@atk atk requested a review from thetarnav November 3, 2025 08:33
@thetarnav

Copy link
Copy Markdown
Member

Object.assign is the only part I'm questioning really. I would prefer typecasting as any. But if you're fine with it, I don't have other comments.

@atk

atk commented Nov 4, 2025

Copy link
Copy Markdown
Member Author

The problem with typecasting is that the generics can clash. It is very difficult to get around that, hence why I prefer Object.assign.

@davedbase

Copy link
Copy Markdown
Member

@atk what would we like to do this type PR? Perhaps you can close or move it to the new primitives release?

@davedbase davedbase added the enhancement New feature or request label Jul 4, 2026
@okikio

okikio commented Jul 6, 2026

Copy link
Copy Markdown

there is also a perf. benefit in v8 to using Object.assign over { ... }, it's just something v8 hasn't optimized for some reason

@atk

atk commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

I'd like to merge this nonetheless; the new version uses a similar pattern.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 7, 2026

Copy link
Copy Markdown

Deploying solid-primitives with  Cloudflare Pages  Cloudflare Pages

Latest commit: a4ee095
Status:🚫  Build failed.

View logs

@davedbase

Copy link
Copy Markdown
Member

@atk there's no official approval but either feel free to merge or give me the approval so I feel comfortable to merge :) thanks!

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR simplifies the PersistedState type and makePersisted overload generics in packages/storage/src/persisted.ts, changes the return value construction to use Object.assign, updates a related test's mocked promise value, revises the README resource example, and adds a changeset.

Changes

makePersisted Type and Return Shape

Layer / File(s) Summary
PersistedState type and imports
packages/storage/src/persisted.ts
PersistedState<S> is redefined as S & { 2: Promise<string> | string | null }, removing the exported SignalInput/SignalType helpers; type-only imports drop Accessor.
Overloads and return construction
packages/storage/src/persisted.ts
makePersisted overloads use explicit generics T and S; the no-storage branch returns Object.assign(signal, { 2: null }); the final return uses Object.assign([], signal, { 1: wrapped setter, 2: init }) instead of a tuple literal.
Test and documentation updates
packages/storage/test/persisted.test.ts, packages/storage/README.md, .changeset/short-memes-count.md
The async-storage test now mocks a JSON-stringified resolved value; README resource example is updated with a storage: value => makePersisted(...) pattern and makeDeepSignal guidance; a changeset entry documents the minor bump.

Estimated code review effort: 2 (Simple) | ~12 minutes

Suggested reviewers: brenelz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately points to the main change: a storage type fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch storage-types-and-resource-usage

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with 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.

Inline comments:
In `@packages/storage/src/persisted.ts`:
- Around line 112-115: The no-storage branch in persisted storage is mutating
the caller-provided signal tuple in place via Object.assign(signal, { 2: null
}), which makes it inconsistent with the storage-available path in persisted.ts.
Update the early return in the persisted logic so it creates and returns a new
tuple/array, matching the cloning approach used later in the storage-enabled
branch, and avoid writing to the original signal object.
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 008ce502-5e5a-4bf2-9450-179ca945d9b8

📥 Commits

Reviewing files that changed from the base of the PR and between 3e98818 and a4ee095.

📒 Files selected for processing (4)
  • .changeset/short-memes-count.md
  • packages/storage/README.md
  • packages/storage/src/persisted.ts
  • packages/storage/test/persisted.test.ts

Comment on lines +95 to 111
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> {

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.

Comment on lines 112 to +115
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 });

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 | 🟠 Major | ⚡ Quick win

No-storage branch mutates the caller's signal in place, unlike the storage-available branch.

Object.assign(signal, { 2: null }) mutates the array passed in by the caller directly (growing .length from 2 to 3), whereas the storage-available path (Lines 161-182) builds a new array via Object.assign([], signal, {...}) without touching the original. If a caller holds a reference to the original signal tuple beyond destructuring (e.g. stores it, passes it elsewhere), that reference is now silently mutated with an unexpected third index only in the no-storage case.

♻️ Make both branches consistent by not mutating the input
   if (!storage) {
-    return Object.assign(signal, { 2: null });
+    return Object.assign([], signal, { 2: null }) as PersistedState<S>;
   }
📝 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
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 });
const storage = options.storage || (globalThis.localStorage as Storage | undefined);
const name = options.name || `storage-${createUniqueId()}`;
if (!storage) {
return Object.assign([], signal, { 2: null }) as PersistedState<S>;
🤖 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 112 - 115, The no-storage
branch in persisted storage is mutating the caller-provided signal tuple in
place via Object.assign(signal, { 2: null }), which makes it inconsistent with
the storage-available path in persisted.ts. Update the early return in the
persisted logic so it creates and returns a new tuple/array, matching the
cloning approach used later in the storage-enabled branch, and avoid writing to
the original signal object.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants