diff --git a/.changeset/hip-beans-shave.md b/.changeset/hip-beans-shave.md new file mode 100644 index 000000000..710c730b8 --- /dev/null +++ b/.changeset/hip-beans-shave.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/destructure": minor +--- + +new option 'normalize' which converts all values to accessor functions diff --git a/packages/destructure/README.md b/packages/destructure/README.md index 5bd39e44f..0fb6c15a1 100644 --- a/packages/destructure/README.md +++ b/packages/destructure/README.md @@ -33,7 +33,8 @@ import { destructure } from "@solid-primitives/destructure"; `destructure` is an reactive primitive, hence needs to be used under an reactive root. Pass an reactive object or a signal as it's first argument, and configure it's behavior via options: -- `memo` - wraps accessors in `createMemo`, making each property update independently. _(enabled by default for signal source)_ +- `memo` - if true: wraps accessors in `createMemo`, making each property update independently. _(enabled by default for signal source)_ +- `memo` - if "normalize": turn all static values to accessors e.g. `{ a: 1 } => { a: () => 1 }` but keep all functions and accessors as they are. So after destructuring all destructured props are functions. - `lazy` - property accessors are created on key read. enable if you want to only a subset of source properties, or use properties initially missing - `deep` - destructure nested objects diff --git a/packages/destructure/package.json b/packages/destructure/package.json index 25ff521f6..d68bb02d7 100644 --- a/packages/destructure/package.json +++ b/packages/destructure/package.json @@ -3,6 +3,9 @@ "version": "0.1.14", "description": "Primitives for destructuring reactive objects – like props or stores – or signals of them into a separate accessors updated individually.", "author": "Damian Tarnawski @thetarnav ", + "contributors": [ + "Martin Rapp @madaxen86 " + ], "license": "MIT", "homepage": "https://github.com/solidjs-community/solid-primitives/tree/main/packages/destructure#readme", "repository": { diff --git a/packages/destructure/src/index.ts b/packages/destructure/src/index.ts index cb155a670..0e3514477 100644 --- a/packages/destructure/src/index.ts +++ b/packages/destructure/src/index.ts @@ -1,5 +1,12 @@ import { createMemo, Accessor, runWithOwner, getOwner, MemoOptions } from "solid-js"; -import { access, MaybeAccessor, AnyObject, Values, AnyFunction } from "@solid-primitives/utils"; +import { + access, + MaybeAccessor, + AnyObject, + Values, + AnyFunction, + MaybeAccessorValue, +} from "@solid-primitives/utils"; type ReactiveSource = [] | any[] | AnyObject; @@ -7,27 +14,42 @@ export type DestructureOptions = MemoOptions memo?: boolean; lazy?: boolean; deep?: boolean; + normalize?: boolean; }; +type FunctionWithParams = T extends (...args: infer P) => infer R + ? P extends [] // Check if the parameter list is empty + ? never + : (...args: P) => R + : never; +type ReturnFunction = T extends (...args: infer P) => infer R + ? P extends [] // Check if the parameter list is empty + ? R extends FunctionWithParams //if empty check if the returned value is a function with params + ? R //return the function with params e.g. passed prop "() => (foo) => bar" will return "(foo) => bar" + : T //no params and no returned function with params return original prop which is already a function / Accessor + : T // T is a funtion with params return that function e.g. "(foo) => bar" will stay "(foo) => bar" + : () => T; // prop was static value return function "foo" will return "()=> foo" +type ReturnValue = N extends true ? ReturnFunction : Accessor; -export type Spread = { - readonly [K in keyof T]: Accessor; +export type Spread = { + readonly [K in keyof T]: ReturnValue; }; -export type DeepSpread = { + +export type DeepSpread = { readonly [K in keyof T]: T[K] extends ReactiveSource ? T[K] extends AnyFunction - ? Accessor - : DeepSpread - : Accessor; + ? ReturnValue + : DeepSpread + : ReturnValue; }; -export type Destructure = { - readonly [K in keyof T]-?: Accessor; +export type Destructure = { + readonly [K in keyof T]-?: ReturnValue; }; -export type DeepDestructure = { +export type DeepDestructure = { readonly [K in keyof T]-?: T[K] extends ReactiveSource ? T[K] extends AnyFunction - ? Accessor - : DeepDestructure - : Accessor; + ? ReturnValue + : DeepDestructure + : ReturnValue; }; const isReactiveObject = (value: any): boolean => typeof value === "object" && value !== null; @@ -58,8 +80,15 @@ function createProxyCache(obj: object, get: (key: any) => any): any { * @param source reactive object or signal returning one * @param options memo options + primitive configuration: * - `memo` - wraps accessors in `createMemo`, making each property update independently. *(enabled by default for signal source)* + * - `normalize` - turn all static values and getters to accessors, but keep all callbacks and accessors as they are. + * ```ts + * { a: 1, get b() { return foo() }, c: () => bar(), d: (a: string) => {} } + * // becomes + * { a: () => 1, b: () => foo(), c: () => bar(), d: (a string) => {} } + * ``` * - `lazy` - property accessors are created on key read. enable if you want to only a subset of source properties, or use properties initially missing * - `deep` - destructure nested objects + * @returns object of the same keys as the source, but with values turned into accessors. * @example // spread tuples * const [first, second, third] = destructure(() => [1,2,3]) @@ -75,18 +104,23 @@ export function destructure, options?: O, ): O extends { lazy: true; deep: true } - ? DeepDestructure - : O["lazy"] extends true - ? Destructure + ? DeepDestructure + : O extends { lazy: true } + ? Destructure : O["deep"] extends true - ? DeepSpread - : Spread { + ? DeepSpread + : Spread { const config: DestructureOptions = options ?? {}; const memo = config.memo ?? typeof source === "function"; - const getter = - typeof source === "function" - ? (key: any) => () => source()[key] - : (key: any) => () => source[key]; + + const _source = () => (typeof source === "function" ? source() : source); + const getter = (key: any) => { + const accessedValue = () => getNormalizedValue(_source()[key]); + //If accessedValue() is a function with params return the original function + if (typeof accessedValue() === "function" && hasParams(accessedValue())) return accessedValue(); + return accessedValue; + }; + const obj = access(source); // lazy (use proxy) @@ -96,7 +130,9 @@ export function destructure destructure(calc, { ...config, memo })); - return memo ? runWithOwner(owner, () => createMemo(calc, undefined, options)) : calc; + return memo && (!config.normalize || hasParams(calc)) + ? runWithOwner(owner, () => createMemo(calc, undefined, options)) + : calc; }); } @@ -106,7 +142,24 @@ export function destructure>(v: T): MaybeAccessorValue => + typeof v === "function" && !hasParams(v) ? v() : v; + +function hasParams(func: any) { + // Convert the function to a string and check if it includes "arguments" + if (typeof func !== "function") return false; + if (func.length > 0) return true; + const funcString = func.toString(); + const paramsPos = funcString.match(/\(.*?\)/); //get pos of first parantethes + return funcString.includes("arguments") || /\(\s*\.\.\.\s*[^\)]+\)/.test(paramsPos[0]); +} diff --git a/packages/destructure/test/destructure.test.ts b/packages/destructure/test/destructure.test.ts index 6d5e6b078..97ee93c83 100644 --- a/packages/destructure/test/destructure.test.ts +++ b/packages/destructure/test/destructure.test.ts @@ -1,5 +1,5 @@ -import { describe, test, expect } from "vitest"; import { createComputed, createRoot, createSignal } from "solid-js"; +import { describe, expect, test } from "vitest"; import { destructure } from "../src/index.js"; describe("destructure", () => { @@ -248,4 +248,218 @@ describe("destructure", () => { dispose(); })); + test("spread object normalize and deep", () => + createRoot(dispose => { + const [toggle, setToggle] = createSignal(true); + + const [_numbers, setNumbers] = createSignal({ + a: 3, + b: () => (toggle() ? 2 : 3), + c: (a: number, b: number) => a * b, + _c: (a: number, b: number) => a * b, + __c: () => () => (a: number, b: number) => a * b, + d: toggle() ? 1 : 0, //intentionally wrongly not reactive + onClick: (e: MouseEvent) => e.type, + nested: { + sum: (a: number, b: number) => a + b, + num: 1, + }, + }); + + const { + a, + b, + c, + _c, + __c, + d, + onClick, + nested: { sum, num }, + } = destructure(_numbers, { + normalize: true, + memo: true, + deep: true, + }); + + const updates = { + a: 0, + b: 0, + c: 0, + _c: 0, + d: 0, + }; + createComputed(() => { + a(); + updates.a++; + }); + createComputed(() => { + b(); + updates.b++; + }); + createComputed(() => { + c(a(), b()); + updates.c++; + }); + createComputed(() => { + __c()()(a(), b()); + updates._c++; + }); + + expect(a()).toBe(3); + expect(b()).toBe(2); + expect(c.length).toBe(2); + expect(c(a(), b())).toBe(6); + expect(_c.length).toBe(2); + expect(_c(a(), b())).toBe(6); + expect(__c()().length).toBe(2); + expect(__c()()(a(), b())).toBe(6); + expect(d()).toBe(1); + expect(onClick.length).toBe(1); + expect(onClick(new MouseEvent("click"))).toBe("click"); + expect(sum.length).toBe(2); + expect(sum(1, 2)).toBe(3); + expect(num()).toBe(1); + + expect(updates.a).toBe(1); + expect(updates.b).toBe(1); + expect(updates.c).toBe(1); + setToggle(false); + expect(updates.b).toBe(2); + expect(b()).toBe(3); + //@ts-ignore + setNumbers(prev => ({ ...prev, a: () => 4, b: 6 })); + + expect(b()).toBe(6); + //d is static. + expect(d()).toBe(1); + expect(c(a(), b())).toBe(24); + expect(_c(a(), b())).toBe(24); + expect(__c()()(a(), b())).toBe(24); + expect(updates.a).toBe(2); + expect(updates.b).toBe(3); + expect(updates.c).toBe(3); // as we change a and b we compute c 2x + dispose(); + })); + test("normalize - effects are triggered correctly", () => + createRoot(dispose => { + const [count, setCount] = createSignal(1); + + const { x, y } = destructure( + { + get x() { + return count() > 5; + }, + y: () => count() > 5, + }, + { memo: true, normalize: false }, + ); + const { _x, _y } = destructure( + { + get _x() { + return count() > 5; + }, + _y: () => count() > 5, + }, + { normalize: true }, + ); + + const updates = { + x: 0, + y: 0, + _x: 0, + _y: 0, + }; + + createComputed(() => { + x(); + updates.x++; + }); + createComputed(() => { + y(); + updates.y++; + }); + createComputed(() => { + _x(); + updates._x++; + }); + createComputed(() => { + _y(); + updates._y++; + }); + + expect(updates.x).toBe(1); + expect(updates.y).toBe(1); + expect(updates._x).toBe(1); + expect(updates._y).toBe(1); + + setCount(2); // shouldn't rerun effects for x and y but for _x and _y + expect(updates.x).toBe(1); + expect(updates.y).toBe(1); + expect(updates._x).toBe(2); + expect(updates._y).toBe(2); + + setCount(6); // should rerun effects for x,y,_x,_y + expect(updates.x).toBe(2); + expect(updates.y).toBe(2); + expect(updates._x).toBe(3); + expect(updates._y).toBe(3); + + dispose(); + })); + test("variadic params", () => { + createRoot(dispose => { + type Props = { + handleClick: (e: MouseEvent) => void; + }; + + function hasParams(func: any) { + // Convert the function to a string and check if it includes "arguments" or for arrow functions test with regex for "..." + const funcString = func.toString(); + const paramsPos = funcString.match(/\(.*?\)/); + return ( + func.length > 0 || + funcString.includes("arguments") || + /\(\s*\.\.\.\s*[^\)]+\)/.test(paramsPos[0]) + ); + } + + const logMousePos = (e: MouseEvent) => { + console.log("mouse", e.x, e.y); + }; + + // fine + const a: Props = destructure({ handleClick: logMousePos }); + + const e = "click"; + // a.handleClick(e); // => works + expect(hasParams(a.handleClick)).toBe(true); + // variadic params + + const log = (...x: any[]) => { + console.log(...x, "###"); + }; + function vp(...y: any[]) { + console.log(...y, "***"); + } + function np() { + return null; + } + function nvp() { + const t = []; + (...t: any) => null; + function foo(...t: any) { + return null; + } + } + expect(hasParams(log)).toBe(true); + expect(hasParams(vp)).toBe(true); + expect(hasParams(np)).toBe(false); + expect(hasParams(nvp)).toBe(false); + // ts doen't mind + const b = destructure({ handleClick: log }); // logs immediately - not anymore :-P + expect(hasParams(b.handleClick)).toBe(true); + + dispose(); + }); + }); });