diff --git a/.changeset/require-complete-store-seeds.md b/.changeset/require-complete-store-seeds.md new file mode 100644 index 000000000..72915d07b --- /dev/null +++ b/.changeset/require-complete-store-seeds.md @@ -0,0 +1,6 @@ +--- +"@solidjs/signals": patch +"solid-js": patch +--- + +Require complete values for projection store seeds. `createProjection`, derived `createStore`, and derived `createOptimisticStore` no longer accept `Partial`, preventing a store typed as `T` from being created without all required properties. diff --git a/packages/signals/src/store/index.ts b/packages/signals/src/store/index.ts index 0c7efc3ac..80ed97c0c 100644 --- a/packages/signals/src/store/index.ts +++ b/packages/signals/src/store/index.ts @@ -36,7 +36,7 @@ export function createStore( ): [get: Store, set: StoreSetter]; export function createStore( fn: (store: T) => void | T | Promise | AsyncIterable, - store: Partial | Store>, + store: NoFn | Store>, options?: ProjectionOptions ): [get: Refreshable>, set: StoreSetter]; export function createStore(first: any, second?: any, third?: any): any { diff --git a/packages/signals/src/store/next/optimistic.ts b/packages/signals/src/store/next/optimistic.ts index 6e924adaf..9d4936e26 100644 --- a/packages/signals/src/store/next/optimistic.ts +++ b/packages/signals/src/store/next/optimistic.ts @@ -175,6 +175,14 @@ function familyHasLiveOverrides(fam: { overlaid?: Set }): boolean { return false; } +export function createOptimisticStoreNext( + store: NoFn | Store> +): [get: Store, set: StoreSetter]; +export function createOptimisticStoreNext( + fn: (store: T) => void | T | Promise | AsyncIterable, + store: NoFn | Store>, + options?: ProjectionOptions +): [get: Store, set: StoreSetter]; export function createOptimisticStoreNext( first: T | ((store: T) => void | T | Promise | AsyncIterable), second?: NoFn | Store>, diff --git a/packages/signals/src/store/next/projection.ts b/packages/signals/src/store/next/projection.ts index 7ff2afbeb..b46a7f9d1 100644 --- a/packages/signals/src/store/next/projection.ts +++ b/packages/signals/src/store/next/projection.ts @@ -177,7 +177,7 @@ function wrapDraft( function createProjectionNextInternal( fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial, + seed: T, options?: ProjectionOptions ) { const fam: StoreNextFamily = { @@ -211,7 +211,7 @@ function createProjectionNextInternal( export function createProjectionNext( fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, + seed: NoFn | Store>, options?: ProjectionOptions ): Refreshable> { return createProjectionNextInternal(fn, seed, options).store; @@ -222,7 +222,7 @@ export function createProjectionNext( * same-flush dependency change). */ export function createStoreDerivedNext( fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, + seed: NoFn | Store>, options?: ProjectionOptions ): [Refreshable>, (f: (draft: T) => T | void) => void] { const { store, node } = createProjectionNextInternal(fn, seed, options); 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 c1438ac00..bef3823b9 100644 --- a/packages/signals/tests/store/store.type-tests.ts +++ b/packages/signals/tests/store/store.type-tests.ts @@ -32,24 +32,29 @@ import { store.count satisfies number; } -// ── createStore (projection) — partial seed ─────────────────────────── +// ── projection seeds must be complete ──────────────────────────────── -{ - const [store] = createStore(() => ({ foo: true }), {}); - store.foo satisfies boolean; -} +type UserState = { user: { name: string }; ready: boolean }; -{ - const [store] = createStore(() => ({ a: 1, b: "hello" }), {}); - store.a satisfies number; - store.b satisfies string; -} +// @ts-expect-error An inferred seed cannot omit required properties. +createStore(() => ({ user: { name: "Ada" }, ready: true }), { ready: false }); -{ - const [store] = createStore(() => ({ a: 1, b: "hello" }), { a: 0 }); - store.a satisfies number; - store.b satisfies string; -} +// @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) ─────── @@ -75,17 +80,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 ─────────────────────────────── @@ -113,12 +109,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 5733581e7..4e84e2bb4 100644 --- a/packages/solid/src/client/hydration.ts +++ b/packages/solid/src/client/hydration.ts @@ -1592,7 +1592,7 @@ export const createOptimistic: { */ export const createProjection: ( fn: (draft: T) => void | T | Promise | AsyncIterable, - initialValue: Partial | Store>, + initialValue: NoFn | Store>, options?: HydrationProjectionOptions ) => Refreshable> = ((...args: any[]) => { // `hydrating` can only be true once enableHydration() installed the diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index a5d41ea45..4a08a48e3 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -495,6 +495,7 @@ type ServerMemoOptions = MemoOptions & { }; type ServerSignalOptions = SignalOptions; type ServerStoreOptions = ServerSsrOptions; +type NoFn = T extends Function ? never : T; /** * The pending source for BARE `ssrSource: "client"` (no declared commit #0): @@ -1778,12 +1779,12 @@ function setProperty(state: any, property: PropertyKey, value: any) { } export function createStore( - store: T | Store, + store: NoFn | Store>, options?: { name?: string; shallow?: boolean } ): [get: Store, set: StoreSetter]; export function createStore( fn: (store: T) => void | T | Promise, - store: Partial | Store, + store: NoFn | Store>, options?: ServerStoreOptions & { name?: string; shallow?: boolean } ): [get: Store, set: StoreSetter]; export function createStore( @@ -1798,7 +1799,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 T, options as any); + const store = createProjection(first as any, second as NoFn, options as any); return [store as Store, storeSetter(store as T)]; } const state = first as T; @@ -1823,12 +1824,12 @@ function storeSetter(state: T): StoreSetter { } export function createOptimisticStore( - store: T | Store, + store: NoFn | Store>, options?: { name?: string; shallow?: boolean } ): [get: Store, set: StoreSetter]; export function createOptimisticStore( fn: (store: T) => void | T | Promise, - store: Partial | Store, + store: NoFn | Store>, options?: ServerStoreOptions & { name?: string; shallow?: boolean } ): [get: Store, set: StoreSetter]; export function createOptimisticStore( @@ -1969,7 +1970,7 @@ function replaceState(target: T, next: T): void { export function createProjection( fn: (draft: T) => void | T | Promise | AsyncIterable, - initialValue: Partial | Store, + initialValue: NoFn | Store>, options?: ServerStoreOptions ): Store { const ctx = sharedConfig.context; @@ -1999,7 +2000,7 @@ export function createProjection( if (slots) slots[slotId!] = proxy; return proxy; }; - const [state] = createStore(initialValue as T); + const [state] = createStore(initialValue as NoFn); if (options?.ssrSource === "client") { // seedLoadingValue = declared commit #0: the seed renders. Bare = the diff --git a/packages/solid/test/server/ssr-async.spec.ts b/packages/solid/test/server/ssr-async.spec.ts index 9b19e38a4..840c78089 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" } );