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

Add createLens primitive #452

Draft
wants to merge 46 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
9674028
feat: add new package: lens
nathanbabcock May 26, 2023
acd61dc
feat: stub createLens function
nathanbabcock May 26, 2023
fdfbcc5
feat: create recursive types for object path syntax
nathanbabcock May 26, 2023
110d6d7
fix: produce valid type signature on lens setter
nathanbabcock May 26, 2023
6b47de9
feat: validate a given path against an object
nathanbabcock May 26, 2023
e210690
refactor: restore rich recursive type for path syntax
nathanbabcock May 27, 2023
f1eb857
feat: add types for array paths and filter functions
nathanbabcock May 28, 2023
9109e92
fix: prevent infinite depth by bailing after first filter function
nathanbabcock May 28, 2023
1334206
refactor: rename package lens => lenses
nathanbabcock May 28, 2023
7aff44d
refactor(lenses): extract types to separate file
nathanbabcock May 28, 2023
c642d2a
refactor(lenses): rename types for clarity & specificity
nathanbabcock May 28, 2023
7b8c7cc
test(lenses): stub tests for index, server, and types
nathanbabcock May 28, 2023
a50f38a
test(lenses): cover valid store path types
nathanbabcock May 28, 2023
0c1b25a
test(lenses): add typechecking npm script
nathanbabcock May 28, 2023
fb2bdf0
test(lenses): cover types for derived stores
nathanbabcock May 28, 2023
b94bb67
chore(lenses): ignore temporary vitest tsconfig
nathanbabcock May 28, 2023
e47299c
Add `createStoreDelta` primitive
thetarnav May 18, 2023
ad4eb75
Support circular references in `createStoreDelta`
thetarnav May 19, 2023
bf26aa1
Add demo for getting store updates
thetarnav May 19, 2023
7ac6a1a
Fix hydration errors - noop on the server
thetarnav May 19, 2023
44b986d
Rename, add store updates to tracking bench and tests
thetarnav May 19, 2023
7d463f8
deep: Update readme
thetarnav May 19, 2023
7f83137
Add changeset
thetarnav May 19, 2023
002253a
Add solid-js to packages as dev dep
thetarnav May 27, 2023
32a7fed
Update deps
thetarnav May 27, 2023
b3d022b
Update pnpm version
thetarnav May 27, 2023
74b10f1
Import devtools setup
thetarnav May 27, 2023
8761acf
Add "Props derived signals" example
thetarnav May 27, 2023
d910431
Format
thetarnav May 27, 2023
73b337d
Improve `history` demo to show handling updates to stores.
thetarnav May 27, 2023
b68ebd8
Update Readme
thetarnav May 27, 2023
9fbdcaf
Version Packages
github-actions[bot] May 27, 2023
3b0a102
test(lenses): cover evaluating invalid paths
nathanbabcock May 28, 2023
bd5a3fe
test(lenses): cover basic setter behavior for createLens
nathanbabcock May 28, 2023
13c96e4
feat(lenses): add createFocusedGetter
nathanbabcock May 28, 2023
1d6d41c
test(lenses): establish parity between derived signal and focused getter
nathanbabcock May 28, 2023
546c777
docs(lenses): add description and primitives to package readme
nathanbabcock May 28, 2023
b2d5183
fix(lenses): handle top-level array in focused getter
nathanbabcock May 28, 2023
f77ddfc
docs(lenses): update todo items in readme
nathanbabcock May 28, 2023
f06c2cb
refactor(lenses): export separate createFocusedSetter primitive
nathanbabcock May 28, 2023
d95a7c8
fix(lenses): handle accessor in createFocusedGetter
nathanbabcock May 28, 2023
9214647
refactor(lenses): separate setter-specific unit tests
nathanbabcock May 28, 2023
b265787
chore(lenses): update package and metadata
nathanbabcock May 28, 2023
6ec865a
docs(lenses): add motivation section to readme
nathanbabcock May 28, 2023
25c51bb
feat: add lenses package
nathanbabcock May 29, 2023
d9a56e3
chore: update pnpm-lock
nathanbabcock May 29, 2023
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
2 changes: 2 additions & 0 deletions packages/lenses/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
tsconfig.vitest-temp.json
# (created while `vitest typecheck` is running in watch mode)
21 changes: 21 additions & 0 deletions packages/lenses/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2021 Solid Primitives Working Group

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
103 changes: 103 additions & 0 deletions packages/lenses/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<p>
<img width="100%" src="https://assets.solidjs.com/banner?type=Primitives&background=tiles&project=lenses" alt="Solid Primitives lenses">
</p>

