diff --git a/.changeset/align-store-overloads.md b/.changeset/align-store-overloads.md new file mode 100644 index 000000000..a9c18e2b8 --- /dev/null +++ b/.changeset/align-store-overloads.md @@ -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. diff --git a/packages/signals/src/store/index.ts b/packages/signals/src/store/index.ts index 80ed97c0c..54d5bb193 100644 --- a/packages/signals/src/store/index.ts +++ b/packages/signals/src/store/index.ts @@ -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( - store: NoFn | Store>, - options?: StoreOptions & { shallow?: boolean } + initialValue: NoFn | Store>, + options?: StoreOptions ): [get: Store, set: StoreSetter]; export function createStore( - fn: (store: T) => void | T | Promise | AsyncIterable, - store: NoFn | Store>, + fn: (draft: T) => void | T | Promise | AsyncIterable, + seed: NoFn | Store>, options?: ProjectionOptions ): [get: Refreshable>, set: StoreSetter]; export function createStore(first: any, second?: any, third?: any): any { diff --git a/packages/signals/src/store/next/optimistic.ts b/packages/signals/src/store/next/optimistic.ts index 6819e2dfd..fb6504d14 100644 --- a/packages/signals/src/store/next/optimistic.ts +++ b/packages/signals/src/store/next/optimistic.ts @@ -37,6 +37,7 @@ import { isEqual, setSignal, type Computed, + type Refreshable, type Signal } from "../../core/index.js"; import { @@ -56,6 +57,7 @@ import { type NoFn, type ProjectionOptions, type Store, + type StoreOptions, type StoreSetter } from "../store.js"; import { runProjectionComputedNext } from "./projection.js"; @@ -177,17 +179,18 @@ function familyHasLiveOverrides(fam: { overlaid?: Set }): boolean { } export function createOptimisticStoreNext( - store: NoFn | Store> + initialValue: NoFn | Store>, + options?: StoreOptions ): [get: Store, set: StoreSetter]; export function createOptimisticStoreNext( - fn: (store: T) => void | T | Promise | AsyncIterable, - store: NoFn | Store>, + fn: (draft: T) => void | T | Promise | AsyncIterable, + seed: NoFn | Store>, options?: ProjectionOptions -): [get: Store, set: StoreSetter]; +): [get: Refreshable>, set: StoreSetter]; export function createOptimisticStoreNext( first: T | ((store: T) => void | T | Promise | AsyncIterable), - second?: NoFn | Store>, - options?: ProjectionOptions + second?: NoFn | Store> | StoreOptions, + third?: ProjectionOptions ): [get: Store, set: StoreSetter] { // Engine first (armed nodes need optimisticWrite installed before any // node exists), then the next-shape hooks. @@ -195,7 +198,7 @@ export function createOptimisticStoreNext( 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 = { diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index 4edf53a00..f780d1012 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -1908,22 +1908,22 @@ setNextAffectsNodeResolver((t: StoreNextTarget, key: PropertyKey) => ); export function createStoreNext>( - init: T, + initialValue: T, shallow = false ): [T, SetStoreNextFunction] { 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 = fn => storeSetterNext(proxy, fn); diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index ea18525b1..2451f60e4 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -18,25 +18,30 @@ export type Store = Readonly; * * 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 = (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 = [get: Store, set: StoreSetter]; /** Tuple returned by the derived `createStore(fn, seed, options?)` form. */ export type ProjectionStoreReturn = [get: Refreshable>, set: StoreSetter]; -/** 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) | 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 diff --git a/packages/signals/tests/store/store.type-tests.ts b/packages/signals/tests/store/store.type-tests.ts index bef3823b9..3563962cf 100644 --- a/packages/signals/tests/store/store.type-tests.ts +++ b/packages/signals/tests/store/store.type-tests.ts @@ -2,6 +2,7 @@ import { createStore, createProjection, createOptimisticStore, + refresh, type Store } from "../../src/index.js"; @@ -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" }); diff --git a/packages/solid/src/client/hydration.ts b/packages/solid/src/client/hydration.ts index 4e84e2bb4..a0e77f5a2 100644 --- a/packages/solid/src/client/hydration.ts +++ b/packages/solid/src/client/hydration.ts @@ -33,6 +33,7 @@ import { type SignalOptions, type SourceAccessor, type Store, + type StoreOptions, type StoreSetter, type RevealOrder, createOwner, @@ -1592,7 +1593,7 @@ export const createOptimistic: { */ export const createProjection: ( fn: (draft: T) => void | T | Promise | AsyncIterable, - initialValue: NoFn | Store>, + seed: NoFn | Store>, options?: HydrationProjectionOptions ) => Refreshable> = ((...args: any[]) => { // `hydrating` can only be true once enableHydration() installed the @@ -1625,7 +1626,7 @@ type NoFn = 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)`. @@ -1666,12 +1667,12 @@ type NoFn = T extends Function ? never : T; */ export const createStore: { ( - store: NoFn | Store>, - options?: { name?: string; shallow?: boolean } + initialValue: NoFn | Store>, + options?: StoreOptions ): [get: Store, set: StoreSetter]; ( - fn: (store: T) => void | T | Promise | AsyncIterable, - store: NoFn | Store>, + fn: (draft: T) => void | T | Promise | AsyncIterable, + seed: NoFn | Store>, options?: HydrationProjectionOptions ): [get: Refreshable>, set: StoreSetter]; } = ((...args: any[]) => { @@ -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. * @@ -1729,10 +1730,13 @@ export const createStore: { * @returns `[store: Store, setStore: StoreSetter]` */ export const createOptimisticStore: { - (store: NoFn | Store>): [get: Store, set: StoreSetter]; ( - fn: (store: T) => void | T | Promise | AsyncIterable, - store: NoFn | Store>, + initialValue: NoFn | Store>, + options?: StoreOptions + ): [get: Store, set: StoreSetter]; + ( + fn: (draft: T) => void | T | Promise | AsyncIterable, + seed: NoFn | Store>, options?: HydrationProjectionOptions ): [get: Refreshable>, set: StoreSetter]; } = ((...args: any[]) => { diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 4a08a48e3..e90612fa9 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -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 = Refreshable>; @@ -472,17 +477,10 @@ interface ServerComputation { 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 = MemoOptions & { /** * Keep this value out of the hydration payload. The subtree still hydrates; @@ -494,7 +492,6 @@ type ServerMemoOptions = MemoOptions & { serialize?: false; }; type ServerSignalOptions = SignalOptions; -type ServerStoreOptions = ServerSsrOptions; type NoFn = T extends Function ? never : T; /** @@ -1778,19 +1775,19 @@ function setProperty(state: any, property: PropertyKey, value: any) { } else state[property] = value; } -export function createStore( - store: NoFn | Store>, - options?: { name?: string; shallow?: boolean } -): [get: Store, set: StoreSetter]; -export function createStore( - fn: (store: T) => void | T | Promise, - store: NoFn | Store>, - options?: ServerStoreOptions & { name?: string; shallow?: boolean } +export function createStore( + initialValue: NoFn | Store>, + options?: StoreOptions ): [get: Store, set: StoreSetter]; -export function createStore( - first: T | Store | ((store: T) => void | T | Promise), +export function createStore( + fn: (draft: T) => void | T | Promise | AsyncIterable, + seed: NoFn | Store>, + options?: ServerProjectionOptions +): [get: Refreshable>, set: StoreSetter]; +export function createStore( + first: T | Store | ((store: T) => void | T | Promise | AsyncIterable), second?: T | Store, - options?: ServerSsrOptions & { name?: string; shallow?: boolean } + third?: ServerProjectionOptions ): [get: Store, set: StoreSetter] { if (typeof first === "function") { // Forward options: dropping them made ssrSource inert for derived stores — @@ -1799,7 +1796,7 @@ export function createStore( // 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, options as any); + const store = createProjection(first as any, second as NoFn, third as any); return [store as Store, storeSetter(store as T)]; } const state = first as T; @@ -1823,25 +1820,25 @@ function storeSetter(state: T): StoreSetter { }) as StoreSetter; } -export function createOptimisticStore( - store: NoFn | Store>, - options?: { name?: string; shallow?: boolean } -): [get: Store, set: StoreSetter]; -export function createOptimisticStore( - fn: (store: T) => void | T | Promise, - store: NoFn | Store>, - options?: ServerStoreOptions & { name?: string; shallow?: boolean } +export function createOptimisticStore( + initialValue: NoFn | Store>, + options?: StoreOptions ): [get: Store, set: StoreSetter]; -export function createOptimisticStore( - first: T | Store | ((store: T) => void | T | Promise), +export function createOptimisticStore( + fn: (draft: T) => void | T | Promise | AsyncIterable, + seed: NoFn | Store>, + options?: ServerProjectionOptions +): [get: Refreshable>, set: StoreSetter]; +export function createOptimisticStore( + first: T | Store | ((store: T) => void | T | Promise | AsyncIterable), second?: T | Store, - options?: ServerSsrOptions & { name?: string; shallow?: boolean } + third?: ServerProjectionOptions ): [get: Store, set: StoreSetter] { // 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, StoreSetter]; + const [store] = (createStore as Function)(first, second, third) as [Store, StoreSetter]; return [store, (() => warnServerWrite("optimistic")) as StoreSetter]; } @@ -1968,10 +1965,15 @@ function replaceState(target: T, next: T): void { Object.assign(target, next); } -export function createProjection( +export function createProjection( fn: (draft: T) => void | T | Promise | AsyncIterable, - initialValue: NoFn | Store>, - options?: ServerStoreOptions + seed: NoFn | Store>, + options?: ServerProjectionOptions +): Refreshable>; +export function createProjection( + fn: (draft: T) => void | T | Promise | AsyncIterable, + seed: NoFn | Store>, + options?: ServerProjectionOptions ): Store { const ctx = sharedConfig.context; const owner = createOwner(); @@ -2000,7 +2002,7 @@ export function createProjection( if (slots) slots[slotId!] = proxy; return proxy; }; - const [state] = createStore(initialValue as NoFn); + const [state] = createStore(seed as NoFn); if (options?.ssrSource === "client") { // seedLoadingValue = declared commit #0: the seed renders. Bare = the