diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3b3e8316c..e2fd692e6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -302,3 +302,76 @@ export function batch(cb: () => T): T { } } } + +type Signalable = Array | ((...args: any[]) => any) | string | boolean | number | bigint | symbol | undefined | null; + +export type Storeable = { + [key: string]: (() => any) | Signalable | Storeable +}; + +type ReadOnlyDeep = { + readonly [P in keyof T]: ReadOnlyDeep; +} + +export interface IDeepSignal { value: ReadOnlyDeep, peek: () => ReadOnlyDeep } + +export type DeepSignal = IDeepSignal & { + [K in keyof T]: + T[K] extends Signalable ? Signal : + T[K] extends Storeable ? DeepSignal : + Signal; +}; + +export class DeepSignalImpl implements IDeepSignal { + get value(): ReadOnlyDeep { + return getValue(this as any as DeepSignal); + } + + set value(payload: ReadOnlyDeep) { + batch(() => setValue(this as any as DeepSignal, payload)); + } + + peek(): ReadOnlyDeep { + return getValue(this as any as DeepSignal, { peek: true }); + } +} + +export const deepSignal = (initialValue: T): DeepSignal => + Object.assign( + new DeepSignalImpl(), + Object.entries(initialValue).reduce( + (acc, [key, value]) => { + if (["value", "peek"].some(iKey => iKey === key)) { + throw new Error(`${key} is a reserved property name`); + } else if (typeof value !== "object" || value === null || Array.isArray(value)) { + acc[key] = signal(value); + } else { + acc[key] = deepSignal(value); + } + return acc; + }, {} as { [key: string]: unknown }) + ) as DeepSignal; + +const setValue = >( + deepSignal: T, + payload: U +): void => + Object.keys(payload).forEach((key: keyof U) => + deepSignal[key].value = payload[key] + ); + +const getValue = >( + deepSignal: T, + { peek = false }: { peek?: boolean } = {} +): ReadOnlyDeep => + Object.entries(deepSignal).reduce(( + acc, + [key, value] + ) => { + if (value instanceof Signal) { + acc[key] = peek ? value.peek() : value.value; + } else if (value instanceof DeepSignalImpl) { + acc[key] = getValue(value as DeepSignal, { peek }); + } + return acc; + }, {} as { [key: string]: unknown }) as ReadOnlyDeep; diff --git a/packages/core/test/signal.test.tsx b/packages/core/test/signal.test.tsx index e76a9d076..d6c96408c 100644 --- a/packages/core/test/signal.test.tsx +++ b/packages/core/test/signal.test.tsx @@ -1,4 +1,4 @@ -import { signal, computed, effect, batch } from "@preact/signals-core"; +import { signal, computed, effect, batch, deepSignal, Signal } from "@preact/signals-core"; describe("signal", () => { it("should return value", () => { @@ -211,7 +211,6 @@ describe("computed()", () => { const a = signal(2); const b = computed(() => a.value - 1); - const c = computed(() => a.value + 1); const d = computed(() => a.value + b.value); @@ -682,3 +681,36 @@ describe("batch/transaction", () => { expect(result).to.equal("aa bb cc"); }); }); + +describe("deepSignal", () => { + it("turns deeply nested atomic properties into Signals with peek and value", () => { + const a = deepSignal({ + b: { + c: { + d: "a" + } + } + }); + expect(a.b.c.d instanceof Signal).to.equal(true); + expect(a.value.b.c.d).to.equal("a"); + expect(a.b.value.c.d).to.equal("a"); + expect(a.b.c.value.d).to.equal("a"); + expect(a.b.c.d.value).to.equal("a"); + a.value = { b: { c: { d: "b" }} }; + expect(a.peek().b.c.d).to.equal("b"); + expect(a.b.peek().c.d).to.equal("b"); + expect(a.b.c.peek().d).to.equal("b"); + expect(a.b.c.d.peek()).to.equal("b"); + a.b.c.d.value = "c" + expect(a.value.b.c.d).to.equal("c"); + expect(a.b.value.c.d).to.equal("c"); + expect(a.b.c.value.d).to.equal("c"); + expect(a.b.c.d.value).to.equal("c"); + }); + + it("doesn't allow you to set a property with keys named peek or value", () => { + expect(() => deepSignal({ value: "a" })).to.throw(/reserved property name/); + expect(() => deepSignal({ peek: "a" })).to.throw(/reserved property name/); + }); + +}); \ No newline at end of file diff --git a/packages/preact/src/index.ts b/packages/preact/src/index.ts index 7d6446729..6edc83ad1 100644 --- a/packages/preact/src/index.ts +++ b/packages/preact/src/index.ts @@ -6,6 +6,11 @@ import { batch, effect, Signal, + deepSignal, + DeepSignalImpl, + type IDeepSignal, + type DeepSignal, + type Storeable, type ReadonlySignal, } from "@preact/signals-core"; import { @@ -17,7 +22,7 @@ import { ElementUpdater, } from "./internal"; -export { signal, computed, batch, effect, Signal, type ReadonlySignal }; +export { signal, computed, batch, effect, Signal, DeepSignalImpl, deepSignal, type ReadonlySignal, type Storeable, type IDeepSignal, type DeepSignal }; // Components that have a pending Signal update: (used to bypass default sCU:false) const hasPendingUpdate = new WeakSet(); @@ -300,6 +305,10 @@ export function useComputed(compute: () => T) { return useMemo(() => computed(() => $compute.current()), []); } +export function useDeepSignal (initial: T | (() => T)) { + return useMemo(() => deepSignal(typeof initial === "function" ? initial() : initial), []); +} + /** * @todo Determine which Reactive implementation we'll be using. * @internal diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index d85875136..bbe8453fa 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -12,11 +12,16 @@ import { batch, effect, Signal, + deepSignal, + DeepSignalImpl, + type Storeable, + type IDeepSignal, + type DeepSignal, type ReadonlySignal, } from "@preact/signals-core"; import { Updater, ReactOwner, ReactDispatcher } from "./internal"; -export { signal, computed, batch, effect, Signal, type ReadonlySignal }; +export { signal, computed, batch, effect, Signal, DeepSignalImpl, deepSignal, type ReadonlySignal, type Storeable, type IDeepSignal, type DeepSignal }; /** * Install a middleware into React.createElement to replace any Signals in props with their value. @@ -151,3 +156,7 @@ export function useComputed(compute: () => T) { $compute.current = compute; return useMemo(() => computed(() => $compute.current()), []); } + +export function useDeepSignal (initial: T | (() => T)) { + return useMemo(() => deepSignal(typeof initial === "function" ? initial() : initial), []); +}