# @solid-primitives/lenses

[![turborepo](https://img.shields.io/badge/built%20with-turborepo-cc00ff.svg?style=for-the-badge&logo=turborepo)](https://turborepo.org/)
[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/lenses?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/lenses)
[![version](https://img.shields.io/npm/v/@solid-primitives/lenses?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/lenses)
[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process)

Utilities for working with nested reactivity in a modular way.

- `createLens` - Given a path within a Store object, return a derived or "focused"
getter and setter pair.

- `createFocusedGetter` - The first half of the lens tuple; a derived signal
using path syntax on an object.

- `createFocusedSetter` - The second half of the lens tuple; a Setter
for a specific path within a Store.

## Installation

```bash
npm install @solid-primitives/lenses
# or
yarn add @solid-primitives/lenses
# or
pnpm add @solid-primitives/lenses
```

## How to use it

```ts
// Start with an ordinary SolidJS Store
const storeTuple = createStore([
{ myString: 'first' }
])

// Create a lens to focus on one particular item in the Store.
// Any valid path accepted by `setStore` works here!
const [firstString, setFirstString] = createLens(storeTuple, 0, myString)

// Setters and Getters work just like ordinary Signals
setFirstString("woohoo") // equivalent to `setStore(0, "myString", "woohoo")
console.log(firstString()) // "woohoo"

```

## Motivation

### 1. Separation of Concerns

Components can receive scoped Setters for only the parts of state they need
access to, rather than needing a top-level `setStore` function.

### 2. Type-safety

Essentially, we are just [partially
applying](https://en.wikipedia.org/wiki/Partial_application) a `setStore`
function with an initial path, and returning a function that will apply the
remainder of the path. It is just syntactic sugar, and under the hood
everything is using calls to native Store functionality.

The same approach can already be used by the Setter returned by `createStore`. However,
Typescript users will find it hard to maintain type-safety for the arguments
passed to a "derived"/partially-applied Setter. The type definitions for `SetStoreFunction` are...
[daunting](https://github.com/solidjs/solid/blob/44a0fdeb585c4f5a3b9bccbf4b7d6c60c7db3ecd/packages/solid/store/src/store.ts#L389).

The `lenses` package alleviates this friction by providing both `StorePath<T>`
and `EvaluatePath<T, P>` generic type helpers.

### 3. Shared path syntax between Getters and Setters

The path syntax defined in Solid Stores is incredibly expressive and powerful.
By introducing `createScopedGetter`, the same syntax can be also be used to
access Store values as derived Signals. This is particularly relevant to
child components which may both display and modify items from a Store
collection.

## TODO

- [X] Type-safe path syntax
- [X] Handle arrays
- [X] Export separate primitives for Getter and Setter
- [X] `createFocusedGetter`
- [X] `createFocusedSetter`
- [X] Handle accessors in `createFocusedGetter`
- [ ] Handle multiple array index syntax (`setStore([1, 2], old => old + 1)`)
- [ ] Test and/or implement mutation syntax setter (`prev => next`)
- [ ] Test all variations of path syntax (for both setter and getter)
- [ ] Test edge case: repeated filter functions in array path
- This may differ from `SetStoreFunction`
- [ ] Check and/or replicate official SolidJS Store unit tests for parity

## Demo

You can use this template for publishing your demo on CodeSandbox: <https://codesandbox.io/s/solid-primitives-demo-template-sz95h>

## Changelog

See [CHANGELOG.md](./CHANGELOG.md)
20 changes: 20 additions & 0 deletions packages/lenses/dev/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Component, createSignal } from "solid-js";

const App: Component = () => {
const [count, setCount] = createSignal(0);
const increment = () => setCount(count() + 1);

return (
<div class="box-border flex min-h-screen w-full flex-col items-center justify-center space-y-4 bg-gray-800 p-24 text-white">
<div class="wrapper-v">
<h4>Counter component</h4>
<p class="caption">it's very important...</p>
<button class="btn" onClick={increment}>
{count()}
</button>
</div>
</div>
);
};

export default App;
59 changes: 59 additions & 0 deletions packages/lenses/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"name": "@solid-primitives/lenses",
"version": "0.0.100",
"description": "Derived setters and getters for Stores.",
"author": "Nathan Babcock <nathan.r.babcock@gmail.com>",
"contributors": [],
"license": "MIT",
"homepage": "https://github.com/solidjs-community/solid-primitives/tree/main/packages/lenses#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/solidjs-community/solid-primitives.git"
},
"bugs": {
"url": "https://github.com/solidjs-community/solid-primitives/issues"
},
"primitive": {
"name": "lenses",
"stage": 0,
"list": [
"createLens",
"createFocusedGetter",
"createFocusedSetter"
],
"category": "Utilities"
},
"keywords": [
"solid",
"primitives"
],
"private": false,
"sideEffects": false,
"files": [
"dist"
],
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"browser": {},
"exports": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": "./dist/index.cjs"
},
"typesVersions": {},
"scripts": {
"dev": "jiti ../../scripts/dev.ts",
"build": "jiti ../../scripts/build.ts",
"vitest": "vitest -c ../../configs/vitest.config.ts",
"test": "pnpm run vitest",
"test:ssr": "pnpm run vitest --mode ssr",
"typecheck": "pnpm run vitest typecheck"
},
"peerDependencies": {
"solid-js": "^1.6.12"
}
}
102 changes: 102 additions & 0 deletions packages/lenses/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { Accessor, JSX } from "solid-js";
import { SetStoreFunction, StoreNode, unwrap } from "solid-js/store";
import type { EvaluatePath, StorePath } from "./types";

/**
* Given a path within a Store object, return a derived or "focused" getter and setter.
*
* @param store A store getter and setter tuple, as returned by `createStore`
* @param {...*} path a path array within the store, same as the parameters of `setStore`
* @return A derived or "focused" Store, as a getter & setter tuple
*/
export const createLens = <T, P extends StorePath<T>, V extends EvaluatePath<T, P>>(
store: [get: T | Accessor<T>, set: SetStoreFunction<T>],
...path: P
): [get: Accessor<V>, set: SetStoreFunction<V>] => {
const [getStore, setStore] = store;

const get: Accessor<V> = createFocusedGetter(getStore, ...path);
const set: any = createFocusedSetter(setStore, ...path);

return [get, set];
};

/** Create a derived Signal from a Store using the same path syntax as
* `setStore`. */
export function createFocusedGetter<T, P extends StorePath<T>, V extends EvaluatePath<T, P>>(
store: T | Accessor<T>,
...path: P
): Accessor<V> {
const unwrappedStore = unwrap((store || {}) as T);
function getValue() {
const store = unwrappedStore instanceof Function ? unwrappedStore() : unwrappedStore;
const value = getValueByPath(store as StoreNode, [...path]) as V;
return value;
}
return getValue;
}

/** Create a derived setter for a Store, given a partial path within the Store object. */
export function createFocusedSetter<T, P extends StorePath<T>, V extends EvaluatePath<T, P>>(
setStore: SetStoreFunction<T>,
...path: P
): SetStoreFunction<V> {
const set: any = (...localPath: any) => {
const combinedPath = [...path, ...localPath] as unknown as Parameters<SetStoreFunction<T>>;
return setStore(...combinedPath);
};
return set;
}

/** Same algorithm as `updatePath` in `solid-js/store`, but only for getting values. */
function getValueByPath(current: StoreNode, path: any[], traversed: PropertyKey[] = []): any {
if (path.length === 0) return current;

// RE `path.shift()`: Beware that this has a side effect that mutates the
// array that is passed in! This doesn't affect anything in `updatePath` from
// `solid-store` because a new array is passed in every time. However, in the
// case of `createFocusedGetter`, the same path argument is re-used every
// time. That means it should be cloned before being passed to
// `getValueByPath`.
const part = path.shift(),
partType = typeof part,
isArray = Array.isArray(current);

if (Array.isArray(part)) {
// Ex. update('data', [2, 23], 'label', l => l + ' !!!');
const value: any[] = [];
for (let i = 0; i < part.length; i++) {
value.push(getValueByPath(current, [part[i]].concat(path), traversed));
}
return value;
} else if (isArray && partType === "function") {
// Ex. update('data', i => i.id === 42, 'label', l => l + ' !!!');
const value: any[] = [];
for (let i = 0; i < current.length; i++) {
if (part(current[i], i)) value.push(getValueByPath(current, [i].concat(path), traversed));
}
return value;
} else if (isArray && partType === "object") {
// Ex. update('data', { from: 3, to: 12, by: 2 }, 'label', l => l + ' !!!');
const { from = 0, to = current.length - 1, by = 1 } = part;
const value: any[] = [];
for (let i = from; i <= to; i += by) {
value.push(getValueByPath(current, [i].concat(path), traversed));
}
return value;
} else {
return getValueByPath(current[part], path, [part].concat(traversed));
}
}

// ...

// While making primitives, there are many patterns in our arsenal
// There are functions like one above, but we also can use components, directives, element properties, etc.
// Solid's tutorial on directives: https://www.solidjs.com/tutorial/bindings_directives
// Example package that uses directives: https://github.com/solidjs-community/solid-primitives/tree/main/packages/intersection-observer
// Example use of components: https://github.com/solidjs-community/solid-primitives/blob/main/packages/event-listener/src/components.ts

// This ensures the `JSX` import won't fall victim to tree shaking before
// TypesScript can use it
export type E = JSX.Element;
61 changes: 61 additions & 0 deletions packages/lenses/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { type ArrayFilterFn, type StorePathRange } from "solid-js/store";

/** @source https://github.com/type-challenges/type-challenges/blob/main/questions/15260-hard-tree-path-array/README.md */
export type StorePath<T> = T extends readonly unknown[]
? [number] | [ArrayFilterFn<T[number]>] | [StorePathRange] | [number, ...StorePath<T[number]>]
: T extends Record<PropertyKey, unknown>
? {
[P in keyof T]: [P] | [P, ...StorePath<T[P]>];
}[keyof T]
: never;

/** Resolves to the type specified by following the path `P` on base type `T`. */
export type EvaluatePath<T, P extends StorePath<T>> = T extends unknown[]
? EvaluateArrayPath<T, P>
: T extends Record<PropertyKey, unknown>
? EvaluateObjectPath<T, P>
: T;

export type EvaluateObjectPath<
T extends Record<PropertyKey, unknown>,
P extends StorePath<T>,
> = P extends []
? T // base case; empty path array (return the whole object)
: P extends [infer K, ...infer Rest] // bind to array head and tail
? K extends keyof T
? Rest extends []
? T[K] // alternate base case; this is the last path segment
: Rest extends StorePath<T[K]> // recursive case
? EvaluatePath<T[K], Rest>
: never // tail was an invalid path (impossible)
: never // a path segment was an invalid key
: never; // path was not even an array to begin with (impossible)

export type EvaluateArrayPath<T extends unknown[], P extends StorePath<T>> = P extends [
infer K,
...infer Rest,
]
? K extends never
? T // base case; empty path array (return the whole type)
: K extends number
? Rest extends StorePath<T[K]>
? EvaluatePath<T[K], Rest>
: Rest extends []
? T[K]
: never // Three repeated edge-cases: last array item
: K extends ArrayFilterFn<T[number]>
? Rest extends StorePath<T>
? EvaluatePath<T[number], Rest> // ⚠ self-recursion/infinite regress
: // ❕ Resolved by disallowing repeated filter functions or ranges
Rest extends []
? T
: never // Three repeated edge-cases: last array item
: K extends StorePathRange
? Rest extends StorePath<T>
? EvaluatePath<T[number], Rest> // ⚠ self-recursion/infinite regress
: // ❕ Resolved by disallowing repeated filter functions or ranges
Rest extends []
? T
: never // Three repeated edge-cases: last array item
: never // unsupported array index
: never; // not an array (impossible)
Loading