From 0a1af8d0e874d3bd9923cdb0202352ea907fbe7d Mon Sep 17 00:00:00 2001 From: Gabriel Valfridsson Date: Fri, 4 Sep 2026 16:29:46 +0200 Subject: [PATCH 1/3] fix(signals): make derived stores type-safe --- .changeset/type-safe-derived-stores.md | 6 + packages/signals/src/store/index.ts | 33 +++- packages/signals/src/store/next/optimistic.ts | 74 +++++++-- packages/signals/src/store/next/projection.ts | 138 ++++++++++++++-- packages/signals/src/store/store.ts | 14 +- .../store/createProjection.async.test.ts | 51 ++++++ .../tests/store/createProjection.test.ts | 10 +- .../signals/tests/store/store.type-tests.ts | 86 +++++++--- packages/solid/src/client/hydration.ts | 148 +++++++++++++----- packages/solid/src/index.ts | 1 + packages/solid/src/server/signals.ts | 83 ++++++++-- packages/solid/test/client-hydration.spec.ts | 89 +++++++++++ packages/solid/test/server/ssr-async.spec.ts | 27 +++- 13 files changed, 635 insertions(+), 125 deletions(-) create mode 100644 .changeset/type-safe-derived-stores.md diff --git a/.changeset/type-safe-derived-stores.md b/.changeset/type-safe-derived-stores.md new file mode 100644 index 000000000..edecdd0fe --- /dev/null +++ b/.changeset/type-safe-derived-stores.md @@ -0,0 +1,6 @@ +--- +"@solidjs/signals": patch +"solid-js": patch +--- + +Require complete explicit seeds for derived stores, preventing required properties from being absent at runtime while exposed as present in the store type. Object projections may instead omit the seed and use a return-only initializer that supplies a complete value on every resolution; array projections continue to require an explicit seed. diff --git a/packages/signals/src/store/index.ts b/packages/signals/src/store/index.ts index 02fb74688..f0fe8ec29 100644 --- a/packages/signals/src/store/index.ts +++ b/packages/signals/src/store/index.ts @@ -6,6 +6,7 @@ export type { StoreNode, StoreOptions, ProjectionOptions, + SeededProjectionOptions, NotWrappable, SolidStore } from "./store.js"; @@ -13,7 +14,15 @@ export type { Merge, Omit } from "./utils.js"; export { isWrappable, $TRACK, $PROXY, $TARGET } from "./store.js"; -import type { NoFn, ProjectionOptions, Store, StoreOptions, StoreSetter } from "./store.js"; +import type { + NoFn, + ProjectionOptions, + SeededProjectionOptions, + Store, + StoreOptions, + StoreSetter, + SeedlessRoot +} from "./store.js"; import type { Refreshable } from "../core/index.js"; import { createStoreNext, @@ -24,21 +33,33 @@ import { import { reconcileNextState } from "./next/reconcile.js"; import { createStoreDerivedNext } from "./next/projection.js"; -export { createProjectionNext as createProjection } from "./next/projection.js"; +export { + createProjectionNext as createProjection, + createProjectionHydrationReplayNext as createProjectionHydrationReplay, + createStoreHydrationReplayNext as createStoreHydrationReplay +} from "./next/projection.js"; export { storeIsShallow, storeHasFamily, storeHasOptimisticFamily } from "./next/store.js"; -export { createOptimisticStoreNext as createOptimisticStore } from "./next/optimistic.js"; +export { + createOptimisticStoreNext as createOptimisticStore, + createOptimisticStoreHydrationReplayNext as createOptimisticStoreHydrationReplay +} from "./next/optimistic.js"; /** Public createStore: plain form `(initialValue, options?)` and derived writable - * form `(fn, seed, options?)`. */ + * forms `(fn)` / `(fn, seed, options?)`. */ export function createStore( initialValue: NoFn | Store>, options?: StoreOptions ): [get: Store, set: StoreSetter]; export function createStore( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, + fn: (() => T | Promise | AsyncIterable) & SeedlessRoot, + seed?: null, options?: ProjectionOptions ): [get: Refreshable>, set: StoreSetter]; +export function createStore( + fn: (draft: T) => void | T | Promise | AsyncIterable, + seed: NoFn | Store>, + options?: SeededProjectionOptions +): [get: Refreshable>, set: StoreSetter]; export function createStore(first: any, second?: any, third?: any): any { if (typeof first === "function") return createStoreDerivedNext(first, second, third); return createStoreNext(first, !!second?.shallow); diff --git a/packages/signals/src/store/next/optimistic.ts b/packages/signals/src/store/next/optimistic.ts index 41d4bea52..cf6ca38d3 100644 --- a/packages/signals/src/store/next/optimistic.ts +++ b/packages/signals/src/store/next/optimistic.ts @@ -56,11 +56,19 @@ import { markRawIngest, type NoFn, type ProjectionOptions, + type SeededProjectionOptions, type Store, type StoreOptions, - type StoreSetter + type StoreSetter, + type SeedlessRoot } from "../store.js"; -import { runProjectionComputedNext } from "./projection.js"; +import { + runProjectionComputedNext, + validateStoreValue, + createReplayStoreValidator, + type ProjectionFn, + type ProjectionResultValidator +} from "./projection.js"; import { bumpDeep, authoritativeRead, @@ -183,14 +191,45 @@ export function createOptimisticStoreNext( options?: StoreOptions ): [get: Store, set: StoreSetter]; export function createOptimisticStoreNext( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, + fn: (() => T | Promise | AsyncIterable) & SeedlessRoot, + seed?: null, options?: ProjectionOptions ): [get: Refreshable>, set: StoreSetter]; export function createOptimisticStoreNext( - first: T | ((store: T) => void | T | Promise | AsyncIterable), - second?: Partial | NoFn | Store> | StoreOptions, - third?: ProjectionOptions + fn: (draft: T) => void | T | Promise | AsyncIterable, + seed: NoFn | Store>, + options?: SeededProjectionOptions +): [get: Refreshable>, set: StoreSetter]; +export function createOptimisticStoreNext( + first: + | T + | ((draft: T) => void | T | Promise | AsyncIterable) + | (() => T | Promise | AsyncIterable), + second?: NoFn | Store> | StoreOptions | null, + third?: SeededProjectionOptions +): [get: Store, set: StoreSetter] { + const derived = typeof first === "function"; + const seeded = !derived || second != null; + const options = (derived ? third : second) as SeededProjectionOptions | undefined; + if (!seeded && options?.seedLoadingValue) + throw new Error("seedLoadingValue requires an explicit store seed"); + const derive = + derived && !seeded + ? () => (first as () => T | Promise | AsyncIterable)() + : (first as T | ProjectionFn); + return createOptimisticStoreNextInternal( + derive, + (derived ? (second ?? {}) : first) as T, + options, + derived && !seeded ? validateStoreValue : undefined + ); +} + +function createOptimisticStoreNextInternal( + first: T | ProjectionFn, + initialValue: T, + options?: SeededProjectionOptions, + validateResult?: ProjectionResultValidator ): [get: Store, set: StoreSetter] { // Engine first (armed nodes need optimisticWrite installed before any // node exists), then the next-shape hooks. @@ -198,8 +237,6 @@ export function createOptimisticStoreNext( installNextBlockedHalf(); const derived = typeof first === "function"; - const options = (derived ? third : second) as ProjectionOptions | undefined; - const initialValue = (derived ? second : first) as T; const fam: StoreNextFamily = { map: new WeakMap(), @@ -224,7 +261,7 @@ export function createOptimisticStoreNext( } if (derived) { - const fn = first as (store: T) => void | T | Promise | AsyncIterable; + const fn = first as ProjectionFn; // #3146: an async settle event belongs to the flight's OWN transaction. // A live declared one re-enters (a merge if the generic settle path // already entered a graph-stamped stranger — the landing supersedes any @@ -321,7 +358,8 @@ export function createOptimisticStoreNext( fn, options?.key === undefined ? "id" : options.key, wrapCommit, - aroundDraftWrite + aroundDraftWrite, + validateResult ) ); } finally { @@ -347,6 +385,20 @@ export function createOptimisticStoreNext( ]; } +/** @internal Hydration replay starts with a complete snapshot, then mutates a private draft. */ +export function createOptimisticStoreHydrationReplayNext( + fn: ProjectionFn, + replaying: () => boolean, + options?: ProjectionOptions +): [get: Refreshable>, set: StoreSetter] { + return createOptimisticStoreNextInternal( + fn, + {} as T, + options, + createReplayStoreValidator(replaying) + ); +} + /** Resolve a retained transition through its merge chain (`_done` holds the * merge target while merged, `true` once settled). Null = dead. */ function liveTransition(txn: Transition): Transition | null { diff --git a/packages/signals/src/store/next/projection.ts b/packages/signals/src/store/next/projection.ts index 7ff2afbeb..429ce6afe 100644 --- a/packages/signals/src/store/next/projection.ts +++ b/packages/signals/src/store/next/projection.ts @@ -24,6 +24,7 @@ import { type Computed, type Refreshable } from "../../core/index.js"; +import { STATUS_UNINITIALIZED } from "../../core/constants.js"; import { projectionWriteActive, setProjectionWriteActive } from "../../core/scheduler.js"; import { @@ -33,12 +34,40 @@ import { STORE_VALUE, type NoFn, type ProjectionOptions, - type Store + type SeededProjectionOptions, + type Store, + type SeedlessRoot } from "../store.js"; import { reconcileNextState } from "./reconcile.js"; import { storeSetterNext, wrapNext } from "./store.js"; import type { StoreNextFamily } from "./target.js"; +export function validateStoreValue(value: void | object): void { + if (value === undefined) throw new Error("A seedless store projection must produce a value"); + if (value === null || typeof value !== "object") + throw new Error("A seedless store projection must produce an object value"); + if (Array.isArray(value)) throw new Error("Array store projections require an explicit seed"); + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) + throw new Error("A seedless store projection must produce a plain object value"); +} + +export type ProjectionFn = ( + draft: T +) => void | T | Promise | AsyncIterable; +export type ProjectionResultValidator = ( + value: void | T, + owner: Computed +) => void; + +export function createReplayStoreValidator( + replaying: () => boolean +): ProjectionResultValidator { + return (value, owner) => { + if (!replaying() || owner._statusFlags & STATUS_UNINITIALIZED) validateStoreValue(value); + }; +} + /** * Wrap a store proxy as a projection DRAFT: every operation carries the write * override (the derive is the author — its ops must not hit the §6c firewall @@ -176,21 +205,22 @@ function wrapDraft( } function createProjectionNextInternal( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial, - options?: ProjectionOptions + fn: ProjectionFn, + initialValue: T, + options?: SeededProjectionOptions, + validateResult?: ProjectionResultValidator ) { const fam: StoreNextFamily = { map: new WeakMap(), node: null, shallow: !!(options as any)?.shallow }; - const store = wrapNext(seed as any, null, null, fam) as Store; + const store = wrapNext(initialValue as any, null, null, fam) as Store; if (fam.shallow) { // Shallow projection: the root is the only wrapped level — slot values // serve raw, ingests sticky raw-mark (same t.s machinery as plain). ((store as any)[$TARGET] as any).s = true; - markRawIngest(seed); + markRawIngest(initialValue); } let nodeOptions: { name?: string; loadingValue?: void } | undefined; @@ -198,7 +228,14 @@ function createProjectionNextInternal( if (__DEV__ && options?.name) nodeOptions = { ...nodeOptions, name: options.name }; const node = computed(() => { if (!fam.node) fam.node = getOwner() as Computed; - runProjectionComputedNext(store, fn, options?.key === undefined ? "id" : options.key); + runProjectionComputedNext( + store, + fn, + options?.key === undefined ? "id" : options.key, + undefined, + undefined, + validateResult + ); }, nodeOptions); node._config &= ~CONFIG_AUTO_DISPOSE; fam.node = node; @@ -209,23 +246,78 @@ function createProjectionNextInternal( }; } +export function createProjectionNext( + fn: (() => T | Promise | AsyncIterable) & SeedlessRoot, + seed?: null, + options?: ProjectionOptions +): Refreshable>; export function createProjectionNext( fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, + seed: NoFn | Store>, + options?: SeededProjectionOptions +): Refreshable>; +export function createProjectionNext( + fn: + | ((draft: T) => void | T | Promise | AsyncIterable) + | (() => T | Promise | AsyncIterable), + seed: NoFn | Store> | null | undefined, + options?: SeededProjectionOptions +): Refreshable> { + const seeded = seed != null; + if (!seeded && options?.seedLoadingValue) + throw new Error("seedLoadingValue requires an explicit store seed"); + const derive = seeded + ? (fn as ProjectionFn) + : () => (fn as () => T | Promise | AsyncIterable)(); + return createProjectionNextInternal( + derive, + (seed ?? {}) as T, + options, + seeded ? undefined : validateStoreValue + ).store; +} + +/** @internal Hydration replay starts with a complete snapshot, then mutates a private draft. */ +export function createProjectionHydrationReplayNext( + fn: ProjectionFn, + replaying: () => boolean, options?: ProjectionOptions ): Refreshable> { - return createProjectionNextInternal(fn, seed, options).store; + return createProjectionNextInternal(fn, {} as T, options, createReplayStoreValidator(replaying)) + .store; } /** Derived writable store (legacy parity): a projection whose public setter * masks the recompute for the tick (core R31 — the manual write wins over a * same-flush dependency change). */ export function createStoreDerivedNext( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, - options?: ProjectionOptions + fn: + | ((draft: T) => void | T | Promise | AsyncIterable) + | (() => T | Promise | AsyncIterable), + seed: NoFn | Store> | null | undefined, + options?: SeededProjectionOptions +): [Refreshable>, (f: (draft: T) => T | void) => void] { + const seeded = seed != null; + if (!seeded && options?.seedLoadingValue) + throw new Error("seedLoadingValue requires an explicit store seed"); + const derive = seeded + ? (fn as ProjectionFn) + : () => (fn as () => T | Promise | AsyncIterable)(); + return createStoreDerivedNextInternal( + derive, + (seed ?? {}) as T, + options, + seeded ? undefined : validateStoreValue + ); +} + +function createStoreDerivedNextInternal( + fn: ProjectionFn, + initialValue: T, + options?: SeededProjectionOptions, + validateResult?: ProjectionResultValidator ): [Refreshable>, (f: (draft: T) => T | void) => void] { - const { store, node } = createProjectionNextInternal(fn, seed, options); + const { store, node } = createProjectionNextInternal(fn, initialValue, options, validateResult); return [ store, (f: (draft: T) => T | void): void => { @@ -236,12 +328,27 @@ export function createStoreDerivedNext( ]; } +/** @internal Hydration replay starts with a complete snapshot, then mutates a private draft. */ +export function createStoreHydrationReplayNext( + fn: ProjectionFn, + replaying: () => boolean, + options?: ProjectionOptions +): [Refreshable>, (f: (draft: T) => T | void) => void] { + return createStoreDerivedNextInternal( + fn, + {} as T, + options, + createReplayStoreValidator(replaying) + ); +} + export function runProjectionComputedNext( wrappedStore: Store, - fn: (draft: T) => void | T | Promise | AsyncIterable, + fn: ProjectionFn, key: string | ((item: NonNullable) => any) | null, wrapCommit?: (write: () => void, value: T) => void, - aroundDraftWrite?: (op: () => void) => void + aroundDraftWrite?: (op: () => void) => void, + validateResult?: ProjectionResultValidator ): Computed { const owner = getOwner() as Computed; let settled = false; @@ -264,6 +371,7 @@ export function runProjectionComputedNext( result = fn((shadow ?? s) as T); settled = true; const commit = (v: void | T) => { + validateResult?.(v, owner); // Shadow run: commit a detached snapshot, never the shadow itself // (adoption takes the value by identity — handing it the live shadow // would fuse the draft to the observable store). diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index 14ed794bd..0bfa76f59 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -25,7 +25,7 @@ export type Store = T; export type StoreSetter = (fn: (state: T) => T | void) => void; /** 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. */ +/** Tuple returned by the derived `createStore(fn, seed?, options?)` form. */ export type ProjectionStoreReturn = [get: Refreshable>, set: StoreSetter]; /** Options shared by all store primitives. */ export interface StoreOptions { @@ -34,14 +34,13 @@ export interface StoreOptions { /** Single-layer store: root keys reactive, values raw records replaced by reference */ shallow?: boolean; } -/** - * Options for derived/projected stores created with - * `createStore(fn, seed, options?)`, `createProjection(fn, seed, options?)`, - * or `createOptimisticStore(fn, seed, options?)`. - */ +/** Options shared by seeded and seedless derived/projected stores. */ export interface ProjectionOptions extends StoreOptions { /** Key property name or function for reconciliation identity; `null` merges positionally */ key?: string | ((item: NonNullable) => any) | null; +} +/** Options for a derived/projected store with an explicit seed. */ +export interface SeededProjectionOptions extends ProjectionOptions { /** * 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 @@ -57,6 +56,9 @@ export interface ProjectionOptions extends StoreOptions { */ seedLoadingValue?: boolean; } +/** Restricts seedless projections to non-callable object roots with a known proxy shape. @internal */ +export type SeedlessRoot = + Extract extends never ? unknown : never; export type NoFn = T extends Function ? never : T; type DataNode = Signal; diff --git a/packages/signals/tests/store/createProjection.async.test.ts b/packages/signals/tests/store/createProjection.async.test.ts index 5a271c306..9e825c271 100644 --- a/packages/signals/tests/store/createProjection.async.test.ts +++ b/packages/signals/tests/store/createProjection.async.test.ts @@ -28,6 +28,57 @@ function deferred() { } describe("Projection async behavior", () => { + it("adopts every value from a seedless async iterable", async () => { + const next = deferred(); + let receivedArgs: unknown[] | undefined; + const projection = createProjection<{ value: number }>(async function* (...args: unknown[]) { + receivedArgs = args; + yield { value: 1 }; + await next.promise; + yield { value: 2 }; + }); + + flush(); + expect(() => projection.value).toThrow(NotReadyError); + await Promise.resolve(); + await Promise.resolve(); + expect(receivedArgs).toEqual([]); + expect(projection.value).toBe(1); + + next.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(projection.value).toBe(2); + }); + + it("rejects seedless values without a compatible object proxy shape", async () => { + class State { + value = 1; + } + const createInvalid = (fn: () => any) => { + const projection = createProjection(fn); + flush(); + return projection.value; + }; + + expect(() => createInvalid(() => undefined)).toThrow( + "A seedless store projection must produce a value" + ); + expect(() => createInvalid(() => [])).toThrow( + "Array store projections require an explicit seed" + ); + expect(() => createInvalid(() => new State())).toThrow( + "A seedless store projection must produce a plain object value" + ); + + const asyncProjection = createProjection(async () => undefined); + flush(); + await Promise.resolve(); + await Promise.resolve(); + expect(() => asyncProjection.value).toThrow("A seedless store projection must produce a value"); + }); + it("resolves async draft and transforms into new value", async () => { const [$x, setX] = createSignal(1); diff --git a/packages/signals/tests/store/createProjection.test.ts b/packages/signals/tests/store/createProjection.test.ts index e0038a498..4b787ba87 100644 --- a/packages/signals/tests/store/createProjection.test.ts +++ b/packages/signals/tests/store/createProjection.test.ts @@ -518,7 +518,7 @@ describe("projection over a store — chained backing (#2941)", () => { proj = createProjection(() => { derive(); return store; - }, {}) as { a: number }; + }, {} as any) as { a: number }; createEffect( () => proj.a, v => { @@ -547,7 +547,7 @@ describe("projection over a store — chained backing (#2941)", () => { const dispose = createRoot(dispose => { const [store, set] = createStore({ top: "t1", nest: { deep: "d1" } }); setStore = set; - const proj = createProjection(() => store, {}) as typeof store; + const proj = createProjection(() => store, {} as any) as typeof store; createEffect( () => proj.top, v => { @@ -587,7 +587,7 @@ describe("projection over a store — chained backing (#2941)", () => { const proj = createProjection(() => { const m = mode(); return m === "a" ? a : m === "b" ? b : { v: "plain" }; - }, {}) as { v: string }; + }, {} as any) as { v: string }; createEffect( () => proj.v, v => { @@ -676,7 +676,7 @@ describe("projection over a store — chained backing (#2941)", () => { const dispose = createRoot(dispose => { const [store, set] = createStore({ a: 1, nest: { b: 2 } }); setStore = set; - proj = createProjection(() => store, {}); + proj = createProjection(() => store, {} as any); return dispose; }); flush(); @@ -697,7 +697,7 @@ describe("projection over a store — chained backing (#2941)", () => { const dispose = createRoot(dispose => { const [store, set] = createStore>({ a: 1 }); setStore = set; - const proj = createProjection(() => store, {}) as Record; + const proj = createProjection(() => store, {} as any) as Record; createEffect( () => Object.keys(proj).join(","), v => { diff --git a/packages/signals/tests/store/store.type-tests.ts b/packages/signals/tests/store/store.type-tests.ts index 56d83015b..1a5e675c0 100644 --- a/packages/signals/tests/store/store.type-tests.ts +++ b/packages/signals/tests/store/store.type-tests.ts @@ -65,25 +65,72 @@ import { store.count satisfies number; } -// ── createStore (projection) — partial seed ─────────────────────────── +// ── seedless projections ────────────────────────────────────────────── { - const [store] = createStore(() => ({ foo: true }), {}); - store.foo satisfies boolean; + const [store] = createStore(async () => ({ user: { name: "Ada" } })); + store.user.name satisfies string; } { - const [store] = createStore(() => ({ a: 1, b: "hello" }), {}); - store.a satisfies number; - store.b satisfies string; + const store = createProjection(() => ({ nested: { count: 1 } })); + store.nested.count satisfies number; } { - const [store] = createStore(() => ({ a: 1, b: "hello" }), { a: 0 }); - store.a satisfies number; - store.b satisfies string; + const [store] = createOptimisticStore(async function* () { + yield { status: "loading" as const }; + yield { status: "ready" as const }; + }); + store.status satisfies "loading" | "ready"; +} + +{ + type Profile = { name: string; visits: number }; + + createProjection(() => ({ name: "Ada", visits: 1 })); + + // @ts-expect-error A seedless projection must return a complete value. + createProjection(() => {}); + // @ts-expect-error A seedless projection does not receive a draft. + createProjection((draft: Profile) => ({ ...draft, visits: draft.visits + 1 })); + // @ts-expect-error A seedless projection cannot expose a loading seed. + createProjection(() => ({ name: "Ada", visits: 1 }), undefined, { + seedLoadingValue: true + }); } +// @ts-expect-error Array projections require a seed to establish the proxy shape. +createProjection(() => [{ id: 1 }]); +// @ts-expect-error Writable array projections require a seed to establish the proxy shape. +createStore(() => [{ id: 1 }]); +// @ts-expect-error Optimistic array projections require a seed to establish the proxy shape. +createOptimisticStore(() => [{ id: 1 }]); + +// ── projection seeds must be complete ──────────────────────────────── + +type UserState = { user: { name: string }; ready: boolean }; + +// @ts-expect-error An inferred seed cannot omit required properties. +createStore(() => ({ user: { name: "Ada" }, ready: true }), { ready: false }); + +// @ts-expect-error An explicit type argument cannot opt back into a partial seed. +createStore(() => ({ user: { name: "Ada" }, ready: true }), {}); + +// ── callable store roots are rejected ──────────────────────────────── + +type CallableState = (() => void) & { count: number }; +const callableState = Object.assign(() => {}, { count: 0 }); + +// @ts-expect-error A projection draft cannot represent a callable root. +createStore(() => callableState, callableState); + +// @ts-expect-error A projection draft cannot represent a callable root. +createProjection(() => callableState, callableState); + +// @ts-expect-error A projection draft cannot represent a callable root. +createOptimisticStore(() => callableState, callableState); + // ── createProjection — mutation only (void return, T from seed) ─────── { @@ -108,17 +155,8 @@ import { store.active satisfies boolean; } -// ── createProjection — partial seed ─────────────────────────────────── - -{ - const store = createProjection(() => ({ foo: true }), {}); - store.foo satisfies boolean; -} - -{ - const store = createProjection(() => ({ nested: { x: 1 } }), {}); - store.nested.x satisfies number; -} +// @ts-expect-error createProjection also requires a complete seed. +createProjection(() => ({ user: { name: "Ada" }, ready: true }), { ready: false }); // ── createProjection — empty array seed ─────────────────────────────── @@ -146,12 +184,8 @@ import { proj.count satisfies number; } -// ── createOptimisticStore (projection) — partial seed ───────────────── - -{ - const [store] = createOptimisticStore(() => ({ foo: true }), {}); - store.foo satisfies boolean; -} +// @ts-expect-error createOptimisticStore also requires a complete seed. +createOptimisticStore(() => ({ user: { name: "Ada" }, ready: true }), { ready: false }); // ── createOptimisticStore (projection) — seed matches return type ───── diff --git a/packages/solid/src/client/hydration.ts b/packages/solid/src/client/hydration.ts index 6d238d4ef..45be8a984 100644 --- a/packages/solid/src/client/hydration.ts +++ b/packages/solid/src/client/hydration.ts @@ -16,6 +16,9 @@ import { createProjection as coreProjection, createStore as coreStore, createOptimisticStore as coreOptimisticStore, + createProjectionHydrationReplay as coreProjectionHydrationReplay, + createStoreHydrationReplay as coreStoreHydrationReplay, + createOptimisticStoreHydrationReplay as coreOptimisticStoreHydrationReplay, createRenderEffect as coreRenderEffect, createEffect as coreEffect, setSnapshotCapture, @@ -28,6 +31,7 @@ import { type NoInfer, type Owner, type ProjectionOptions, + type SeededProjectionOptions, type Refreshable, type Signal, type SignalOptions, @@ -108,6 +112,7 @@ declare module "@solidjs/signals" { type HydrationMemoOptions = MemoOptions; type HydrationSignalOptions = SignalOptions & MemoOptions; type HydrationProjectionOptions = ProjectionOptions; +type HydrationSeededProjectionOptions = SeededProjectionOptions; export type HydrationContext = {}; @@ -287,7 +292,7 @@ let _createLoadingBoundary: Function | undefined; // enableHydration() installed these. let _hydrateSignalLike: ((coreFn: Function, fn: any, options?: any) => any) | undefined; let _hydrateStoreLike: - | ((coreFn: Function, fn: any, initialValue: any, options?: any) => any) + | ((coreFn: Function, replayFn: Function, fn: any, initialValue: any, options?: any) => any) | undefined; // lazy()'s server-module lookup: only meaningful under hydration, so the // implementation (and peekNextChildId/_$HY access behind it) installs here @@ -683,8 +688,22 @@ function hydrateSignalFromAsyncIterable(coreFn: Function, compute: any, options: }, options); } +function createStoreFromHydrationReplay( + coreFn: Function, + replayFn: Function, + compute: Function, + initialValue: any, + options: any, + replaying: () => boolean +) { + return initialValue != null + ? coreFn(compute, initialValue, options) + : replayFn(compute, replaying, options); +} + function hydrateStoreFromAsyncIterable( coreFn: Function, + replayFn: Function, fn: any, initialValue: any, options: any @@ -697,6 +716,7 @@ function hydrateStoreFromAsyncIterable( const srcIt = loaded[Symbol.asyncIterator](); const loading = hasLoadingWindow(options); + const seeded = initialValue != null; let isFirst = true; let buffered: any = null; let terminal = false; @@ -704,7 +724,9 @@ function hydrateStoreFromAsyncIterable( terminal = true; throw e; }; - return coreFn( + return createStoreFromHydrationReplay( + coreFn, + replayFn, (draft: any) => { // A run after the serialized stream reached its terminal state (done // or error) is a real invalidation — a dependency change or refresh() @@ -716,13 +738,17 @@ function hydrateStoreFromAsyncIterable( // re-runs each time a pending pull lands — and must keep adopting the // shared replay (going live there re-fetches data the document is // still delivering). - if (terminal) return fn(draft); + if (terminal) return runStoreFn(fn, draft, seeded); // Run the user fn up to its first await on the client so any reactive // dependencies read before the first suspension are tracked. Writes go // to a shadow of the draft and are discarded — the server iterator is // authoritative and drives the real draft via the iterable below. - const { proxy } = createShadowDraft(draft); - subFetch(fn, proxy); + if (seeded) { + const { proxy } = createShadowDraft(draft); + subFetch(fn, proxy); + } else { + subFetch(() => fn()); + } const process = (res: any) => { if (res.done) { terminal = true; @@ -730,6 +756,9 @@ function hydrateStoreFromAsyncIterable( } if (isFirst) { isFirst = false; + // A seedless replay's first entry is a complete snapshot. Let core + // adopt it before later patch entries mutate the private draft. + if (!seeded) return { done: false, value: res.value }; // The initial full value IS the snapshot state the SSR DOM reflects. // Disable snapshot capture while applying it so prepareStoreWrite doesn't // record the pre-write (empty) base as the snapshot — otherwise reads @@ -856,7 +885,8 @@ function hydrateStoreFromAsyncIterable( }; }, initialValue, - options + options, + () => !terminal ); } @@ -1103,17 +1133,24 @@ function hydratedCreateErrorBoundary( return coreErrorBoundary(fn, fallback); } -function wrapStoreFn(fn: any, options?: any) { - return (draft: any) => readSerializedOrCompute(() => fn(draft), draft, options); +function runStoreFn(fn: any, draft: any, seeded: boolean) { + return seeded ? fn(draft) : fn(); +} + +function wrapStoreFn(fn: any, seeded: boolean, options?: any) { + return (draft: any) => + readSerializedOrCompute(() => runStoreFn(fn, draft, seeded), draft, options); } function hydrateStoreLikeFn( coreFn: Function, + replayFn: Function, fn: any, initialValue: any, options: any, ssrSource: string | undefined ): any { + const seeded = initialValue != null; if (ssrSource === "client") { return withHydrationGate(hydrated => coreFn( @@ -1122,7 +1159,7 @@ function hydrateStoreLikeFn( // seedLoadingValue the seed is commit #0 and remains readable; // otherwise the store suspends until its first client result. if (!hydrated()) return UNASKED; - return fn(draft); + return runStoreFn(fn, draft, seeded); }, initialValue, options @@ -1138,11 +1175,12 @@ function hydrateStoreLikeFn( if (sharedConfig.has!(o.id!)) return readHydratedValue( sharedConfig.load!(o.id!), - () => subFetch(fn, draft), + () => subFetch(() => runStoreFn(fn, draft, seeded)), options ); - return fn(draft); + return runStoreFn(fn, draft, seeded); } + if (!seeded) return fn(); const { proxy, activate } = createShadowDraft(draft); const r = fn(proxy); return isAsyncIterable(r) ? wrapFirstYield(r, activate) : r; @@ -1152,9 +1190,9 @@ function hydrateStoreLikeFn( ) ); } - const aiResult = hydrateStoreFromAsyncIterable(coreFn, fn, initialValue, options); + const aiResult = hydrateStoreFromAsyncIterable(coreFn, replayFn, fn, initialValue, options); if (aiResult !== null) return aiResult; - return coreFn(wrapStoreFn(fn, options), initialValue, options); + return coreFn(wrapStoreFn(fn, seeded, options), initialValue, options); } // The store-shaped counterpart to hydrateSignalLike: one body for @@ -1163,9 +1201,17 @@ function hydrateStoreLikeFn( // backlog parking in hydrateStoreFromAsyncIterable (and the module-local // onHydrationEnd it defers through) is unchanged — only how the code is // reached moved. -function hydrateStoreLike(coreFn: Function, fn: any, initialValue: any, options?: any) { +function hydrateStoreLike( + coreFn: Function, + replayFn: Function, + fn: any, + initialValue: any, + options?: any +) { markTopLevelSnapshotScope(); - return hydrateStoreLikeFn(coreFn, fn, initialValue, options, options?.ssrSource); + if (initialValue == null && options?.seedLoadingValue) + throw new Error("seedLoadingValue requires an explicit store seed"); + return hydrateStoreLikeFn(coreFn, replayFn, fn, initialValue, options, options?.ssrSource); } // --- Hydration-aware effect implementations --- @@ -1555,12 +1601,11 @@ export const createOptimistic: { }) as any; /** - * Creates a derived (projected) store — `createMemo` for stores. The - * derive function receives a mutable draft and either mutates it in - * place (canonical) or returns a new value. Either way the result is - * reconciled against the previous draft by `options.key` (default - * `"id"`), so surviving items keep their proxy identity — only - * added/removed items are created/disposed. + * Creates a derived (projected) store — `createMemo` for stores. With a + * seed, the derive receives a mutable draft and may mutate or replace it. + * Without a seed, the derive receives no draft and must return a complete + * plain object. Results are reconciled by `options.key` (default `"id"`), + * so surviving items keep their proxy identity. * * Returns the projected store directly (no setter — reads only). * @@ -1591,18 +1636,26 @@ export const createOptimistic: { * (`"server"` | `"hybrid"` | `"client"`) for the same client-vs-server * tradeoffs as the other primitives. See {@link HydrationSsrFields}. */ -export const createProjection: ( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, - options?: HydrationProjectionOptions -) => Refreshable> = ((...args: any[]) => { +export const createProjection: { + ( + fn: (() => T | Promise | AsyncIterable) & SeedlessRoot, + seed?: null, + options?: HydrationProjectionOptions + ): Refreshable>; + ( + fn: (draft: T) => void | T | Promise | AsyncIterable, + seed: NoFn | Store>, + options?: HydrationSeededProjectionOptions + ): Refreshable>; +} = ((...args: any[]) => { // `hydrating` can only be true once enableHydration() installed the // adapter slot (see createOptimistic above for the retention story). return sharedConfig.hydrating - ? _hydrateStoreLike!(coreProjection, args[0], args[1], args[2]) + ? _hydrateStoreLike!(coreProjection, coreProjectionHydrationReplay, args[0], args[1], args[2]) : (coreProjection as Function)(...args); }) as any; +type SeedlessRoot = Extract extends never ? unknown : never; type NoFn = T extends Function ? never : T; /** @@ -1628,11 +1681,10 @@ type NoFn = T extends Function ? never : T; * * - **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)`. - * `fn` may be sync, async, or an `AsyncIterable`; the projection's - * result reconciles against the existing store by `options.key` - * (default `"id"`) for stable identity. + * - **Derived form** — `createStore(fn, seed?, options?)`: a + * *projection store* whose contents are computed by `fn`. With a seed, + * `fn(draft)` may mutate or replace it. Without one, `fn()` returns a + * complete plain object. `fn` may be sync, async, or an `AsyncIterable`. * * @example * ```ts @@ -1671,15 +1723,20 @@ export const createStore: { options?: StoreOptions ): [get: Store, set: StoreSetter]; ( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, + fn: (() => T | Promise | AsyncIterable) & SeedlessRoot, + seed?: null, options?: HydrationProjectionOptions ): [get: Refreshable>, set: StoreSetter]; + ( + fn: (draft: T) => void | T | Promise | AsyncIterable, + seed: NoFn | Store>, + options?: HydrationSeededProjectionOptions + ): [get: Refreshable>, set: StoreSetter]; } = ((...args: any[]) => { // `hydrating` can only be true once enableHydration() installed the // adapter slot (see createOptimistic above for the retention story). return typeof args[0] === "function" && sharedConfig.hydrating - ? _hydrateStoreLike!(coreStore, args[0], args[1] ?? {}, args[2]) + ? _hydrateStoreLike!(coreStore, coreStoreHydrationReplay, args[0], args[1], args[2]) : (coreStore as Function)(...args); }) as any; @@ -1693,9 +1750,11 @@ export const createStore: { * single-value optimistic state, prefer `createOptimistic`. * * - **Plain form** — `createOptimisticStore(initialValue, options?)`. - * - **Derived form** — `createOptimisticStore(fn, seed, 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. + * With a seed, `fn` receives its mutable draft. Without one, `fn` + * receives no draft and returns a complete plain object. * * 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 @@ -1735,15 +1794,26 @@ export const createOptimisticStore: { options?: StoreOptions ): [get: Store, set: StoreSetter]; ( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, + fn: (() => T | Promise | AsyncIterable) & SeedlessRoot, + seed?: null, options?: HydrationProjectionOptions ): [get: Refreshable>, set: StoreSetter]; + ( + fn: (draft: T) => void | T | Promise | AsyncIterable, + seed: NoFn | Store>, + options?: HydrationSeededProjectionOptions + ): [get: Refreshable>, set: StoreSetter]; } = ((...args: any[]) => { // `hydrating` can only be true once enableHydration() installed the // adapter slot (see createOptimistic above for the retention story). return typeof args[0] === "function" && sharedConfig.hydrating - ? _hydrateStoreLike!(coreOptimisticStore, args[0], args[1] ?? {}, args[2]) + ? _hydrateStoreLike!( + coreOptimisticStore, + coreOptimisticStoreHydrationReplay, + args[0], + args[1], + args[2] + ) : (coreOptimisticStore as Function)(...args); }) as any; diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index 326b45a0f..5111533b6 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -60,6 +60,7 @@ export type { Omit, Owner, ProjectionOptions, + SeededProjectionOptions, Refreshable, Signal, SignalOptions, diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 63ce38bd0..66f83157d 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -34,6 +34,7 @@ export { snapshot, omit, storePath, $PROXY, $TRACK } from "@solidjs/signals"; import type { Accessor as SignalAccessor, ProjectionOptions, + SeededProjectionOptions, Refreshable, StoreOptions } from "@solidjs/signals"; @@ -481,6 +482,10 @@ interface ServerProjectionOptions extends ProjectionOptions { deferStream?: boolean; ssrSource?: SsrSourceMode; } +interface ServerSeededProjectionOptions extends SeededProjectionOptions { + deferStream?: boolean; + ssrSource?: SsrSourceMode; +} type ServerMemoOptions = MemoOptions & { /** * Keep this value out of the hydration payload. The subtree still hydrates; @@ -493,6 +498,7 @@ type ServerMemoOptions = MemoOptions & { }; type ServerSignalOptions = SignalOptions; type NoFn = T extends Function ? never : T; +type SeedlessRoot = Extract extends never ? unknown : never; /** * The pending source for BARE `ssrSource: "client"` (no declared commit #0): @@ -1780,14 +1786,23 @@ export function createStore( options?: StoreOptions ): [get: Store, set: StoreSetter]; export function createStore( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, + fn: (() => T | Promise | AsyncIterable) & SeedlessRoot, + seed?: null, options?: ServerProjectionOptions ): [get: Refreshable>, set: StoreSetter]; export function createStore( - first: T | Store | ((store: T) => void | T | Promise | AsyncIterable), - second?: T | Store, - third?: ServerProjectionOptions + fn: (draft: T) => void | T | Promise | AsyncIterable, + seed: NoFn | Store>, + options?: ServerSeededProjectionOptions +): [get: Refreshable>, set: StoreSetter]; +export function createStore( + first: + | T + | Store + | ((draft: T) => void | T | Promise | AsyncIterable) + | (() => T | Promise | AsyncIterable), + second?: T | Store | null, + third?: ServerSeededProjectionOptions ): [get: Store, set: StoreSetter] { if (typeof first === "function") { // Forward options: dropping them made ssrSource inert for derived stores — @@ -1796,7 +1811,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 Partial, third 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; @@ -1825,14 +1840,23 @@ export function createOptimisticStore( options?: StoreOptions ): [get: Store, set: StoreSetter]; export function createOptimisticStore( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, + fn: (() => T | Promise | AsyncIterable) & SeedlessRoot, + seed?: null, options?: ServerProjectionOptions ): [get: Refreshable>, set: StoreSetter]; export function createOptimisticStore( - first: T | Store | ((store: T) => void | T | Promise | AsyncIterable), - second?: T | Store, - third?: ServerProjectionOptions + fn: (draft: T) => void | T | Promise | AsyncIterable, + seed: NoFn | Store>, + options?: ServerSeededProjectionOptions +): [get: Refreshable>, set: StoreSetter]; +export function createOptimisticStore( + first: + | T + | Store + | ((draft: T) => void | T | Promise | AsyncIterable) + | (() => T | Promise | AsyncIterable), + second?: T | Store | null, + third?: ServerSeededProjectionOptions ): [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 @@ -1965,16 +1989,37 @@ function replaceState(target: T, next: T): void { Object.assign(target, next); } +function validateStoreValue(value: void | object): void { + if (value === undefined) throw new Error("A seedless store projection must produce a value"); + if (value === null || typeof value !== "object") + throw new Error("A seedless store projection must produce an object value"); + if (Array.isArray(value)) throw new Error("Array store projections require an explicit seed"); + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) + throw new Error("A seedless store projection must produce a plain object value"); +} + export function createProjection( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, + fn: (() => T | Promise | AsyncIterable) & SeedlessRoot, + seed?: null, options?: ServerProjectionOptions ): Refreshable>; export function createProjection( fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, - options?: ServerProjectionOptions + initialValue: NoFn | Store>, + options?: ServerSeededProjectionOptions +): Refreshable>; +export function createProjection( + fn: + | ((draft: T) => void | T | Promise | AsyncIterable) + | (() => T | Promise | AsyncIterable), + initialValue: NoFn | Store> | null | undefined, + options?: ServerSeededProjectionOptions ): Store { + const seeded = initialValue != null; + if (!seeded && options?.seedLoadingValue) + throw new Error("seedLoadingValue requires an explicit store seed"); + const seed = (seeded ? initialValue : {}) as T; const ctx = sharedConfig.context; const owner = createOwner(); // Slot memory (#3068), the projection flavor of the memo slots above @@ -2057,7 +2102,7 @@ export function createProjection( const runProjection = () => { resetOwnerForRerun(owner); - return runWithOwner(owner, () => fn(draft)); + return runWithOwner(owner, () => (seeded ? (fn as Function)(draft) : (fn as Function)())); }; let result: void | T | Promise | AsyncIterable; try { @@ -2073,6 +2118,7 @@ export function createProjection( () => normalizeAsync(runProjection()), deferred, (value: void | T) => { + if (!seeded) validateStoreValue(value); if (value !== undefined && value !== state && value !== draft) { replaceState(state, value as T); } @@ -2106,6 +2152,7 @@ export function createProjection( runFirst, deferred, (value: void | T) => { + if (!seeded) validateStoreValue(value); if (value !== undefined && value !== state && value !== draft) { replaceState(state, value as T); } @@ -2149,6 +2196,7 @@ export function createProjection( () => { patches.length = 0; const resolved = firstResult; + if (!seeded) validateStoreValue(resolved && !resolved.done ? resolved.value : undefined); if ( resolved && !resolved.done && @@ -2194,6 +2242,7 @@ export function createProjection( logDone = true; return; } + if (!seeded) validateStoreValue(r.value); // Apply the replacement through the patch-recording draft BEFORE // draining: its sets/deletes must ride in THIS batch, not sit // unsent behind an already-emitted empty one (#2948). @@ -2255,6 +2304,7 @@ export function createProjection( () => normalizeAsync(runProjection()), deferred, (value: void | T) => { + if (!seeded) validateStoreValue(value); if (value !== undefined && value !== state && value !== draft) { replaceState(state, value as T); } @@ -2271,6 +2321,7 @@ export function createProjection( } // Synchronous: fn either mutated state directly (void) or returned a new value + if (!seeded) validateStoreValue(result as void | T); if (result !== undefined && result !== state && result !== draft) { replaceState(state, result as T); } diff --git a/packages/solid/test/client-hydration.spec.ts b/packages/solid/test/client-hydration.spec.ts index b97633776..b92a445ae 100644 --- a/packages/solid/test/client-hydration.spec.ts +++ b/packages/solid/test/client-hydration.spec.ts @@ -1293,6 +1293,47 @@ describe("bare ssrSource 'client' — unasked through the gate, computes after", expect(store.name).toBe("landed"); expect(read(result)).toBe("landed"); }); + + test("bare seedless client store initializes after hydration", async () => { + startHydration({}); + let store: any; + let setStore: any; + let deriveRan = 0; + let receivedArgs: unknown[] | undefined; + let resolve!: (value: { name: string }) => void; + createRoot( + () => { + [store, setStore] = createStore<{ name: string }>( + (...args: unknown[]) => { + deriveRan++; + receivedArgs = args; + return new Promise(r => (resolve = r)); + }, + undefined, + { ssrSource: "client" } + ); + }, + { id: "t" } + ); + flush(); + + expect(deriveRan).toBe(0); + expect(() => store.name).toThrow(NotReadyError); + + stopHydration(); + flush(); + expect(deriveRan).toBe(1); + expect(receivedArgs).toEqual([]); + expect(() => store.name).toThrow(NotReadyError); + + resolve({ name: "computed" }); + await Promise.resolve(); + await Promise.resolve(); + flush(); + expect(store.name).toBe("computed"); + setStore((draft: any) => void (draft.name = "updated")); + expect(store.name).toBe("updated"); + }); }); // The hydration gate must not close the loading window: a sync prev-return @@ -1642,6 +1683,54 @@ describe("Async Iterable Hydration — createProjection", () => { expect(store.count).toBe(0); }); + test("seedless replay adopts its snapshot before applying patch batches", async () => { + const patches = [[["name"], "Bob"]]; + const ai = createBufferedAsyncIterable([{ name: "Alice", count: 0 }, patches]); + startHydration({ t0: ai }); + + let store: any; + let receivedArgs: unknown[] | undefined; + let valid = true; + let setVersion!: (value: number) => number; + createRoot( + () => { + const [version, set] = coreSignal(0); + setVersion = set; + store = createProjection<{ name: string; count: number }>((...args: unknown[]) => { + version(); + receivedArgs = args; + return valid ? { name: "client", count: 1 } : (undefined as any); + }); + }, + { id: "t" } + ); + flush(); + + expect(receivedArgs).toEqual([]); + expect(store.name).toBe("Alice"); + expect(store.count).toBe(0); + + stopHydration(); + await Promise.resolve(); + flush(); + + expect(store.name).toBe("Bob"); + expect(store.count).toBe(0); + + ai.complete(); + await Promise.resolve(); + await Promise.resolve(); + flush(); + + valid = false; + setVersion(1); + flush(); + await Promise.resolve(); + await Promise.resolve(); + flush(); + expect(() => store.name).toThrow("A seedless store projection must produce a value"); + }); + test("server+AI: deep nested patch application", async () => { const patches = [[["user", "profile", "bio"], "Updated"]]; const ai = createBufferedAsyncIterable([ diff --git a/packages/solid/test/server/ssr-async.spec.ts b/packages/solid/test/server/ssr-async.spec.ts index 9b19e38a4..2199708ab 100644 --- a/packages/solid/test/server/ssr-async.spec.ts +++ b/packages/solid/test/server/ssr-async.spec.ts @@ -1593,7 +1593,7 @@ describe("Stream Blocking / deferStream", () => { createRoot( () => { - createProjection(() => d.promise, {} as { name?: string }); + createProjection(() => d.promise, {} as any); }, { id: "t" } ); @@ -3735,6 +3735,31 @@ describe("Async Iterable — createProjection", () => { expect(store.name).toBe("resolved"); expect(store.count).toBe(42); }); + + test("createStore(fn) without a seed initializes from its first value", async () => { + const { context, serializeLog } = createStreamTrackingContext(); + sharedConfig.context = context; + + const { createStore: createServerStore } = await import("../../src/server/signals.js"); + const d = deferred<{ name: string; count: number }>(); + let store: any; + + createRoot( + () => { + [store] = createServerStore(() => d.promise); + }, + { id: "t" } + ); + + expect(serializeLog.length).toBe(1); + expect(() => store.name).toThrow(NotReadyError); + + d.resolve({ name: "resolved", count: 42 }); + await tick(); + + expect(store.name).toBe("resolved"); + expect(store.count).toBe(42); + }); }); describe("createDeepProxy unit tests", () => { From aca1706a807c833bc65d141713f061494c6dff2d Mon Sep 17 00:00:00 2001 From: Gabriel Valfridsson Date: Fri, 4 Sep 2026 19:24:10 +0200 Subject: [PATCH 2/3] refactor(signals): simplify store projection types --- packages/signals/src/store/index.ts | 12 +++---- packages/signals/src/store/next/optimistic.ts | 31 ++++++++-------- packages/signals/src/store/next/projection.ts | 35 ++++++++----------- packages/signals/src/store/store.ts | 8 ++--- packages/solid/src/client/hydration.ts | 26 +++++++------- packages/solid/src/server/signals.ts | 32 ++++++++--------- 6 files changed, 67 insertions(+), 77 deletions(-) diff --git a/packages/signals/src/store/index.ts b/packages/signals/src/store/index.ts index f0fe8ec29..46d8bd4bc 100644 --- a/packages/signals/src/store/index.ts +++ b/packages/signals/src/store/index.ts @@ -16,12 +16,12 @@ export { isWrappable, $TRACK, $PROXY, $TARGET } from "./store.js"; import type { NoFn, + NoArray, ProjectionOptions, SeededProjectionOptions, Store, StoreOptions, - StoreSetter, - SeedlessRoot + StoreSetter } from "./store.js"; import type { Refreshable } from "../core/index.js"; import { @@ -47,17 +47,17 @@ export { /** Public createStore: plain form `(initialValue, options?)` and derived writable * forms `(fn)` / `(fn, seed, options?)`. */ export function createStore( - initialValue: NoFn | Store>, + initialValue: T & NoFn, options?: StoreOptions ): [get: Store, set: StoreSetter]; export function createStore( - fn: (() => T | Promise | AsyncIterable) & SeedlessRoot, + fn: (() => T | Promise | AsyncIterable) & NoArray & NoFn, seed?: null, options?: ProjectionOptions ): [get: Refreshable>, set: StoreSetter]; export function createStore( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: NoFn | Store>, + fn: ((draft: T) => void | T | Promise | AsyncIterable) & NoFn, + seed: T, options?: SeededProjectionOptions ): [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 cf6ca38d3..fe9e464f6 100644 --- a/packages/signals/src/store/next/optimistic.ts +++ b/packages/signals/src/store/next/optimistic.ts @@ -54,19 +54,18 @@ import { installOptimisticEngine } from "../../core/optimistic.js"; import { $TARGET, markRawIngest, + type NoArray, type NoFn, type ProjectionOptions, type SeededProjectionOptions, type Store, type StoreOptions, - type StoreSetter, - type SeedlessRoot + type StoreSetter } from "../store.js"; import { runProjectionComputedNext, validateStoreValue, createReplayStoreValidator, - type ProjectionFn, type ProjectionResultValidator } from "./projection.js"; import { @@ -187,17 +186,17 @@ function familyHasLiveOverrides(fam: { overlaid?: Set }): boolean { } export function createOptimisticStoreNext( - initialValue: NoFn | Store>, + initialValue: T & NoFn, options?: StoreOptions ): [get: Store, set: StoreSetter]; export function createOptimisticStoreNext( - fn: (() => T | Promise | AsyncIterable) & SeedlessRoot, + fn: (() => T | Promise | AsyncIterable) & NoArray & NoFn, seed?: null, options?: ProjectionOptions ): [get: Refreshable>, set: StoreSetter]; export function createOptimisticStoreNext( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: NoFn | Store>, + fn: ((draft: T) => void | T | Promise | AsyncIterable) & NoFn, + seed: T, options?: SeededProjectionOptions ): [get: Refreshable>, set: StoreSetter]; export function createOptimisticStoreNext( @@ -205,7 +204,7 @@ export function createOptimisticStoreNext( | T | ((draft: T) => void | T | Promise | AsyncIterable) | (() => T | Promise | AsyncIterable), - second?: NoFn | Store> | StoreOptions | null, + second?: T | StoreOptions | null, third?: SeededProjectionOptions ): [get: Store, set: StoreSetter] { const derived = typeof first === "function"; @@ -214,10 +213,8 @@ export function createOptimisticStoreNext( if (!seeded && options?.seedLoadingValue) throw new Error("seedLoadingValue requires an explicit store seed"); const derive = - derived && !seeded - ? () => (first as () => T | Promise | AsyncIterable)() - : (first as T | ProjectionFn); - return createOptimisticStoreNextInternal( + derived && !seeded ? () => (first as () => T | Promise | AsyncIterable)() : first; + return createOptimisticStoreNextInternal( derive, (derived ? (second ?? {}) : first) as T, options, @@ -226,7 +223,7 @@ export function createOptimisticStoreNext( } function createOptimisticStoreNextInternal( - first: T | ProjectionFn, + first: T | ((draft: T) => void | T | Promise | AsyncIterable), initialValue: T, options?: SeededProjectionOptions, validateResult?: ProjectionResultValidator @@ -261,7 +258,7 @@ function createOptimisticStoreNextInternal( } if (derived) { - const fn = first as ProjectionFn; + const fn = first as (draft: T) => void | T | Promise | AsyncIterable; // #3146: an async settle event belongs to the flight's OWN transaction. // A live declared one re-enters (a merge if the generic settle path // already entered a graph-stamped stranger — the landing supersedes any @@ -387,16 +384,16 @@ function createOptimisticStoreNextInternal( /** @internal Hydration replay starts with a complete snapshot, then mutates a private draft. */ export function createOptimisticStoreHydrationReplayNext( - fn: ProjectionFn, + fn: (draft: T) => void | T | Promise | AsyncIterable, replaying: () => boolean, options?: ProjectionOptions ): [get: Refreshable>, set: StoreSetter] { - return createOptimisticStoreNextInternal( + return createOptimisticStoreNextInternal( fn, {} as T, options, createReplayStoreValidator(replaying) - ); + ) as [get: Refreshable>, set: StoreSetter]; } /** Resolve a retained transition through its merge chain (`_done` holds the diff --git a/packages/signals/src/store/next/projection.ts b/packages/signals/src/store/next/projection.ts index 429ce6afe..9865c7a72 100644 --- a/packages/signals/src/store/next/projection.ts +++ b/packages/signals/src/store/next/projection.ts @@ -32,11 +32,11 @@ import { markRawIngest, setWriteOverride, STORE_VALUE, + type NoArray, type NoFn, type ProjectionOptions, type SeededProjectionOptions, - type Store, - type SeedlessRoot + type Store } from "../store.js"; import { reconcileNextState } from "./reconcile.js"; import { storeSetterNext, wrapNext } from "./store.js"; @@ -52,9 +52,6 @@ export function validateStoreValue(value: void | object): void { throw new Error("A seedless store projection must produce a plain object value"); } -export type ProjectionFn = ( - draft: T -) => void | T | Promise | AsyncIterable; export type ProjectionResultValidator = ( value: void | T, owner: Computed @@ -205,7 +202,7 @@ function wrapDraft( } function createProjectionNextInternal( - fn: ProjectionFn, + fn: (draft: T) => void | T | Promise | AsyncIterable, initialValue: T, options?: SeededProjectionOptions, validateResult?: ProjectionResultValidator @@ -247,28 +244,26 @@ function createProjectionNextInternal( } export function createProjectionNext( - fn: (() => T | Promise | AsyncIterable) & SeedlessRoot, + fn: (() => T | Promise | AsyncIterable) & NoArray & NoFn, seed?: null, options?: ProjectionOptions ): Refreshable>; export function createProjectionNext( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: NoFn | Store>, + fn: ((draft: T) => void | T | Promise | AsyncIterable) & NoFn, + seed: T, options?: SeededProjectionOptions ): Refreshable>; export function createProjectionNext( fn: | ((draft: T) => void | T | Promise | AsyncIterable) | (() => T | Promise | AsyncIterable), - seed: NoFn | Store> | null | undefined, + seed: T | null | undefined, options?: SeededProjectionOptions ): Refreshable> { const seeded = seed != null; if (!seeded && options?.seedLoadingValue) throw new Error("seedLoadingValue requires an explicit store seed"); - const derive = seeded - ? (fn as ProjectionFn) - : () => (fn as () => T | Promise | AsyncIterable)(); + const derive = seeded ? fn : () => (fn as () => T | Promise | AsyncIterable)(); return createProjectionNextInternal( derive, (seed ?? {}) as T, @@ -279,7 +274,7 @@ export function createProjectionNext( /** @internal Hydration replay starts with a complete snapshot, then mutates a private draft. */ export function createProjectionHydrationReplayNext( - fn: ProjectionFn, + fn: (draft: T) => void | T | Promise | AsyncIterable, replaying: () => boolean, options?: ProjectionOptions ): Refreshable> { @@ -294,15 +289,13 @@ export function createStoreDerivedNext( fn: | ((draft: T) => void | T | Promise | AsyncIterable) | (() => T | Promise | AsyncIterable), - seed: NoFn | Store> | null | undefined, + seed: T | null | undefined, options?: SeededProjectionOptions ): [Refreshable>, (f: (draft: T) => T | void) => void] { const seeded = seed != null; if (!seeded && options?.seedLoadingValue) throw new Error("seedLoadingValue requires an explicit store seed"); - const derive = seeded - ? (fn as ProjectionFn) - : () => (fn as () => T | Promise | AsyncIterable)(); + const derive = seeded ? fn : () => (fn as () => T | Promise | AsyncIterable)(); return createStoreDerivedNextInternal( derive, (seed ?? {}) as T, @@ -312,7 +305,7 @@ export function createStoreDerivedNext( } function createStoreDerivedNextInternal( - fn: ProjectionFn, + fn: (draft: T) => void | T | Promise | AsyncIterable, initialValue: T, options?: SeededProjectionOptions, validateResult?: ProjectionResultValidator @@ -330,7 +323,7 @@ function createStoreDerivedNextInternal( /** @internal Hydration replay starts with a complete snapshot, then mutates a private draft. */ export function createStoreHydrationReplayNext( - fn: ProjectionFn, + fn: (draft: T) => void | T | Promise | AsyncIterable, replaying: () => boolean, options?: ProjectionOptions ): [Refreshable>, (f: (draft: T) => T | void) => void] { @@ -344,7 +337,7 @@ export function createStoreHydrationReplayNext( export function runProjectionComputedNext( wrappedStore: Store, - fn: ProjectionFn, + fn: (draft: T) => void | T | Promise | AsyncIterable, key: string | ((item: NonNullable) => any) | null, wrapCommit?: (write: () => void, value: T) => void, aroundDraftWrite?: (op: () => void) => void, diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index 0bfa76f59..196d0f537 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -56,10 +56,10 @@ export interface SeededProjectionOptions extends ProjectionOptions { */ seedLoadingValue?: boolean; } -/** Restricts seedless projections to non-callable object roots with a known proxy shape. @internal */ -export type SeedlessRoot = - Extract extends never ? unknown : never; -export type NoFn = T extends Function ? never : T; +/** Rejects array and tuple roots without transforming `T`. @internal */ +export type NoArray = Extract extends never ? unknown : never; +/** Rejects callable store roots without transforming `T`. @internal */ +export type NoFn = Extract extends never ? unknown : never; type DataNode = Signal; type DataNodes = Record; diff --git a/packages/solid/src/client/hydration.ts b/packages/solid/src/client/hydration.ts index 45be8a984..1eb7bf6ae 100644 --- a/packages/solid/src/client/hydration.ts +++ b/packages/solid/src/client/hydration.ts @@ -1638,13 +1638,13 @@ export const createOptimistic: { */ export const createProjection: { ( - fn: (() => T | Promise | AsyncIterable) & SeedlessRoot, + fn: (() => T | Promise | AsyncIterable) & NoArray & NoFn, seed?: null, options?: HydrationProjectionOptions ): Refreshable>; ( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: NoFn | Store>, + fn: ((draft: T) => void | T | Promise | AsyncIterable) & NoFn, + seed: T, options?: HydrationSeededProjectionOptions ): Refreshable>; } = ((...args: any[]) => { @@ -1655,8 +1655,8 @@ export const createProjection: { : (coreProjection as Function)(...args); }) as any; -type SeedlessRoot = Extract extends never ? unknown : never; -type NoFn = T extends Function ? never : T; +type NoArray = Extract extends never ? unknown : never; +type NoFn = Extract extends never ? unknown : never; /** * Creates a deeply-reactive store backed by a Proxy. Reads track each @@ -1719,17 +1719,17 @@ type NoFn = T extends Function ? never : T; */ export const createStore: { ( - initialValue: NoFn | Store>, + initialValue: T & NoFn, options?: StoreOptions ): [get: Store, set: StoreSetter]; ( - fn: (() => T | Promise | AsyncIterable) & SeedlessRoot, + fn: (() => T | Promise | AsyncIterable) & NoArray & NoFn, seed?: null, options?: HydrationProjectionOptions ): [get: Refreshable>, set: StoreSetter]; ( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: NoFn | Store>, + fn: ((draft: T) => void | T | Promise | AsyncIterable) & NoFn, + seed: T, options?: HydrationSeededProjectionOptions ): [get: Refreshable>, set: StoreSetter]; } = ((...args: any[]) => { @@ -1790,17 +1790,17 @@ export const createStore: { */ export const createOptimisticStore: { ( - initialValue: NoFn | Store>, + initialValue: T & NoFn, options?: StoreOptions ): [get: Store, set: StoreSetter]; ( - fn: (() => T | Promise | AsyncIterable) & SeedlessRoot, + fn: (() => T | Promise | AsyncIterable) & NoArray & NoFn, seed?: null, options?: HydrationProjectionOptions ): [get: Refreshable>, set: StoreSetter]; ( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: NoFn | Store>, + fn: ((draft: T) => void | T | Promise | AsyncIterable) & NoFn, + seed: T, options?: HydrationSeededProjectionOptions ): [get: Refreshable>, set: StoreSetter]; } = ((...args: any[]) => { diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 66f83157d..848e0a010 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -497,8 +497,8 @@ type ServerMemoOptions = MemoOptions & { serialize?: false; }; type ServerSignalOptions = SignalOptions; -type NoFn = T extends Function ? never : T; -type SeedlessRoot = Extract extends never ? unknown : never; +type NoArray = Extract extends never ? unknown : never; +type NoFn = Extract extends never ? unknown : never; /** * The pending source for BARE `ssrSource: "client"` (no declared commit #0): @@ -1782,17 +1782,17 @@ function setProperty(state: any, property: PropertyKey, value: any) { } export function createStore( - initialValue: NoFn | Store>, + initialValue: T & NoFn, options?: StoreOptions ): [get: Store, set: StoreSetter]; export function createStore( - fn: (() => T | Promise | AsyncIterable) & SeedlessRoot, + fn: (() => T | Promise | AsyncIterable) & NoArray & NoFn, seed?: null, options?: ServerProjectionOptions ): [get: Refreshable>, set: StoreSetter]; export function createStore( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: NoFn | Store>, + fn: ((draft: T) => void | T | Promise | AsyncIterable) & NoFn, + seed: T, options?: ServerSeededProjectionOptions ): [get: Refreshable>, set: StoreSetter]; export function createStore( @@ -1811,7 +1811,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, third as any); + const store = createProjection(first as any, second as T, third as any); return [store as Store, storeSetter(store as T)]; } const state = first as T; @@ -1836,17 +1836,17 @@ function storeSetter(state: T): StoreSetter { } export function createOptimisticStore( - initialValue: NoFn | Store>, + initialValue: T & NoFn, options?: StoreOptions ): [get: Store, set: StoreSetter]; export function createOptimisticStore( - fn: (() => T | Promise | AsyncIterable) & SeedlessRoot, + fn: (() => T | Promise | AsyncIterable) & NoArray & NoFn, seed?: null, options?: ServerProjectionOptions ): [get: Refreshable>, set: StoreSetter]; export function createOptimisticStore( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: NoFn | Store>, + fn: ((draft: T) => void | T | Promise | AsyncIterable) & NoFn, + seed: T, options?: ServerSeededProjectionOptions ): [get: Refreshable>, set: StoreSetter]; export function createOptimisticStore( @@ -2000,20 +2000,20 @@ function validateStoreValue(value: void | object): void { } export function createProjection( - fn: (() => T | Promise | AsyncIterable) & SeedlessRoot, + fn: (() => T | Promise | AsyncIterable) & NoArray & NoFn, seed?: null, options?: ServerProjectionOptions ): Refreshable>; export function createProjection( - fn: (draft: T) => void | T | Promise | AsyncIterable, - initialValue: NoFn | Store>, + fn: ((draft: T) => void | T | Promise | AsyncIterable) & NoFn, + initialValue: T, options?: ServerSeededProjectionOptions ): Refreshable>; export function createProjection( fn: | ((draft: T) => void | T | Promise | AsyncIterable) | (() => T | Promise | AsyncIterable), - initialValue: NoFn | Store> | null | undefined, + initialValue: T | null | undefined, options?: ServerSeededProjectionOptions ): Store { const seeded = initialValue != null; @@ -2047,7 +2047,7 @@ export function createProjection( if (slots) slots[slotId!] = proxy; return proxy; }; - const [state] = createStore(seed as NoFn); + const [state] = createStore(seed as T & NoFn); if (options?.ssrSource === "client") { // seedLoadingValue = declared commit #0: the seed renders. Bare = the From 66f41a4e477ecdde964eb6db95bf6814b5e57d35 Mon Sep 17 00:00:00 2001 From: Gabriel Valfridsson Date: Fri, 4 Sep 2026 21:22:56 +0200 Subject: [PATCH 3/3] perf(signals): reduce seedless store validation size --- packages/signals/src/store/next/optimistic.ts | 5 +++-- packages/signals/src/store/next/projection.ts | 22 ++++++++++--------- .../store/createProjection.async.test.ts | 8 ++++--- packages/solid/src/client/hydration.ts | 2 -- packages/solid/src/server/signals.ts | 7 ++---- packages/solid/test/client-hydration.spec.ts | 4 +++- 6 files changed, 25 insertions(+), 23 deletions(-) diff --git a/packages/signals/src/store/next/optimistic.ts b/packages/signals/src/store/next/optimistic.ts index fe9e464f6..d5c15c7b5 100644 --- a/packages/signals/src/store/next/optimistic.ts +++ b/packages/signals/src/store/next/optimistic.ts @@ -64,6 +64,7 @@ import { } from "../store.js"; import { runProjectionComputedNext, + validateSeedlessOptions, validateStoreValue, createReplayStoreValidator, type ProjectionResultValidator @@ -210,8 +211,7 @@ export function createOptimisticStoreNext( const derived = typeof first === "function"; const seeded = !derived || second != null; const options = (derived ? third : second) as SeededProjectionOptions | undefined; - if (!seeded && options?.seedLoadingValue) - throw new Error("seedLoadingValue requires an explicit store seed"); + if (!seeded) validateSeedlessOptions(options); const derive = derived && !seeded ? () => (first as () => T | Promise | AsyncIterable)() : first; return createOptimisticStoreNextInternal( @@ -388,6 +388,7 @@ export function createOptimisticStoreHydrationReplayNext( replaying: () => boolean, options?: ProjectionOptions ): [get: Refreshable>, set: StoreSetter] { + validateSeedlessOptions(options); return createOptimisticStoreNextInternal( fn, {} as T, diff --git a/packages/signals/src/store/next/projection.ts b/packages/signals/src/store/next/projection.ts index 9865c7a72..b28f2eae1 100644 --- a/packages/signals/src/store/next/projection.ts +++ b/packages/signals/src/store/next/projection.ts @@ -43,13 +43,15 @@ import { storeSetterNext, wrapNext } from "./store.js"; import type { StoreNextFamily } from "./target.js"; export function validateStoreValue(value: void | object): void { - if (value === undefined) throw new Error("A seedless store projection must produce a value"); - if (value === null || typeof value !== "object") - throw new Error("A seedless store projection must produce an object value"); - if (Array.isArray(value)) throw new Error("Array store projections require an explicit seed"); - const prototype = Object.getPrototypeOf(value); + const prototype = + value != null && typeof value === "object" ? Object.getPrototypeOf(value) : false; if (prototype !== Object.prototype && prototype !== null) - throw new Error("A seedless store projection must produce a plain object value"); + throw new Error(__DEV__ ? "A seedless store projection must produce a plain object value" : ""); +} + +export function validateSeedlessOptions(options?: SeededProjectionOptions): void { + if (options?.seedLoadingValue) + throw new Error(__DEV__ ? "seedLoadingValue requires an explicit store seed" : ""); } export type ProjectionResultValidator = ( @@ -261,8 +263,7 @@ export function createProjectionNext( options?: SeededProjectionOptions ): Refreshable> { const seeded = seed != null; - if (!seeded && options?.seedLoadingValue) - throw new Error("seedLoadingValue requires an explicit store seed"); + if (!seeded) validateSeedlessOptions(options); const derive = seeded ? fn : () => (fn as () => T | Promise | AsyncIterable)(); return createProjectionNextInternal( derive, @@ -278,6 +279,7 @@ export function createProjectionHydrationReplayNext( replaying: () => boolean, options?: ProjectionOptions ): Refreshable> { + validateSeedlessOptions(options); return createProjectionNextInternal(fn, {} as T, options, createReplayStoreValidator(replaying)) .store; } @@ -293,8 +295,7 @@ export function createStoreDerivedNext( options?: SeededProjectionOptions ): [Refreshable>, (f: (draft: T) => T | void) => void] { const seeded = seed != null; - if (!seeded && options?.seedLoadingValue) - throw new Error("seedLoadingValue requires an explicit store seed"); + if (!seeded) validateSeedlessOptions(options); const derive = seeded ? fn : () => (fn as () => T | Promise | AsyncIterable)(); return createStoreDerivedNextInternal( derive, @@ -327,6 +328,7 @@ export function createStoreHydrationReplayNext( replaying: () => boolean, options?: ProjectionOptions ): [Refreshable>, (f: (draft: T) => T | void) => void] { + validateSeedlessOptions(options); return createStoreDerivedNextInternal( fn, {} as T, diff --git a/packages/signals/tests/store/createProjection.async.test.ts b/packages/signals/tests/store/createProjection.async.test.ts index 9e825c271..25fa5e0a5 100644 --- a/packages/signals/tests/store/createProjection.async.test.ts +++ b/packages/signals/tests/store/createProjection.async.test.ts @@ -63,10 +63,10 @@ describe("Projection async behavior", () => { }; expect(() => createInvalid(() => undefined)).toThrow( - "A seedless store projection must produce a value" + "A seedless store projection must produce a plain object value" ); expect(() => createInvalid(() => [])).toThrow( - "Array store projections require an explicit seed" + "A seedless store projection must produce a plain object value" ); expect(() => createInvalid(() => new State())).toThrow( "A seedless store projection must produce a plain object value" @@ -76,7 +76,9 @@ describe("Projection async behavior", () => { flush(); await Promise.resolve(); await Promise.resolve(); - expect(() => asyncProjection.value).toThrow("A seedless store projection must produce a value"); + expect(() => asyncProjection.value).toThrow( + "A seedless store projection must produce a plain object value" + ); }); it("resolves async draft and transforms into new value", async () => { diff --git a/packages/solid/src/client/hydration.ts b/packages/solid/src/client/hydration.ts index 1eb7bf6ae..16adc56b3 100644 --- a/packages/solid/src/client/hydration.ts +++ b/packages/solid/src/client/hydration.ts @@ -1209,8 +1209,6 @@ function hydrateStoreLike( options?: any ) { markTopLevelSnapshotScope(); - if (initialValue == null && options?.seedLoadingValue) - throw new Error("seedLoadingValue requires an explicit store seed"); return hydrateStoreLikeFn(coreFn, replayFn, fn, initialValue, options, options?.ssrSource); } diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 848e0a010..77ad57e1d 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -1990,11 +1990,8 @@ function replaceState(target: T, next: T): void { } function validateStoreValue(value: void | object): void { - if (value === undefined) throw new Error("A seedless store projection must produce a value"); - if (value === null || typeof value !== "object") - throw new Error("A seedless store projection must produce an object value"); - if (Array.isArray(value)) throw new Error("Array store projections require an explicit seed"); - const prototype = Object.getPrototypeOf(value); + const prototype = + value != null && typeof value === "object" ? Object.getPrototypeOf(value) : false; if (prototype !== Object.prototype && prototype !== null) throw new Error("A seedless store projection must produce a plain object value"); } diff --git a/packages/solid/test/client-hydration.spec.ts b/packages/solid/test/client-hydration.spec.ts index b92a445ae..cece5c090 100644 --- a/packages/solid/test/client-hydration.spec.ts +++ b/packages/solid/test/client-hydration.spec.ts @@ -1728,7 +1728,9 @@ describe("Async Iterable Hydration — createProjection", () => { await Promise.resolve(); await Promise.resolve(); flush(); - expect(() => store.name).toThrow("A seedless store projection must produce a value"); + expect(() => store.name).toThrow( + "A seedless store projection must produce a plain object value" + ); }); test("server+AI: deep nested patch application", async () => {