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
6 changes: 6 additions & 0 deletions .changeset/align-store-overloads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@solidjs/signals": patch
"solid-js": patch
---

Align store overloads across the signals, client, and server entry points. Plain stores share `StoreOptions`, projection forms share `ProjectionOptions`, plain optimistic stores expose their existing options argument, and derived optimistic stores are typed as refreshable.
10 changes: 5 additions & 5 deletions packages/signals/src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,15 @@ export { createProjectionNext as createProjection } from "./next/projection.js";
export { storeIsShallow, storeHasFamily, storeHasOptimisticFamily } from "./next/store.js";
export { createOptimisticStoreNext as createOptimisticStore } from "./next/optimistic.js";

/** Public createStore: plain form `(init, options?)` and derived writable
/** Public createStore: plain form `(initialValue, options?)` and derived writable
* form `(fn, seed, options?)`. */
export function createStore<T extends object = {}>(
store: NoFn<T> | Store<NoFn<T>>,
options?: StoreOptions & { shallow?: boolean }
initialValue: NoFn<T> | Store<NoFn<T>>,
options?: StoreOptions
): [get: Store<T>, set: StoreSetter<T>];
export function createStore<T extends object = {}>(
fn: (store: T) => void | T | Promise<void | T> | AsyncIterable<void | T>,
store: NoFn<T> | Store<NoFn<T>>,
fn: (draft: T) => void | T | Promise<void | T> | AsyncIterable<void | T>,
seed: NoFn<T> | Store<NoFn<T>>,
options?: ProjectionOptions
): [get: Refreshable<Store<T>>, set: StoreSetter<T>];
export function createStore(first: any, second?: any, third?: any): any {
Expand Down
17 changes: 10 additions & 7 deletions packages/signals/src/store/next/optimistic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
isEqual,
setSignal,
type Computed,
type Refreshable,
type Signal
} from "../../core/index.js";
import {
Expand All @@ -56,6 +57,7 @@ import {
type NoFn,
type ProjectionOptions,
type Store,
type StoreOptions,
type StoreSetter
} from "../store.js";
import { runProjectionComputedNext } from "./projection.js";
Expand Down Expand Up @@ -177,25 +179,26 @@ function familyHasLiveOverrides(fam: { overlaid?: Set<any> }): boolean {
}

export function createOptimisticStoreNext<T extends object = {}>(
store: NoFn<T> | Store<NoFn<T>>
initialValue: NoFn<T> | Store<NoFn<T>>,
options?: StoreOptions
): [get: Store<T>, set: StoreSetter<T>];
export function createOptimisticStoreNext<T extends object = {}>(
fn: (store: T) => void | T | Promise<void | T> | AsyncIterable<void | T>,
store: NoFn<T> | Store<NoFn<T>>,
fn: (draft: T) => void | T | Promise<void | T> | AsyncIterable<void | T>,
seed: NoFn<T> | Store<NoFn<T>>,
options?: ProjectionOptions
): [get: Store<T>, set: StoreSetter<T>];
): [get: Refreshable<Store<T>>, set: StoreSetter<T>];
export function createOptimisticStoreNext<T extends object = {}>(
first: T | ((store: T) => void | T | Promise<void | T> | AsyncIterable<void | T>),
second?: NoFn<T> | Store<NoFn<T>>,
options?: ProjectionOptions
second?: NoFn<T> | Store<NoFn<T>> | StoreOptions,
third?: ProjectionOptions
): [get: Store<T>, set: StoreSetter<T>] {
// Engine first (armed nodes need optimisticWrite installed before any
// node exists), then the next-shape hooks.
installOptimisticEngine();
installNextBlockedHalf();

const derived = typeof first === "function";
if (!derived && options === undefined) options = second as ProjectionOptions | undefined;
const options = (derived ? third : second) as ProjectionOptions | undefined;
const initialValue = (derived ? second : first) as T;

const fam: StoreNextFamily = {
Expand Down
10 changes: 5 additions & 5 deletions packages/signals/src/store/next/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1908,22 +1908,22 @@ setNextAffectsNodeResolver((t: StoreNextTarget, key: PropertyKey) =>
);

export function createStoreNext<T extends Record<PropertyKey, any>>(
init: T,
initialValue: T,
shallow = false
): [T, SetStoreNextFunction<T>] {
if (shallow && __DEV__) {
// Never both deep-wrapped and raw (R41/R44): a value already tracked as
// a DEEP store cannot be ingested shallow.
const existing = storeNextLookup.get(init);
const existing = storeNextLookup.get(initialValue);
if (existing !== undefined && !(existing as any).s)
throw new Error("createStore({ shallow }): value is already tracked as a deep store");
if ((init as any)[$TARGET])
if ((initialValue as any)[$TARGET])
throw new Error("createStore({ shallow }): value is already a store proxy");
}
const proxy = wrapNext(init);
const proxy = wrapNext(initialValue);
if (shallow) {
((proxy as any)[$TARGET] as StoreNextTarget).s = true;
markRawIngest(init);
markRawIngest(initialValue);
}
if (__DEV__) registerGraph(proxy, getOwner());
const setter: SetStoreNextFunction<T> = fn => storeSetterNext(proxy, fn);
Expand Down
19 changes: 12 additions & 7 deletions packages/signals/src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,30 @@ export type Store<T> = Readonly<T>;
*
* The setter does **not** perform keyed reconciliation. If you need surviving
* items to keep their store identity across full-array replacement, use the
* projection form — `createStore(fn, seed, { key })` or `createProjection` —
* whose derive function reconciles its return by `options.key`.
* projection form — `createStore(fn, seed, { key })` or
* `createProjection(fn, seed, { key })` — whose derive function reconciles
* its return by `options.key`.
*/
export type StoreSetter<T> = (fn: (state: T) => T | void) => void;
/** Tuple returned by the plain `createStore(initialValue)` form. */
/** Tuple returned by the plain `createStore(initialValue, options?)` form. */
export type StoreReturn<T> = [get: Store<T>, set: StoreSetter<T>];
/** Tuple returned by the derived `createStore(fn, seed, options?)` form. */
export type ProjectionStoreReturn<T> = [get: Refreshable<Store<T>>, set: StoreSetter<T>];
/** Base options for store primitives. */
/** Options shared by all store primitives. */
export interface StoreOptions {
/** Debug name (dev mode only) */
name?: string;
/** Single-layer store: root keys reactive, values raw records replaced by reference */
shallow?: boolean;
}
/** Options for derived/projected stores created with `createStore(fn)`, `createProjection`, or `createOptimisticStore(fn)`. */
/**
* Options for derived/projected stores created with
* `createStore(fn, seed, options?)`, `createProjection(fn, seed, options?)`,
* or `createOptimisticStore(fn, seed, options?)`.
*/
export interface ProjectionOptions extends StoreOptions {
/** Key property name or function for reconciliation identity; `null` merges positionally */
key?: string | ((item: NonNullable<any>) => any) | null;
/** Single-layer store: root keys reactive, values raw records replaced by reference */
shallow?: boolean;
/**
* Treat the seed as commit #0: the store is born committed with the seed's
* contents, shown until the derive's first real answer lands. While that
Expand Down
12 changes: 12 additions & 0 deletions packages/signals/tests/store/store.type-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
createStore,
createProjection,
createOptimisticStore,
refresh,
type Store
} from "../../src/index.js";

Expand Down Expand Up @@ -122,4 +123,15 @@ createOptimisticStore(() => ({ user: { name: "Ada" }, ready: true }), { ready: f
{ value: 0 }
);
store.value satisfies number;
void refresh(store);
}

// ── createOptimisticStore (plain) — options preserve inference ───────

{
const [store] = createOptimisticStore([{ id: 1, value: "one" }], { shallow: true });
store[0].value satisfies string;
}

// @ts-expect-error Plain optimistic stores do not reconcile snapshots by key.
createOptimisticStore({ id: 1 }, { key: "id" });
26 changes: 15 additions & 11 deletions packages/solid/src/client/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
type SignalOptions,
type SourceAccessor,
type Store,
type StoreOptions,
type StoreSetter,
type RevealOrder,
createOwner,
Expand Down Expand Up @@ -1592,7 +1593,7 @@ export const createOptimistic: {
*/
export const createProjection: <T extends object = {}>(
fn: (draft: T) => void | T | Promise<void | T> | AsyncIterable<void | T>,
initialValue: NoFn<T> | Store<NoFn<T>>,
seed: NoFn<T> | Store<NoFn<T>>,
options?: HydrationProjectionOptions
) => Refreshable<Store<T>> = ((...args: any[]) => {
// `hydrating` can only be true once enableHydration() installed the
Expand Down Expand Up @@ -1625,7 +1626,7 @@ type NoFn<T> = T extends Function ? never : T;
* `filter`. The setter does **not** do keyed reconciliation; for
* that, use the derived/projection form (or `createProjection`).
*
* - **Plain form** — `createStore(initialValue)`: wraps a value in a
* - **Plain form** — `createStore(initialValue, options?)`: wraps a value in a
* reactive proxy.
* - **Derived form** — `createStore(fn, seed, options?)`: a
* *projection store* whose contents are computed by `fn(draft)`.
Expand Down Expand Up @@ -1666,12 +1667,12 @@ type NoFn<T> = T extends Function ? never : T;
*/
export const createStore: {
<T extends object = {}>(
store: NoFn<T> | Store<NoFn<T>>,
options?: { name?: string; shallow?: boolean }
initialValue: NoFn<T> | Store<NoFn<T>>,
options?: StoreOptions
): [get: Store<T>, set: StoreSetter<T>];
<T extends object = {}>(
fn: (store: T) => void | T | Promise<void | T> | AsyncIterable<void | T>,
store: NoFn<T> | Store<NoFn<T>>,
fn: (draft: T) => void | T | Promise<void | T> | AsyncIterable<void | T>,
seed: NoFn<T> | Store<NoFn<T>>,
options?: HydrationProjectionOptions
): [get: Refreshable<Store<T>>, set: StoreSetter<T>];
} = ((...args: any[]) => {
Expand All @@ -1691,12 +1692,12 @@ export const createStore: {
* Use this for optimistic UI on collection-shaped data. For
* single-value optimistic state, prefer `createOptimistic`.
*
* - **Plain form** — `createOptimisticStore(initialValue)`.
* - **Plain form** — `createOptimisticStore(initialValue, options?)`.
* - **Derived form** — `createOptimisticStore(fn, seed, options?)`:
* a projection store whose authoritative value is recomputed by
* `fn` and whose optimistic overlay reverts after each transition.
*
* `options.key` defaults to `"id"`; specify it only when your data
* In the derived form, `options.key` defaults to `"id"`; specify it only when your data
* uses a different identity field (e.g. `{ key: "uuid" }` or
* `{ key: t => t.slug }`). Restating the default just adds noise.
*
Expand Down Expand Up @@ -1729,10 +1730,13 @@ export const createStore: {
* @returns `[store: Store<T>, setStore: StoreSetter<T>]`
*/
export const createOptimisticStore: {
<T extends object = {}>(store: NoFn<T> | Store<NoFn<T>>): [get: Store<T>, set: StoreSetter<T>];
<T extends object = {}>(
fn: (store: T) => void | T | Promise<void | T> | AsyncIterable<void | T>,
store: NoFn<T> | Store<NoFn<T>>,
initialValue: NoFn<T> | Store<NoFn<T>>,
options?: StoreOptions
): [get: Store<T>, set: StoreSetter<T>];
<T extends object = {}>(
fn: (draft: T) => void | T | Promise<void | T> | AsyncIterable<void | T>,
seed: NoFn<T> | Store<NoFn<T>>,
options?: HydrationProjectionOptions
): [get: Refreshable<Store<T>>, set: StoreSetter<T>];
} = ((...args: any[]) => {
Expand Down
80 changes: 41 additions & 39 deletions packages/solid/src/server/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ export { snapshot, omit, storePath, $PROXY, $TRACK } from "@solidjs/signals";

// === Type re-exports ===

import type { Accessor as SignalAccessor, Refreshable } from "@solidjs/signals";
import type {
Accessor as SignalAccessor,
ProjectionOptions,
Refreshable,
StoreOptions
} from "@solidjs/signals";

export type SourceAccessor<T> = Refreshable<SignalAccessor<T>>;

Expand Down Expand Up @@ -472,17 +477,10 @@ interface ServerComputation<T = any> {
const LIVE_SOURCE = Symbol.for("solid.LiveSource");

type SsrSourceMode = "server" | "hybrid" | "client";
type ServerSsrOptions = {
interface ServerProjectionOptions extends ProjectionOptions {
deferStream?: boolean;
ssrSource?: SsrSourceMode;
/**
* Commit #0 for derived stores: serve the seed instead of suspending. The
* markup flushes with the seed (locked for the whole response — the
* first-value lock at commit #0) and the landing streams as data for the
* client, whose store is born committed with the same seed.
*/
seedLoadingValue?: boolean;
};
}
type ServerMemoOptions<T> = MemoOptions<T> & {
/**
* Keep this value out of the hydration payload. The subtree still hydrates;
Expand All @@ -494,7 +492,6 @@ type ServerMemoOptions<T> = MemoOptions<T> & {
serialize?: false;
};
type ServerSignalOptions<T> = SignalOptions<T>;
type ServerStoreOptions = ServerSsrOptions;
type NoFn<T> = T extends Function ? never : T;

/**
Expand Down Expand Up @@ -1778,19 +1775,19 @@ function setProperty(state: any, property: PropertyKey, value: any) {
} else state[property] = value;
}

export function createStore<T extends object>(
store: NoFn<T> | Store<NoFn<T>>,
options?: { name?: string; shallow?: boolean }
): [get: Store<T>, set: StoreSetter<T>];
export function createStore<T extends object>(
fn: (store: T) => void | T | Promise<void | T>,
store: NoFn<T> | Store<NoFn<T>>,
options?: ServerStoreOptions & { name?: string; shallow?: boolean }
export function createStore<T extends object = {}>(
initialValue: NoFn<T> | Store<NoFn<T>>,
options?: StoreOptions
): [get: Store<T>, set: StoreSetter<T>];
export function createStore<T extends object>(
first: T | Store<T> | ((store: T) => void | T | Promise<void | T>),
export function createStore<T extends object = {}>(
fn: (draft: T) => void | T | Promise<void | T> | AsyncIterable<void | T>,
seed: NoFn<T> | Store<NoFn<T>>,
options?: ServerProjectionOptions
): [get: Refreshable<Store<T>>, set: StoreSetter<T>];
export function createStore<T extends object = {}>(
first: T | Store<T> | ((store: T) => void | T | Promise<void | T> | AsyncIterable<void | T>),
second?: T | Store<T>,
options?: ServerSsrOptions & { name?: string; shallow?: boolean }
third?: ServerProjectionOptions
): [get: Store<T>, set: StoreSetter<T>] {
if (typeof first === "function") {
// Forward options: dropping them made ssrSource inert for derived stores —
Expand All @@ -1799,7 +1796,7 @@ export function createStore<T extends object>(
// The impl signature stays loose; the public overload above enforces the
// client/seedLoadingValue pairing, and createProjection re-checks at
// runtime.
const store = createProjection(first as any, second as NoFn<T>, options as any);
const store = createProjection(first as any, second as NoFn<T>, third as any);
return [store as Store<T>, storeSetter(store as T)];
}
const state = first as T;
Expand All @@ -1823,25 +1820,25 @@ function storeSetter<T extends object>(state: T): StoreSetter<T> {
}) as StoreSetter<T>;
}

export function createOptimisticStore<T extends object>(
store: NoFn<T> | Store<NoFn<T>>,
options?: { name?: string; shallow?: boolean }
): [get: Store<T>, set: StoreSetter<T>];
export function createOptimisticStore<T extends object>(
fn: (store: T) => void | T | Promise<void | T>,
store: NoFn<T> | Store<NoFn<T>>,
options?: ServerStoreOptions & { name?: string; shallow?: boolean }
export function createOptimisticStore<T extends object = {}>(
initialValue: NoFn<T> | Store<NoFn<T>>,
options?: StoreOptions
): [get: Store<T>, set: StoreSetter<T>];
export function createOptimisticStore<T extends object>(
first: T | Store<T> | ((store: T) => void | T | Promise<void | T>),
export function createOptimisticStore<T extends object = {}>(
fn: (draft: T) => void | T | Promise<void | T> | AsyncIterable<void | T>,
seed: NoFn<T> | Store<NoFn<T>>,
options?: ServerProjectionOptions
): [get: Refreshable<Store<T>>, set: StoreSetter<T>];
export function createOptimisticStore<T extends object = {}>(
first: T | Store<T> | ((store: T) => void | T | Promise<void | T> | AsyncIterable<void | T>),
second?: T | Store<T>,
options?: ServerSsrOptions & { name?: string; shallow?: boolean }
third?: ServerProjectionOptions
): [get: Store<T>, set: StoreSetter<T>] {
// Same no-op rationale as createOptimistic above: optimistic writes are
// masks that revert at settle, and server output is settled state. The
// setter never invokes its function — a draft mutation here would be a
// literal (permanent) mutation, the opposite of optimistic.
const [store] = (createStore as Function)(first, second, options) as [Store<T>, StoreSetter<T>];
const [store] = (createStore as Function)(first, second, third) as [Store<T>, StoreSetter<T>];
return [store, (() => warnServerWrite("optimistic")) as StoreSetter<T>];
}

Expand Down Expand Up @@ -1968,10 +1965,15 @@ function replaceState<T extends object>(target: T, next: T): void {
Object.assign(target, next);
}

export function createProjection<T extends object>(
export function createProjection<T extends object = {}>(
fn: (draft: T) => void | T | Promise<void | T> | AsyncIterable<void | T>,
initialValue: NoFn<T> | Store<NoFn<T>>,
options?: ServerStoreOptions
seed: NoFn<T> | Store<NoFn<T>>,
options?: ServerProjectionOptions
): Refreshable<Store<T>>;
export function createProjection<T extends object = {}>(
fn: (draft: T) => void | T | Promise<void | T> | AsyncIterable<void | T>,
seed: NoFn<T> | Store<NoFn<T>>,
options?: ServerProjectionOptions
): Store<T> {
const ctx = sharedConfig.context;
const owner = createOwner();
Expand Down Expand Up @@ -2000,7 +2002,7 @@ export function createProjection<T extends object>(
if (slots) slots[slotId!] = proxy;
return proxy;
};
const [state] = createStore(initialValue as NoFn<T>);
const [state] = createStore(seed as NoFn<T>);

if (options?.ssrSource === "client") {
// seedLoadingValue = declared commit #0: the seed renders. Bare = the
Expand Down
Loading