Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

solid-primitives destructure: Added new config option "normalize" #525

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hip-beans-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solid-primitives/destructure": minor
---

new option 'normalize' which converts all values to accessor functions
3 changes: 2 additions & 1 deletion packages/destructure/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions packages/destructure/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <[email protected]>",
"contributors": [
"Martin Rapp @madaxen86 <[email protected]>"
],
"license": "MIT",
"homepage": "https://github.com/solidjs-community/solid-primitives/tree/main/packages/destructure#readme",
"repository": {
Expand Down
65 changes: 41 additions & 24 deletions packages/destructure/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,32 @@ export type DestructureOptions<T extends ReactiveSource> = MemoOptions<Values<T>
memo?: boolean;
lazy?: boolean;
deep?: boolean;
normalize?: boolean;
};

export type Spread<T extends ReactiveSource> = {
readonly [K in keyof T]: Accessor<T[K]>;
type ReturnFunction<T> = T extends (...args: any[]) => any ? T : () => T;
type ReturnValue<T, N> = N extends true ? ReturnFunction<T> : Accessor<T>;

export type Spread<T extends ReactiveSource, N = false> = {
readonly [K in keyof T]: ReturnValue<T[K], N>;
};
export type DeepSpread<T extends ReactiveSource> = {

export type DeepSpread<T extends ReactiveSource, N = false> = {
readonly [K in keyof T]: T[K] extends ReactiveSource
? T[K] extends AnyFunction
? Accessor<T[K]>
: DeepSpread<T[K]>
: Accessor<T[K]>;
? ReturnValue<T[K], N>
: DeepSpread<T[K], N>
: ReturnValue<T[K], N>;
};
export type Destructure<T extends ReactiveSource> = {
readonly [K in keyof T]-?: Accessor<T[K]>;
export type Destructure<T extends ReactiveSource, N = false> = {
readonly [K in keyof T]-?: ReturnValue<T[K], N>;
};
export type DeepDestructure<T extends ReactiveSource> = {
export type DeepDestructure<T extends ReactiveSource, N = false> = {
readonly [K in keyof T]-?: T[K] extends ReactiveSource
? T[K] extends AnyFunction
? Accessor<T[K]>
: DeepDestructure<T[K]>
: Accessor<T[K]>;
? ReturnValue<T[K], N>
: DeepDestructure<T[K], N>
: ReturnValue<T[K], N>;
};

const isReactiveObject = (value: any): boolean => typeof value === "object" && value !== null;
Expand Down Expand Up @@ -57,9 +62,11 @@ function createProxyCache(obj: object, get: (key: any) => any): any {
* Destructures an reactive object *(e.g. store or component props)* or a signal of one into a tuple/map of signals for each object key.
* @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)*
* - `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
madaxen86 marked this conversation as resolved.
Show resolved Hide resolved
* - `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])
Expand All @@ -75,18 +82,22 @@ export function destructure<T extends ReactiveSource, O extends DestructureOptio
source: MaybeAccessor<T>,
options?: O,
): O extends { lazy: true; deep: true }
? DeepDestructure<T>
: O["lazy"] extends true
? Destructure<T>
? DeepDestructure<T, O["normalize"]>
: O extends { lazy: true }
? Destructure<T, O["normalize"]>
: O["deep"] extends true
? DeepSpread<T>
: Spread<T> {
? DeepSpread<T, O["normalize"]>
: Spread<T, O["normalize"]> {
const config: DestructureOptions<T> = options ?? {};
const memo = config.memo ?? typeof source === "function";
const getter =
typeof source === "function"
? (key: any) => () => source()[key]
: (key: any) => () => source[key];

const _source = createMemo(() => (typeof source === "function" ? source() : source));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whats the reason for memoizing the source? in most cases the primitive is used with props object or a signal, where the source memoization is useless.

const getter = (key: any) => {
const accessedValue = () => access(_source()[key]);
//If accessedValue() is a function with params return the original function
if (typeof accessedValue() === "function" && accessedValue().length) return accessedValue();
return accessedValue;
};
const obj = access(source);

// lazy (use proxy)
Expand All @@ -96,7 +107,9 @@ export function destructure<T extends ReactiveSource, O extends DestructureOptio
const calc = getter(key);
if (config.deep && isReactiveObject(obj[key]))
return runWithOwner(owner, () => destructure(calc, { ...config, memo }));
return memo ? runWithOwner(owner, () => createMemo(calc, undefined, options)) : calc;
return memo && (!config.normalize || calc.length === 0)
? runWithOwner(owner, () => createMemo(calc, undefined, options))
: calc;
});
}

