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..46d8bd4bc 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, + NoArray, + ProjectionOptions, + SeededProjectionOptions, + Store, + StoreOptions, + StoreSetter +} 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>, + initialValue: T & NoFn, options?: StoreOptions ): [get: Store, set: StoreSetter]; export function createStore( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, + fn: (() => T | Promise | AsyncIterable) & NoArray & NoFn, + seed?: null, options?: ProjectionOptions ): [get: Refreshable>, set: StoreSetter]; +export function createStore( + 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 { 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..d5c15c7b5 100644 --- a/packages/signals/src/store/next/optimistic.ts +++ b/packages/signals/src/store/next/optimistic.ts @@ -54,13 +54,21 @@ import { installOptimisticEngine } from "../../core/optimistic.js"; import { $TARGET, markRawIngest, + type NoArray, type NoFn, type ProjectionOptions, + type SeededProjectionOptions, type Store, type StoreOptions, type StoreSetter } from "../store.js"; -import { runProjectionComputedNext } from "./projection.js"; +import { + runProjectionComputedNext, + validateSeedlessOptions, + validateStoreValue, + createReplayStoreValidator, + type ProjectionResultValidator +} from "./projection.js"; import { bumpDeep, authoritativeRead, @@ -179,18 +187,46 @@ 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: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, + fn: (() => T | Promise | AsyncIterable) & NoArray & NoFn, + 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) & NoFn, + seed: T, + options?: SeededProjectionOptions +): [get: Refreshable>, set: StoreSetter]; +export function createOptimisticStoreNext( + first: + | T + | ((draft: T) => void | T | Promise | AsyncIterable) + | (() => T | Promise | AsyncIterable), + second?: T | 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) validateSeedlessOptions(options); + const derive = + derived && !seeded ? () => (first as () => T | Promise | AsyncIterable)() : first; + return createOptimisticStoreNextInternal( + derive, + (derived ? (second ?? {}) : first) as T, + options, + derived && !seeded ? validateStoreValue : undefined + ); +} + +function createOptimisticStoreNextInternal( + first: T | ((draft: T) => void | T | Promise | AsyncIterable), + 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 +234,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 +258,7 @@ export function createOptimisticStoreNext( } if (derived) { - const fn = first as (store: T) => void | T | Promise | AsyncIterable; + 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 @@ -321,7 +355,8 @@ export function createOptimisticStoreNext( fn, options?.key === undefined ? "id" : options.key, wrapCommit, - aroundDraftWrite + aroundDraftWrite, + validateResult ) ); } finally { @@ -347,6 +382,21 @@ export function createOptimisticStoreNext( ]; } +/** @internal Hydration replay starts with a complete snapshot, then mutates a private draft. */ +export function createOptimisticStoreHydrationReplayNext( + fn: (draft: T) => void | T | Promise | AsyncIterable, + replaying: () => boolean, + options?: ProjectionOptions +): [get: Refreshable>, set: StoreSetter] { + validateSeedlessOptions(options); + return createOptimisticStoreNextInternal( + fn, + {} as T, + options, + createReplayStoreValidator(replaying) + ) as [get: Refreshable>, set: StoreSetter]; +} + /** 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..b28f2eae1 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 { @@ -31,14 +32,41 @@ import { markRawIngest, setWriteOverride, STORE_VALUE, + type NoArray, type NoFn, type ProjectionOptions, + type SeededProjectionOptions, type Store } 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 { + const prototype = + value != null && typeof value === "object" ? Object.getPrototypeOf(value) : false; + if (prototype !== Object.prototype && prototype !== null) + 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 = ( + 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 @@ -177,20 +205,21 @@ function wrapDraft( function createProjectionNextInternal( fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial, - options?: ProjectionOptions + 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 +227,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; @@ -210,22 +246,72 @@ function createProjectionNextInternal( } export function createProjectionNext( + fn: (() => T | Promise | AsyncIterable) & NoArray & NoFn, + seed?: null, + options?: ProjectionOptions +): Refreshable>; +export function createProjectionNext( + 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: T | null | undefined, + options?: SeededProjectionOptions +): Refreshable> { + const seeded = seed != null; + if (!seeded) validateSeedlessOptions(options); + const derive = seeded ? fn : () => (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: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, + replaying: () => boolean, options?: ProjectionOptions ): Refreshable> { - return createProjectionNextInternal(fn, seed, options).store; + validateSeedlessOptions(options); + 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) + | (() => T | Promise | AsyncIterable), + seed: T | null | undefined, + options?: SeededProjectionOptions +): [Refreshable>, (f: (draft: T) => T | void) => void] { + const seeded = seed != null; + if (!seeded) validateSeedlessOptions(options); + const derive = seeded ? fn : () => (fn as () => T | Promise | AsyncIterable)(); + return createStoreDerivedNextInternal( + derive, + (seed ?? {}) as T, + options, + seeded ? undefined : validateStoreValue + ); +} + +function createStoreDerivedNextInternal( fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, - options?: ProjectionOptions + 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 +322,28 @@ export function createStoreDerivedNext( ]; } +/** @internal Hydration replay starts with a complete snapshot, then mutates a private draft. */ +export function createStoreHydrationReplayNext( + fn: (draft: T) => void | T | Promise | AsyncIterable, + replaying: () => boolean, + options?: ProjectionOptions +): [Refreshable>, (f: (draft: T) => T | void) => void] { + validateSeedlessOptions(options); + return createStoreDerivedNextInternal( + fn, + {} as T, + options, + createReplayStoreValidator(replaying) + ); +} + export function runProjectionComputedNext( wrappedStore: Store, fn: (draft: T) => void | T | Promise | AsyncIterable, 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 +366,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..196d0f537 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,7 +56,10 @@ export interface ProjectionOptions extends StoreOptions { */ seedLoadingValue?: boolean; } -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/signals/tests/store/createProjection.async.test.ts b/packages/signals/tests/store/createProjection.async.test.ts index 5a271c306..25fa5e0a5 100644 --- a/packages/signals/tests/store/createProjection.async.test.ts +++ b/packages/signals/tests/store/createProjection.async.test.ts @@ -28,6 +28,59 @@ 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 plain object value" + ); + expect(() => createInvalid(() => [])).toThrow( + "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" + ); + + const asyncProjection = createProjection(async () => undefined); + flush(); + await Promise.resolve(); + await Promise.resolve(); + expect(() => asyncProjection.value).toThrow( + "A seedless store projection must produce a plain object 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..16adc56b3 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,15 @@ 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); + return hydrateStoreLikeFn(coreFn, replayFn, fn, initialValue, options, options?.ssrSource); } // --- Hydration-aware effect implementations --- @@ -1555,12 +1599,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,19 +1634,27 @@ 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) & NoArray & NoFn, + seed?: null, + options?: HydrationProjectionOptions + ): Refreshable>; + ( + fn: ((draft: T) => void | T | Promise | AsyncIterable) & NoFn, + seed: T, + 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 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 @@ -1628,11 +1679,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 @@ -1667,19 +1717,24 @@ type NoFn = T extends Function ? never : T; */ export const createStore: { ( - initialValue: NoFn | Store>, + initialValue: T & NoFn, options?: StoreOptions ): [get: Store, set: StoreSetter]; ( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, + fn: (() => T | Promise | AsyncIterable) & NoArray & NoFn, + seed?: null, options?: HydrationProjectionOptions ): [get: Refreshable>, set: StoreSetter]; + ( + fn: ((draft: T) => void | T | Promise | AsyncIterable) & NoFn, + seed: T, + 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 +1748,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 @@ -1731,19 +1788,30 @@ export const createStore: { */ export const createOptimisticStore: { ( - initialValue: NoFn | Store>, + initialValue: T & NoFn, options?: StoreOptions ): [get: Store, set: StoreSetter]; ( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, + fn: (() => T | Promise | AsyncIterable) & NoArray & NoFn, + seed?: null, options?: HydrationProjectionOptions ): [get: Refreshable>, set: StoreSetter]; + ( + fn: ((draft: T) => void | T | Promise | AsyncIterable) & NoFn, + seed: T, + 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..77ad57e1d 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; @@ -492,7 +497,8 @@ type ServerMemoOptions = MemoOptions & { serialize?: false; }; type ServerSignalOptions = SignalOptions; -type NoFn = T extends Function ? never : T; +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): @@ -1776,18 +1782,27 @@ 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: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, + fn: (() => T | Promise | AsyncIterable) & NoArray & NoFn, + 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) & NoFn, + seed: T, + 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 T, third as any); return [store as Store, storeSetter(store as T)]; } const state = first as T; @@ -1821,18 +1836,27 @@ function storeSetter(state: T): StoreSetter { } export function createOptimisticStore( - initialValue: NoFn | Store>, + initialValue: T & NoFn, options?: StoreOptions ): [get: Store, set: StoreSetter]; export function createOptimisticStore( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, + fn: (() => T | Promise | AsyncIterable) & NoArray & NoFn, + 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) & NoFn, + seed: T, + 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,34 @@ function replaceState(target: T, next: T): void { Object.assign(target, next); } +function validateStoreValue(value: void | object): void { + 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"); +} + export function createProjection( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, + fn: (() => T | Promise | AsyncIterable) & NoArray & NoFn, + seed?: null, options?: ServerProjectionOptions ): Refreshable>; export function createProjection( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, - options?: ServerProjectionOptions + 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: T | 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 @@ -2002,7 +2044,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 @@ -2057,7 +2099,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 +2115,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 +2149,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 +2193,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 +2239,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 +2301,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 +2318,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..cece5c090 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,56 @@ 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 plain object 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", () => {