Expand All @@ -106,7 +119,11 @@ export function destructure<T extends ReactiveSource, O extends DestructureOptio
const calc = getter(key);
if (config.deep && isReactiveObject(value))
result[key] = destructure(calc, { ...config, memo });
else result[key] = memo ? createMemo(calc, undefined, options) : calc;
else
result[key] =
memo && (!config.normalize || calc.length === 0)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is one thing I'm realizing about this.

const [callback, setCallback] = createSignal((a, b) => a + b)

const {cb} = destructure({get cb() { return callback() }})

setCallback(() => () => 69)

expect(cb()).toBe(69) // fail: still calls the original function

Even if we want to avoid cb()(1, 2) syntax of normal destructure, maybe it should still support "reactive callbacks", the same props.callback(1, 2) would if you passed callback={someSignal()} in JSX.
Although Solid doesn't allow for reactive callbacks in on___ props, so it may be fine to restrict that as well.

? createMemo(calc, undefined, options)
: calc;
}
return result;
}
139 changes: 138 additions & 1 deletion packages/destructure/test/destructure.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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";
import { get } from "@solid-primitives/utils/immutable";
thetarnav marked this conversation as resolved.
Show resolved Hide resolved

describe("destructure", () => {
test("spread array", () =>
Expand Down Expand Up @@ -246,6 +247,142 @@ describe("destructure", () => {
expect(updates.b).toBe(2);
expect(updates.c).toBe(2);

dispose();
}));
test("spread object normalize and deep", () =>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can spit it to two different tests as you are testing two things. "@thetarnav's stuff" and "@madaxen86's stuff". Could be more descriptive btw 😄
I'm aware what my test is doing (testing combining with the memo option enabled and disabled), so I'm guessing the second test is for a function source and deep: true?

createRoot(dispose => {
const [toggle, setToggle] = createSignal(true);
const [count, setCount] = createSignal(1);

const { x, y } = destructure(
{
get x() {
return count() > 5;
},
y: () => count() > 5,
},
{ memo: true, normalize: true },
);
const { _x, _y } = destructure(
{
get _x() {
return count() > 5;
},
_y: () => count() > 5,
},
{ normalize: true },
);

const [_numbers, setNumbers] = createSignal({
a: 3,
b: () => (toggle() ? 2 : 3),
c: (a: number, b: number) => a * b,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you replace it with c: () => (a: number, b: number) => a * b the types break, but tests still pass

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have adjusted the types.
It became a bit messy but now the types are inferred correctly.

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,
d,
onClick,
nested: { sum, num },
} = destructure(_numbers, {
normalize: true,
memo: false,
deep: true,
});

const updates = {
a: 0,
b: 0,
c: 0,
d: 0,
x: 0,
y: 0,
_x: 0,
_y: 0,
};
createComputed(() => {
a();
updates.a++;
});
createComputed(() => {
b();
updates.b++;
});
createComputed(() => {
c(a(), b());
updates.c++;
});
createComputed(() => {
x();
updates.x++;
});
createComputed(() => {
y();
updates.y++;
});
createComputed(() => {
_x();
updates._x++;
});
createComputed(() => {
_y();
updates._y++;
});

//@thetarnav's stuff
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);

//@madaxen86's stuff
expect(a()).toBe(3);
expect(b()).toBe(2);
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(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();
}));
});