├── .github └── workflows │ ├── build.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── LICENSE ├── README.md ├── image.png ├── jest.config.js ├── package.json ├── src ├── awaitable.ts ├── basic-lens.test.ts ├── basic-lens.ts ├── breaker.test.ts ├── breaker.ts ├── connection.ts ├── create-lens.ts ├── index.ts ├── is-object.ts ├── key-path-to-string.test.ts ├── key-path-to-string.ts ├── lens-focus.ts ├── proxy-lens.test.ts ├── proxy-lens.ts ├── proxy-value.ts ├── react-devtools.ts ├── react.test.tsx ├── react.ts ├── shallow-copy.ts ├── should-update.test.ts ├── should-update.ts ├── store.test.ts ├── store.ts ├── subscription-graph.test.ts ├── subscription-graph.ts ├── suspended-closure.test.ts ├── suspended-closure.ts └── types.ts ├── tsconfig.json ├── tsup.config.js └── yarn.lock /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - uses: actions/checkout@v2 9 | - uses: actions/setup-node@v2 10 | - run: yarn install --frozen-lockfile 11 | - run: yarn build 12 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [push] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - uses: actions/checkout@v2 9 | - uses: actions/setup-node@v2 10 | - run: yarn install --frozen-lockfile 11 | - run: yarn test 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | dist 17 | docs/dist 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # notes 29 | scratchpad.md 30 | scratchpad.tsx -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | .gitignore 3 | .prettierrc 4 | jest.config.js 5 | tsconfig.json 6 | yarn.lock 7 | tsup.config.js 8 | image.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Gabe Scholz 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | Lens-like state management (for React). 6 | 7 | 8 | 9 | 10 | - [Overview](#overview) 11 | - [Why use Concave?](#why-use-concave) 12 | - [Create your lens](#create-your-lens) 13 | - [Build your application](#build-your-application) 14 | - [Thinking in lenses (for React developers)](#thinking-in-lenses-for-react-developers) 15 | - [From Selectors](#from-selectors) 16 | - [Now kiss!](#now-kiss) 17 | - [Looking recursively](#looking-recursively) 18 | - [Installation](#installation) 19 | - [API](#api) 20 | - [createLens](#createlens) 21 | - [Lens](#lens) 22 | - [Lens.getStore(): Direct access to the store](#lensgetstore-direct-access-to-the-store) 23 | - [Lens.use(): Hook into a React component](#lensuse-hook-into-a-react-component) 24 | - [Should use() re-render?](#should-use-re-render) 25 | - [Lens.$key: A unique key for the `Lens`](#lenskey-a-unique-key-for-the-lensa) 26 | - [Store](#store) 27 | - [connection](#connection) 28 | - [Connection](#connection) 29 | - [useCreateLens](#usecreatelens) 30 | - [Use without TypeScript](#use-without-typescript) 31 | 32 | 33 | 34 | ## Overview 35 | 36 | Concave is not a general purpose state management library. It is intended for highly interactive UIs where the shape of the state is recursive and/or closely reflects the shape of the UI. Specifically, Concave is an strong candidate for page/form/diagram builder-type applications (written in React). 37 | 38 | ### Why use Concave? 39 | 40 | 1. Excellent for handling recursive application states. 41 | 2. Use it where you need it. Not an all or nothing solution. 42 | 3. Minimalistic and intuitive API. 43 | 44 | ### Create your lens 45 | 46 | ```ts 47 | // lens.ts 48 | 49 | import { createLens } from "concave"; 50 | 51 | export type Todo = { 52 | description: string; 53 | completed: boolean; 54 | }; 55 | 56 | export type State = { 57 | todos: Todo[]; 58 | }; 59 | 60 | const initialAppState: State = { 61 | todos: [], 62 | }; 63 | 64 | export const lens = createLens(initialAppState); 65 | ``` 66 | 67 | ### Build your application 68 | 69 | ```tsx 70 | // index.tsx 71 | 72 | import ReactDOM from "react"; 73 | import { lens } from "./lens"; 74 | import { App } from "./components/App"; 75 | 76 | /** 77 | * Retreive the underlying store. 78 | */ 79 | const store = lens.getStore(); 80 | 81 | /** 82 | * Subscribe to state updates. 83 | */ 84 | store.subscribe(() => { 85 | const currentState = store.getSnapshot(); 86 | 87 | /** 88 | * Do something with the `currentState`. 89 | */ 90 | }); 91 | 92 | const root = ReactDOM.createRoot(document.querySelector("#root")); 93 | root.render(); 94 | ``` 95 | 96 | ```tsx 97 | // components/App.tsx 98 | 99 | import { Lens } from "concave"; 100 | import { State } from "../lens"; 101 | import { NewTodoForm } from "./NewTodoForm"; 102 | import { Todo } from "./Todo"; 103 | 104 | type Props = { 105 | state: Lens; 106 | }; 107 | 108 | /** 109 | * Fully memoize the component because `Lens` is static and will never change. 110 | */ 111 | export const App = React.memo((props: Props) => { 112 | /** 113 | * `lens.use()` is a React hook that integrates the underlying 114 | * store into the component life cycle. 115 | * 116 | * It takes a "should update?" argument that decides whether the hook should 117 | * trigger a re-render. In this case, we render when the length of todos changes 118 | * or any todo.completed is toggled. 119 | */ 120 | const [todos, updateTodos] = props.state.todos.use({ completed: true }); 121 | 122 | const incomplete = todos.filter((todo) => !todo.completed); 123 | const complete = todos.filter((todo) => todo.completed); 124 | 125 | return ( 126 | <> 127 | {/* When creating a new TODO, append it to the list of existing todos. */} 128 | updateTodos((prev) => [...prev, todo])} /> 129 | {incomplete.map((todo) => { 130 | /** 131 | * Tranform data back into `Lens`. 132 | */ 133 | const lens = todo.toLens(); 134 | 135 | /** 136 | * Render using the unique `lens.$key` as the key. 137 | */ 138 | return ; 139 | })} 140 | {complete.map((todo) => { 141 | const lens = todo.toLens(); 142 | return ; 143 | })} 144 | 145 | ); 146 | }); 147 | ``` 148 | 149 | ```tsx 150 | // components/Todo.tsx 151 | 152 | import { Lens } from "concave"; 153 | import type { Todo } from "../lens"; 154 | 155 | type Props = { 156 | state: Lens; 157 | }; 158 | 159 | /** 160 | * Fully memoize the component because `Lens` is static and will never change. 161 | */ 162 | export const Todo = React.memo((props: Props) => { 163 | const [todo, setTodo] = props.state.use(); 164 | 165 | /** 166 | * Render the Todo. 167 | */ 168 | }); 169 | ``` 170 | 171 | ## Thinking in lenses (for React developers) 172 | 173 | ### From Selectors 174 | 175 | If you have built React applications with Redux then you are probably familiar with [selectors](https://redux.js.org/usage/deriving-data-selectors). A Redux selector is a "getter" from the monolithic application state meant to obfuscate the shape of that state from the rest of the application. Used correctly, they are a good application of the [Law of Demeter](https://en.wikipedia.org/wiki/Law_of_Demeter). 176 | 177 | ```ts 178 | import { State, User } from "./state"; 179 | 180 | /** 181 | * Get `User` off of the global `State` 182 | */ 183 | export const getUser = (state: State): User => state.user; 184 | 185 | /** 186 | * Get `name` off the `User` 187 | */ 188 | export const getUserName = (state: State) => getUser(state).name; 189 | ``` 190 | 191 | The second "getter", `getUserName`, is a "refinement" on `getUser`. It gives us a way to write `getUserName` in terms of the _entire_ application state without revealing it. That is, `getUserName` only needs to know the shape of `User`, while `getUser` can get it from the parent. And so on... 192 | 193 | In Redux, state updates occur through dispatching actions. Lets consider how could look with explicit "setters" (... "setlectors"? :trollface:). 194 | 195 | ```ts 196 | /** 197 | * Set `user` on the global `State`. 198 | */ 199 | export const setUser = (state: State, user: User) => { 200 | return { 201 | ...state, 202 | user, 203 | }; 204 | }; 205 | 206 | /** 207 | * Set `name` on `user` which in turn will set `user` on the global `State`. 208 | */ 209 | export const setUserName = (state: State, name: string) => { 210 | const user = getUser(state); 211 | 212 | return setUser(state, { 213 | ...user, 214 | name, 215 | }); 216 | }; 217 | ``` 218 | 219 | Again, notice how the second "setter" relies on the first: `setUserName` is a "refinement" of `setUser`. Once more, `setUserName` can rely on `getUser` and `setUser` in order to get and set the user on the global state without revealing it. 220 | 221 | ### Now kiss! 222 | 223 | In the most basic sense, a lens is just a getter and setter pair where their refinements are explicitly coupled to each other. When we define a way to get the user's name, lets also define the way to set it. Starting from the global state, each refinement _focuses_ in on a smaller piece of data—which is why they are called lenses. 224 | 225 | Lets start by writing a basic lens for the entire state. 226 | 227 | ```ts 228 | const stateLens: BasicLens = { 229 | get(state: State): State { 230 | return state; 231 | }, 232 | 233 | set(prev: State, next: State): State { 234 | return next; 235 | }, 236 | }; 237 | ``` 238 | 239 | This is the identity equivalent for a lens and not interesting, but now lets refine the lens for the user. 240 | 241 | ```ts 242 | const userLens: BasicLens = { 243 | get(state: State): User { 244 | return stateLens.get(state).user; 245 | }, 246 | 247 | set(state: State, next: User): State { 248 | const prev = stateLens.get(state); 249 | 250 | return stateLens.set(state, { 251 | ...prev, 252 | user, 253 | }); 254 | }, 255 | }; 256 | ``` 257 | 258 | And finally for the user name. 259 | 260 | ```ts 261 | const userNameLens: BasicLens = { 262 | get(state: State) { 263 | return userLens.get(state).name; 264 | }, 265 | 266 | set(state: State, name: string): State { 267 | const user = userLens.get(state); 268 | 269 | return userLens.set(state, { 270 | ...user, 271 | name, 272 | }); 273 | }, 274 | }; 275 | ``` 276 | 277 | These look nearly identical to the getter/setter examples at the beginning of this section except they are defacto paired together. Again, each refinement focuses more and more on a smaller piece of data. Despite that, they are always rooted in terms of the global `State`. 278 | 279 | ```ts 280 | const globalState: State = { 281 | /* ... */ 282 | }; 283 | 284 | /** 285 | * Retrieve the user name using the global `State`. 286 | */ 287 | const userName = userNameLens.get(globalState); 288 | 289 | // ... 290 | 291 | /** 292 | * Set a new user name in terms of the global `State`. 293 | */ 294 | const nextGlobalState = userNameLens.set(globalState, "Gabey Baby"); 295 | ``` 296 | 297 | You may have noticed that it is probably common to make `keyof` refinements and so we can just write a helper function to do this. 298 | 299 | ```ts 300 | declare function prop( 301 | lens: BasicLens, 302 | key: Key 303 | ): BasicLens; 304 | ``` 305 | 306 | And so instead, you might say, 307 | 308 | ```ts 309 | const userLens = prop(stateLens, "user"); 310 | const userNameLens = prop(userLens, "name"); 311 | ``` 312 | 313 | ### Looking recursively 314 | 315 | Lenses start to become particularly useful in situations where both the UI and application state are recursive. Builder-type applications 316 | often have sections inside of sections inside of sections with arbitrary contents and their data is represented as such. Using Redux to maintain this kind of state will often devolve into coming up with some kind of weird scheme where we keep track of the key path and pass it as an argument to the action so that the reducer can walk the state and find the piece of data that you actually meant to update. By pairing the data getter with a corresponding setter, these kinds of updates become trivial. 317 | 318 | ## Installation 319 | 320 | This library uses `useSyncExternalStore` (introduced in React 18). If you want to use Concave with a version of React older than 18, you must also [install a shim](https://github.com/reactwg/react-18/discussions/86). 321 | 322 | ```bash 323 | npm install concave use-sync-external-store 324 | ``` 325 | 326 | ## API 327 | 328 | ### createLens 329 | 330 | `createLens(initialState: S): Lens` 331 | 332 | Creates a store with state `S` and wraps it in a `Lens` which is returned. To create a `Lens` inside of a React component, use `useCreateLens` (see below). 333 | 334 | ```ts 335 | import { createLens } from "concave"; 336 | import { State, initialState } from "./state"; 337 | 338 | export const lens = createLens(initialState); 339 | ``` 340 | 341 | ### Lens 342 | 343 | ```ts 344 | type Lens = { 345 | getStore(): Store; 346 | use(shouldUpdate?: ShouldUpdate): [ProxyValue, Update]; 347 | $key: string; 348 | }; 349 | ``` 350 | 351 | A stateless [Proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) around some data `A`. Inherits all 352 | _own keys_ that the underlying object/array would have. 353 | 354 | For example, 355 | 356 | ```ts 357 | type Account = { 358 | name: string; 359 | email: string; 360 | }; 361 | 362 | type User = { 363 | id: string; 364 | account: Account; 365 | }; 366 | 367 | type State = { 368 | user: User; 369 | }; 370 | 371 | let lens: Lens; 372 | 373 | // ... 374 | 375 | const userLens: Lens = lens.user; 376 | const accountLens: Lens = userLens.account; 377 | const emailLens: Lens = accountLens.email; 378 | ``` 379 | 380 | Lenses are cached and static from the time they are first accessed. `lens.user.account` will always _be_ the same `Lens`. 381 | 382 | :warning: If a React component only accepts a `Lens` as props then it can be fully memoized with `React.memo`. 383 | 384 | ### Lens.getStore(): Direct access to the store 385 | 386 | ```ts 387 | declare function getStore(): Store; 388 | ``` 389 | 390 | Every `Lens` exposes a `getStore()` method that returns the underlying `Store` (see below). With this you can access the current state of the store for `A`, as well as subscribe to and push updates. 391 | 392 | ```ts 393 | let accountLens: Lens; 394 | 395 | const accountStore: Store = accountLens.getStore(); 396 | 397 | /** 398 | * Subscribe to all updates that may be relevant to `Lens`. 399 | */ 400 | const unsubscribe = accountStore.subscribe(() => { 401 | const currentAccount = accountStore.getSnapshot(); 402 | 403 | /** 404 | * Do something with `currentAccount`. 405 | */ 406 | }); 407 | 408 | // ... 409 | 410 | let email: string; 411 | 412 | /** 413 | * Update email. 414 | */ 415 | accountStore.setSnapshot({ 416 | ...accountStore.getSnapshot(), 417 | email, 418 | }); 419 | ``` 420 | 421 | ### Lens.use(): Hook into a React component 422 | 423 | ```ts 424 | declare function use(shouldUpdate?: ShouldUpdate): [ProxyValue, Update]` 425 | ``` 426 | 427 | A React hook that wraps `getStore()` into the component lifecycle and returns a tuple similar to `React.useState`. 428 | 429 | The first value, `ProxyValue`, is a Proxy around some state `A`. 430 | 431 | ```ts 432 | type ProxyValue = { [K in keyof A]: ProxyValue } & { toLens(): Lens }; 433 | ``` 434 | 435 | It applies recursively, so accessing properties of a `ProxyValue` will return another `ProxyValue` **unless it is a primitive value** :warning:. That is, 436 | 437 | ```ts 438 | let lens: Lens; 439 | 440 | const App = () => { 441 | const [state, updateState] = lens.use(); 442 | 443 | /** 444 | * `state.user.account` is a `ProxyValue`. 445 | */ 446 | const accountLens = state.user.account.toLens(); 447 | 448 | /** 449 | * Error! `.toLens()` is not defined on primitive values. 450 | */ 451 | state.user.account.email.toLens(); 452 | 453 | // ... 454 | }; 455 | ``` 456 | 457 | Calling `toLens()` will return the same `Lens` as if you had just traversed the lens. 458 | 459 | ```ts 460 | let lens: Lens; 461 | 462 | const App = () => { 463 | const [state, updateState] = lens.use(); 464 | 465 | /** 466 | * These are the same. `Object.is(accountLens1, accountLens2) === true`. 467 | */ 468 | const accountLens1 = state.user.account.toLens(); 469 | const accountLens2 = lens.user.account; 470 | 471 | // ... 472 | }; 473 | ``` 474 | 475 | The second value in the `use()` tuple, `Update`, is a function that takes a callback where the current store value is passed as an argument and expects to return the next value. 476 | 477 | ```ts 478 | let lens: Lens; 479 | 480 | const App = () => { 481 | const [account, updateAccount] = lens.user.account.use(); 482 | 483 | // ... 484 | 485 | updateAccount((currentAccount) => { 486 | return { 487 | ...currentAccount, 488 | email: "neato@example.com", 489 | }; 490 | }); 491 | 492 | // ... 493 | }; 494 | ``` 495 | 496 | ### Should use() re-render? 497 | 498 | Whether it's iterating an array or switching on a [discriminated union](https://www.typescriptlang.org/docs/handbook/unions-and-intersections.html#union-exhaustiveness-checking), you will need to call `Lens.use()` in order to access the underlying data and decide _what_ to render. The "should update" argument is an optional way to decide whether `Lens.use()` _should_ re-render. Specifically, it provides a convenient way to define the data dependencies for the component—not unlike the dependency array for `useEffect`, `useCallback`, etc. 499 | 500 | The default behavior of `Lens.use()` is to render when the next value is no longer _strictly equal_ to the previous one. However, this is not sufficient for a deeply recursive component tree. And the closer the `Lens.use()` call is to the root state, the more likely an update will fail the strictly equal check because changes cascade up the lens' inner traversal. 501 | 502 | For example, a component relying on `lens.element.use()` that does not define a "should update" argument would trigger a re-render when another component updates from `lens.element.data.children[1].data.placeholder.use()`. Therefore, if the component only actually relied on the `status` property of the `element`, it could be rewritten as `lens.element.use({ status: true })` and all other changes would be ignored. 503 | 504 | The following is a list of ways to define "should update", 505 | 506 | 1. `true`: Noop. Will inherit the default behavior. 507 | 2. `false`: Will never re-render. 508 | 3. `(prev: A, next: A) => boolean`: Similar to React's `shouldComponentUpdate`. 509 | 4. `(keyof A)[]`: Will only render when any of the listed keys change. 510 | 5. `{ [K in keyof A]: ShouldUpdate }`: Will recursively apply these rules to values and ignore any keys that are not provided. 511 | 512 | Here are some examples of how you can define "should update", 513 | 514 | ```ts 515 | /** 516 | * Render when the account object changes. 517 | */ 518 | lens.user.account.use(true); 519 | 520 | /** 521 | * Never re-render. 522 | */ 523 | lens.user.account.use(false); 524 | 525 | /** 526 | * Render _only_ when the account.email changes. 527 | */ 528 | lens.user.account.use({ email: true }); 529 | 530 | /** 531 | * Render _only_ when the next email value is longer than the one 532 | * that was previously rendered. 533 | */ 534 | lens.user.account.use({ email: (prev, next) => next.length > prev.length }); 535 | 536 | /** 537 | * Functionally equivalent to `false`. Never re-render. 538 | */ 539 | lens.user.account.use({}); 540 | 541 | /** 542 | * Render _only_ when the account.name changes. 543 | */ 544 | lens.user.account.use(["name"]); 545 | 546 | /** 547 | * Render _only_ when the account.name or account.email changes. 548 | */ 549 | lens.user.account.use(["name", "email"]); 550 | 551 | /** 552 | * Render _only_ when the user.account.name changes. Note this is different than 553 | * the above as it is the lens for the entire User and not just the Account. 554 | */ 555 | lens.user.use({ account: ["name"] }); 556 | ``` 557 | 558 | :warning: **For arrays, when defining "should update" as an array of keys or an object (4 or 5 above), the library assumes that you also mean to trigger a render when the length of the array changes. Additionally, when specifying properties on an array, it is assumed that you mean to target all of the members of the array. You therefore do not need to traverse the keys of the array (the indices) and instead you define keys of the individual members.** :warning: 559 | 560 | For example, 561 | 562 | ```ts 563 | type State = { 564 | todos: Array<{ 565 | completed: boolean; 566 | description: string; 567 | // ... 568 | }>; 569 | }; 570 | 571 | let lens: Lens; 572 | 573 | /** 574 | * Render _only_ when the length of `todos` has changed and/or _any_ of 575 | * the todos' `completed` is toggled. 576 | */ 577 | lens.todos.use({ completed: true }); 578 | 579 | /** 580 | * Render _only_ when the length has changed _or_ any of the todos' `description` has changed. 581 | */ 582 | lens.todos.use(["description"]); 583 | 584 | /** 585 | * Render _only_ when the length has changed. 586 | */ 587 | lens.todos.use([]); 588 | lens.todos.use({}); 589 | lens.todos.use((prev, next) => prev.length !== next.length); 590 | ``` 591 | 592 | ### Lens.$key: A unique key for the `Lens` 593 | 594 | A unique key for the `Lens` (Just matches the traversal path.) `lens.user.account.email.$key === "root.user.account.email"`. Meant to be used when React requires a key. 595 | 596 | ```tsx 597 | export const TodoList = () => { 598 | const [todos] = todoLens.use(); 599 | 600 | return <> 601 | {todos.map((todo) => { 602 | const lens = todo.toLens(); 603 | 604 | return 605 | })} 606 | <> 607 | } 608 | ``` 609 | 610 | ### Store 611 | 612 | ```ts 613 | type Store = { 614 | getSnapshot(): A; 615 | setSnapshot(next: A): void; 616 | subscribe(listener: Listener): Unsubscribe; 617 | }; 618 | 619 | type Listener = () => void; 620 | type Unsubscribe = () => void; 621 | ``` 622 | 623 | Returned by `lens.getStore()`. Used to make imperative operations easy to do. Get and set the data directly as well as subscribe to updates. 624 | 625 | Stores are used by [`connection`](#connection) to allow for complex async behaviors directly in the lens. As such, calling `getSnapshot()` on a store belonging to a connection before it has received at least one value will throw a Promise. 626 | 627 | ### connection 628 | 629 | ```ts 630 | declare function connection(create: (store: Store, input: I) => Unsubscribe | void): Connection; 631 | ``` 632 | 633 | A connection takes a `create` callback that receives a [`Store`](#store) and some input `I`. Connections can be embedded inside a monolithic `Lens` and follow the protocol for [React Suspense](https://reactjs.org/docs/concurrent-mode-suspense.html)—they throw a Promise if data is not yet present—so that async data fetching can be written as if it is synchronous. 634 | 635 | ```ts 636 | const timer = connection((store, input) => { 637 | /** 638 | * This store is identical to any other store. 639 | */ 640 | store.setSnapshot(input.startTime); 641 | 642 | const intervalId = setInterval(() => { 643 | const prev = store.getSnapshot(); 644 | store.setSnapshot(prev + 1); 645 | }, input.interval); 646 | 647 | return () => { 648 | clearInterval(intervalId); 649 | }; 650 | }); 651 | 652 | const state = { 653 | // ... 654 | count: timer, 655 | }; 656 | 657 | export const lens = createLens(state); 658 | ``` 659 | 660 | And then in a component, the connection can be collapsed into a lens given some input. 661 | 662 | ```tsx 663 | type Props = { 664 | state: Lens<{ count: Connection }>; 665 | }; 666 | 667 | const App = (props: Props) => { 668 | /** 669 | * As part of the lens, `count` is a function that takes the input for the connection 670 | * and returns a new lens. 671 | */ 672 | const [count, updateCount] = props.state.count({ startTime: 10, interval: 1000 }).use(); 673 | 674 | // ... 675 | }; 676 | ``` 677 | 678 | `count` and `updateCount` work exactly as they would for any other lens, meaning that you could, for example, subtract 20 seconds off of the timer by calling `updateCount(prev => prev - 20)`. 679 | 680 | Connections only automatically share state _if_ they exist at the same key path _and_ have the same input. The input is serialized as a key with `JSON.stringify`, so changing the order of keys or including extraneous values will create a new cached value. 681 | 682 | Furthermore, `connection` can store any value. Walking that value, even if there is no data yet, happens with the `Lens` as if the data is already present. 683 | 684 | ```tsx 685 | type Props = { 686 | /** 687 | * Imagine we have a lens with the following 688 | */ 689 | state: Lens<{ 690 | me: { 691 | /** 692 | * This `Connection` does not have a second type variable because it doesn't take any input 693 | */ 694 | profile: Connection<{ 695 | account: { 696 | emailPreferences: { 697 | subscribed: boolean; 698 | }; 699 | }; 700 | }>; 701 | }; 702 | }>; 703 | }; 704 | 705 | export const EmailPreferencesApp = (props: Props) => { 706 | const [subscribed, setSubscribed] = props.state.me.profile().account.emailPreferences.subscribed.use(); 707 | 708 | // ... 709 | }; 710 | ``` 711 | 712 | This component `EmailPreferencesApp` will be suspended until the profile connection has resolved a value. The connection itself might look something like this. 713 | 714 | ```ts 715 | type Profile = { 716 | account: { 717 | emailPreferences: { 718 | subscribed: boolean; 719 | }; 720 | }; 721 | }; 722 | 723 | const profile = connection((store) => { 724 | fetch("/profile") 725 | .then((resp) => resp.json()) 726 | .then((data) => { 727 | store.setSnapshot(data); 728 | }); 729 | }); 730 | ``` 731 | 732 | :warning: Calling `.use()` on a connection—for example, `props.state.me.profile.use()` will return the raw `Connection` which you should not attempt to replace or write to. It is a special object with magical powers, so just don't. :warning: 733 | 734 | ### Connection 735 | 736 | ```ts 737 | type Connection = {}; 738 | ``` 739 | 740 | The type returned by `connection`. It is useless outside of the library internals, but necessary for typing your state/lens. 741 | 742 | - `A`: The data kept inside of the store. 743 | - `I`: The input data provided to the store. 744 | 745 | ### useCreateLens 746 | 747 | ```ts 748 | declare function useCreateLens(initialState: S | (() => S)): Lens; 749 | ``` 750 | 751 | A convenience wrapper that memoizes a call to `createLens`. If passed a function, it will call it once when creating the `Lens`. 752 | 753 | ## Use without TypeScript 754 | 755 | This library relies heavily on the meta-programming capabilities afforded by TypeScript + Proxy. I really do not recommend using this without TypeScript. It's 2021. Why aren't you writing TypeScript, bud? 756 | -------------------------------------------------------------------------------- /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garbles/concave/2676c69d54b0645ac4ed6e8e51c61a6cac2564d4/image.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "concave", 3 | "version": "0.2.0", 4 | "description": "Lens-like state management (for React)", 5 | "main": "dist/index.js", 6 | "module": "dist/index.mjs", 7 | "types": "dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "import": "./dist/index.mjs", 11 | "require": "./dist/index.js" 12 | } 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "author": "Gabe Scholz", 18 | "bugs": { 19 | "url": "https://github.com/garbles/concave/issues" 20 | }, 21 | "homepage": "https://github.com/garbles/concave", 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/garbles/concave.git" 25 | }, 26 | "license": "MIT", 27 | "sideEffects": false, 28 | "keywords": [ 29 | "react", 30 | "lens", 31 | "optics", 32 | "state", 33 | "state-management", 34 | "store", 35 | "redux" 36 | ], 37 | "scripts": { 38 | "test": "jest", 39 | "typecheck": "tsc --noEmit", 40 | "build": "tsup", 41 | "prepublish": "yarn test && yarn typecheck && yarn build" 42 | }, 43 | "dependencies": {}, 44 | "devDependencies": { 45 | "@testing-library/react": "^13.0.0-alpha.5", 46 | "@types/jest": "^27.0.3", 47 | "@types/react": "^17.0.37", 48 | "@types/react-dom": "^17.0.11", 49 | "doctoc": "^2.1.0", 50 | "jest": "^27.4.5", 51 | "react": "^18.0.0-rc.0", 52 | "react-dom": "^18.0.0-rc.0", 53 | "ts-jest": "^27.1.2", 54 | "tsup": "^5.11.8", 55 | "typescript": "^4.5.4" 56 | }, 57 | "peerDependencies": { 58 | "@types/react": "^17.0.37", 59 | "react": "^18.0.0-rc.0" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/awaitable.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from "./is-object"; 2 | 3 | export type Awaitable = T | PromiseLike; 4 | 5 | const isPromiseLike = (obj: any): obj is PromiseLike => { 6 | return isObject(obj) && Reflect.has(obj, "then"); 7 | }; 8 | 9 | /** 10 | * Wraps a callback that returns an `Awaitable`. This is benefitial over async/await in 11 | * some cases because it can be made to be synchronous when the callback does not return 12 | * a promise and therefore calling `.then()` will yield immediately instead of waiting 13 | * like a typical promise does. If the callback does return a promise then the consumer 14 | * will wait. 15 | */ 16 | export const awaitable = 17 | (get: () => Awaitable) => 18 | (): PromiseLike => { 19 | return { 20 | then(onfulfilled) { 21 | const value = get(); 22 | onfulfilled ??= null; 23 | 24 | if (onfulfilled === null) { 25 | throw new Error("Unexpected error. Do not use awaitable(value).then()"); 26 | } 27 | 28 | if (isPromiseLike(value)) { 29 | return value.then(onfulfilled); 30 | } 31 | 32 | const result = onfulfilled(value); 33 | 34 | if (isPromiseLike(result)) { 35 | return result; 36 | } 37 | 38 | return awaitable(() => result)(); 39 | }, 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /src/basic-lens.test.ts: -------------------------------------------------------------------------------- 1 | import { basicLens, prop } from "./basic-lens"; 2 | 3 | type State = { 4 | a: { 5 | b: { 6 | c: number; 7 | }; 8 | d: { 9 | e: string; 10 | }; 11 | f?: string; 12 | }; 13 | }; 14 | 15 | const state: State = { 16 | a: { 17 | b: { 18 | c: 100, 19 | }, 20 | d: { 21 | e: "chicken", 22 | }, 23 | }, 24 | }; 25 | 26 | const lens = basicLens(); 27 | const a = prop(lens, "a"); 28 | const b = prop(a, "b"); 29 | const c = prop(b, "c"); 30 | const f = prop(a, "f"); 31 | 32 | test("always returns the same base lens", () => { 33 | const a = basicLens(); 34 | const b = basicLens(); 35 | 36 | expect(a).toBe(b); 37 | }); 38 | 39 | test("drills data", () => { 40 | expect(lens.get(state)).toEqual(state); 41 | expect(a.get(state)).toEqual(state.a); 42 | expect(b.get(state)).toEqual(state.a.b); 43 | expect(c.get(state)).toEqual(state.a.b?.c); 44 | }); 45 | 46 | test("deeply sets data", () => { 47 | const next = c.set(state, 300); 48 | 49 | expect(next).not.toEqual(state); 50 | expect(next).toEqual({ a: { b: { c: 300 }, d: { e: "chicken" } } }); 51 | }); 52 | 53 | test("only updates parts of the data", () => { 54 | const next = b.set(state, { c: 0 }); 55 | 56 | expect(next.a.d).toBe(state.a.d); 57 | }); 58 | -------------------------------------------------------------------------------- /src/basic-lens.ts: -------------------------------------------------------------------------------- 1 | import { shallowCopy } from "./shallow-copy"; 2 | 3 | export type BasicLens = { 4 | get(s: S): A; 5 | set(s: S, a: A): S; 6 | }; 7 | 8 | const identity: BasicLens = Object.freeze({ 9 | get(s) { 10 | return s; 11 | }, 12 | set(s, a) { 13 | return a; 14 | }, 15 | }); 16 | 17 | export const basicLens = (): BasicLens => identity; 18 | 19 | export const prop = ( 20 | lens: BasicLens, 21 | key: K 22 | ): BasicLens => { 23 | return { 24 | get(s) { 25 | const a = lens.get(s); 26 | return a[key]; 27 | }, 28 | 29 | set(s, b) { 30 | const a = lens.get(s); 31 | const copy = shallowCopy(a); 32 | copy[key] = b; 33 | 34 | return lens.set(s, copy); 35 | }, 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /src/breaker.test.ts: -------------------------------------------------------------------------------- 1 | import { Breaker } from "./breaker"; 2 | 3 | test("connects/disconnects", () => { 4 | const unsubscribe = jest.fn(); 5 | const subscribe = jest.fn(() => unsubscribe); 6 | 7 | const breaker = new Breaker(subscribe); 8 | 9 | expect(breaker.connected).toBe(false); 10 | 11 | breaker.connect(); 12 | 13 | expect(breaker.connected).toBe(true); 14 | expect(subscribe).toHaveBeenCalledTimes(1); 15 | expect(unsubscribe).toHaveBeenCalledTimes(0); 16 | 17 | breaker.connect(); 18 | 19 | expect(breaker.connected).toBe(true); 20 | expect(subscribe).toHaveBeenCalledTimes(1); 21 | expect(unsubscribe).toHaveBeenCalledTimes(0); 22 | 23 | breaker.disconnect(); 24 | 25 | expect(breaker.connected).toBe(false); 26 | expect(subscribe).toHaveBeenCalledTimes(1); 27 | expect(unsubscribe).toHaveBeenCalledTimes(1); 28 | 29 | breaker.connect(); 30 | 31 | expect(breaker.connected).toBe(true); 32 | expect(subscribe).toHaveBeenCalledTimes(2); 33 | expect(unsubscribe).toHaveBeenCalledTimes(1); 34 | }); 35 | -------------------------------------------------------------------------------- /src/breaker.ts: -------------------------------------------------------------------------------- 1 | import { Unsubscribe } from "./types"; 2 | 3 | type State = { connected: false } | { connected: true; unsubscribe: Unsubscribe }; 4 | 5 | export interface BreakerLike { 6 | connect(): void; 7 | disconnect(): void; 8 | } 9 | 10 | export class Breaker implements BreakerLike { 11 | static noop() { 12 | return new Breaker(() => () => {}); 13 | } 14 | 15 | private state: State = { connected: false }; 16 | 17 | constructor(private subscribe: () => Unsubscribe) {} 18 | 19 | get connected() { 20 | return this.state.connected; 21 | } 22 | 23 | connect() { 24 | if (this.state.connected) { 25 | return; 26 | } 27 | 28 | const unsubscribe = this.subscribe.call(null); 29 | 30 | this.state = { 31 | connected: true, 32 | unsubscribe, 33 | }; 34 | } 35 | 36 | disconnect() { 37 | if (!this.state.connected) { 38 | return; 39 | } 40 | 41 | this.state.unsubscribe(); 42 | 43 | this.state = { 44 | connected: false, 45 | }; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/connection.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from "./is-object"; 2 | import { LensFocus, refineLensFocus } from "./lens-focus"; 3 | import { doNotShallowCopy } from "./shallow-copy"; 4 | import { Store } from "./store"; 5 | import { SuspendedClosure } from "./suspended-closure"; 6 | import { Unsubscribe } from "./types"; 7 | 8 | type ConnectionCache = { 9 | [cacheKey: string]: SuspendedClosure; 10 | }; 11 | 12 | type ValueCache = { 13 | [cacheKey: string]: A; 14 | }; 15 | 16 | type InsertConnection = (store: Store, input: I, cacheKey: string) => SuspendedClosure; 17 | 18 | const INSERT = Symbol(); 19 | const CACHE = Symbol(); 20 | 21 | export type Connection = { 22 | [INSERT]: InsertConnection; 23 | [CACHE]: ValueCache; 24 | }; 25 | 26 | export const connection = ( 27 | create: (store: Store, input: I) => Unsubscribe | void 28 | ): Connection => { 29 | const connectionCache: ConnectionCache = {}; 30 | 31 | const stub = doNotShallowCopy({} as ValueCache); 32 | /** 33 | * Wrap the real cache to handle suspense. 34 | */ 35 | const cache = new Proxy(stub, { 36 | get(_target, _key): A { 37 | let key = _key as keyof ConnectionCache; 38 | 39 | /** 40 | * If the value is not in the cache then create an unresolved entry for it. 41 | * This can happen if we call `getSnapshot()` before the connection has even 42 | * had a chance to insert an entry for the cache yet. 43 | */ 44 | let cached = (connectionCache[key] ??= new SuspendedClosure()); 45 | 46 | return cached.getSnapshot(); 47 | }, 48 | 49 | set(_target, _key, value) { 50 | let key = _key as keyof ConnectionCache; 51 | let conn = connectionCache[key]; 52 | 53 | if (conn === undefined) { 54 | return false; 55 | } 56 | 57 | conn.setSnapshot(value); 58 | return true; 59 | }, 60 | }); 61 | 62 | const insert: InsertConnection = (store, input, cacheKey) => { 63 | /** 64 | * It can be that the cache entry was previously created by trying to 65 | * access the cache because the code had been loaded. 66 | */ 67 | let conn = (connectionCache[cacheKey] ??= new SuspendedClosure()); 68 | 69 | conn.load(() => create(store, input) ?? (() => {})); 70 | 71 | return conn; 72 | }; 73 | 74 | const conn = doNotShallowCopy({} as Connection); 75 | 76 | Object.defineProperties(conn, { 77 | [INSERT]: { 78 | configurable: true, 79 | enumerable: true, 80 | writable: false, 81 | value: insert, 82 | }, 83 | [CACHE]: { 84 | configurable: true, 85 | enumerable: true, 86 | writable: true, 87 | value: cache, 88 | }, 89 | }); 90 | 91 | return conn; 92 | }; 93 | 94 | export const focusToCache = (focus: LensFocus>, cacheKey: string): LensFocus => 95 | refineLensFocus(focus, [CACHE, cacheKey]); 96 | 97 | export const insert = (conn: Connection, store: Store, input: I, cacheKey: string) => { 98 | return conn[INSERT](store, input, cacheKey); 99 | }; 100 | 101 | export const isConnection = (conn: any): conn is Connection => { 102 | return isObject(conn) && Reflect.has(conn, INSERT) && Reflect.has(conn, CACHE); 103 | }; 104 | -------------------------------------------------------------------------------- /src/create-lens.ts: -------------------------------------------------------------------------------- 1 | import { ProxyLens, proxyLens } from "./proxy-lens"; 2 | import { createRootStoreFactory } from "./store"; 3 | 4 | export const createLens = (initialState: S): ProxyLens => { 5 | const [factory, focus] = createRootStoreFactory(initialState); 6 | return proxyLens(factory, focus); 7 | }; 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { ProxyLens } from "./proxy-lens"; 2 | export { connection } from "./connection"; 3 | export type { Connection } from "./connection"; 4 | export { createLens } from "./create-lens"; 5 | export { useCreateLens } from "./react"; 6 | export type { Store } from "./store"; 7 | export type Lens = ProxyLens; 8 | -------------------------------------------------------------------------------- /src/is-object.ts: -------------------------------------------------------------------------------- 1 | export const isObject = (obj: any): obj is object => Object.prototype.toString.call(obj) === "[object Object]"; 2 | -------------------------------------------------------------------------------- /src/key-path-to-string.test.ts: -------------------------------------------------------------------------------- 1 | import { keyPathToString } from "./key-path-to-string"; 2 | 3 | test("joins a keypath together", () => { 4 | const keyPath = ["a", "b", 10, "c", "55", Symbol("hello"), "d"]; 5 | 6 | const result = keyPathToString(keyPath); 7 | 8 | expect(result).toEqual("root.a.b[10].c[55][Symbol(hello)].d"); 9 | }); 10 | -------------------------------------------------------------------------------- /src/key-path-to-string.ts: -------------------------------------------------------------------------------- 1 | import { Key } from "./types"; 2 | 3 | const cache = new WeakMap(); 4 | const IS_NUMBER_STRING = /^\d+$/; 5 | 6 | export const keyPathToString = (keyPath: Key[]) => { 7 | let cached = cache.get(keyPath); 8 | 9 | if (!cached) { 10 | cached = "root"; 11 | 12 | for (let key of keyPath) { 13 | if (typeof key === "symbol" || typeof key === "number" || key.match(IS_NUMBER_STRING)) { 14 | cached += `[${String(key)}]`; 15 | } else { 16 | cached += `.${key}`; 17 | } 18 | } 19 | 20 | cache.set(keyPath, cached); 21 | } 22 | 23 | return cached; 24 | }; 25 | -------------------------------------------------------------------------------- /src/lens-focus.ts: -------------------------------------------------------------------------------- 1 | import { basicLens, BasicLens, prop } from "./basic-lens"; 2 | import { Key } from "./types"; 3 | 4 | export type LensFocus = { 5 | keyPath: Key[]; 6 | lens: BasicLens; 7 | }; 8 | 9 | export function refineLensFocus(focus: LensFocus, keys: [K1]): LensFocus; 10 | 11 | export function refineLensFocus( 12 | focus: LensFocus, 13 | keys: [K1, K2] 14 | ): LensFocus; 15 | 16 | export function refineLensFocus( 17 | focus: LensFocus, 18 | keys: [K1, K2, K3] 19 | ): LensFocus; 20 | 21 | export function refineLensFocus(focus: LensFocus, keys: Key[]): LensFocus { 22 | const keyPath = [...focus.keyPath, ...keys]; 23 | let lens = focus.lens; 24 | 25 | for (const key of keys) { 26 | lens = prop(lens, key); 27 | } 28 | 29 | return { keyPath, lens }; 30 | } 31 | 32 | export const rootLensFocus = (): LensFocus => { 33 | return { 34 | keyPath: [], 35 | lens: basicLens(), 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /src/proxy-lens.test.ts: -------------------------------------------------------------------------------- 1 | import { Connection, connection } from "./connection"; 2 | import { proxyLens, ProxyLens } from "./proxy-lens"; 3 | import { ProxyValue, proxyValue } from "./proxy-value"; 4 | import { ReactDevtools } from "./react-devtools"; 5 | import { createRootStoreFactory } from "./store"; 6 | import { Update, Updater } from "./types"; 7 | 8 | type State = { 9 | a: { 10 | b: { 11 | c: number; 12 | }; 13 | d: { 14 | e: string; 15 | }; 16 | 17 | f: Array<{ g: boolean }>; 18 | }; 19 | }; 20 | 21 | const initialState = (): State => ({ 22 | a: { 23 | b: { 24 | c: 0, 25 | }, 26 | d: { 27 | e: "cool", 28 | }, 29 | 30 | f: [{ g: true }, { g: false }, { g: true }], 31 | }, 32 | }); 33 | 34 | let lens: ProxyLens; 35 | 36 | beforeEach(() => { 37 | const [factory, focus] = createRootStoreFactory(initialState()); 38 | 39 | lens = proxyLens(factory, focus); 40 | }); 41 | 42 | const useLens = (proxy: ProxyLens): [ProxyValue, Update] => { 43 | const store = proxy.getStore(); 44 | 45 | const update = (updater: Updater) => { 46 | const prev = store.getSnapshot(); 47 | const next = updater(prev); 48 | 49 | return store.setSnapshot(next); 50 | }; 51 | 52 | return [proxyValue(store.getSnapshot(), proxy), update]; 53 | }; 54 | 55 | describe("use", () => { 56 | test("creates a wrapper around a value", () => { 57 | const [state] = useLens(lens); 58 | expect(state.toJSON()).toEqual(lens.getStore().getSnapshot()); 59 | 60 | const [bState] = useLens(lens.a.b); 61 | expect(bState.toJSON()).toEqual(lens.getStore().getSnapshot().a.b); 62 | }); 63 | 64 | test("can update state", () => { 65 | const [bState, setB] = useLens(lens.a.b); 66 | 67 | setB(() => ({ c: 500 })); 68 | 69 | const [nextBState] = useLens(lens.a.b); 70 | 71 | expect(bState.toJSON()).not.toEqual(nextBState.toJSON()); 72 | expect(nextBState).toMatchObject({ c: 500 }); 73 | }); 74 | 75 | test("does not expose `toLens` on primitive values", () => { 76 | const [bState] = useLens(lens.a.b); 77 | const [cState] = useLens(lens.a.b.c); 78 | 79 | expect(bState).toHaveProperty("toLens"); 80 | expect(cState).not.toHaveProperty("toLens"); 81 | }); 82 | }); 83 | 84 | test("always returns the same proxy value", () => { 85 | const [state1, updateState] = useLens(lens); 86 | const [state2] = useLens(lens); 87 | const [aState] = useLens(lens.a); 88 | 89 | expect(state1).toBe(state2); 90 | expect(state1.toJSON).toBe(state2.toJSON); 91 | expect(state1.a).toBe(aState); 92 | 93 | updateState((prev) => ({ ...prev })); 94 | 95 | const [state3] = useLens(lens); 96 | 97 | expect(state3).not.toBe(state2); 98 | expect(state3.toJSON).not.toBe(state2.toJSON); 99 | expect(state3.a).toBe(aState); 100 | }); 101 | 102 | describe("returning the same proxy lens", () => { 103 | test("returns the same proxy lens when toggled", () => { 104 | const [state] = useLens(lens); 105 | 106 | expect(state.toLens()).toBe(lens); 107 | }); 108 | 109 | test("from within lists of things", () => { 110 | const [fState] = useLens(lens.a.f); 111 | 112 | const first = fState[0]; 113 | 114 | expect(first.toLens()).toBe(lens.a.f[0]); 115 | }); 116 | 117 | test("when a list is copied but the members stay the same", () => { 118 | const [fState, setF] = useLens(lens.a.f); 119 | const f1 = fState[0]; 120 | 121 | // problem here is we return the wrapped value instead of the next one and so they don't wrap 122 | setF((f) => [...f, { g: true }]); 123 | 124 | const [nextFState] = useLens(lens.a.f); 125 | const nextF1 = nextFState[0]; 126 | 127 | expect(fState.length + 1).toEqual(nextFState.length); 128 | expect(f1.toLens()).toBe(nextF1.toLens()); // the lens should be the same 129 | expect(nextFState.toLens()).toBe(lens.a.f); 130 | expect(f1).toBe(nextF1); // the proxy should have the same value 131 | }); 132 | 133 | test("when an object is copied by the members stay the same", () => { 134 | const [bState, setBState] = useLens(lens.a.b); 135 | const [dState] = useLens(lens.a.d); 136 | 137 | const nextBState = { c: 5000 }; 138 | 139 | setBState(() => ({ c: 5000 })); 140 | 141 | const [aState] = useLens(lens.a); 142 | 143 | expect(aState.b.toJSON()).toEqual(nextBState); 144 | expect(aState.d).toBe(dState); 145 | expect(aState.d.toLens()).toBe(lens.a.d); 146 | expect(dState.toLens()).toBe(lens.a.d); 147 | expect(bState.toLens()).toBe(lens.a.b); 148 | expect(bState.toLens()).toBe(aState.b.toLens()); 149 | }); 150 | 151 | test("checking for errors when making copies", () => { 152 | const [obj] = useLens(lens); 153 | 154 | expect(() => ({ ...obj })).not.toThrow(); 155 | expect(() => Object.assign({}, obj)).not.toThrow(); 156 | 157 | expect(() => [...obj.a.f]).not.toThrow(); 158 | 159 | // iterate 160 | for (const key in obj) { 161 | expect(typeof key).not.toEqual("symbol"); 162 | } 163 | 164 | for (const value of obj.a.f) { 165 | expect(typeof value).not.toEqual("symbol"); 166 | } 167 | 168 | expect(() => ({ ...lens })).toThrow(); 169 | 170 | expect(() => Object.getOwnPropertyDescriptors(lens)).toThrow(); 171 | }); 172 | }); 173 | 174 | describe("inside React Devtools", () => { 175 | test("does not throw getting descriptors", () => { 176 | jest.spyOn(ReactDevtools, "isCalledInsideReactDevtools").mockImplementationOnce(() => true); 177 | expect(() => Object.getOwnPropertyDescriptors(lens)).not.toThrow(); 178 | }); 179 | }); 180 | 181 | test("making a copy of a ProxyValue preserves the same attributes", () => { 182 | const [obj] = useLens(lens); 183 | const copy = { ...obj }; 184 | 185 | expect(copy.toLens()).toBe(lens); 186 | expect(copy.toJSON()).toEqual(JSON.parse(JSON.stringify(copy))); 187 | expect(copy).toEqual(obj); 188 | }); 189 | 190 | test("making a copy, dot-navigating, and then returning to a lens works", () => { 191 | const [obj] = useLens(lens); 192 | const copy = { ...obj }; 193 | 194 | const b = copy.a.b; 195 | const f0 = copy.a.f[0]; 196 | 197 | expect(b.toLens()).toBe(lens.a.b); 198 | expect(f0.toLens()).toBe(lens.a.f[0]); 199 | }); 200 | 201 | describe("connections", () => { 202 | let disconnected = jest.fn(); 203 | 204 | beforeEach(() => { 205 | disconnected = jest.fn(); 206 | }); 207 | 208 | type ConnectionState = { 209 | b: { 210 | c: number; 211 | }; 212 | }; 213 | 214 | const conn = connection((store) => { 215 | store.setSnapshot({ b: { c: 20 } }); 216 | 217 | return disconnected; 218 | }); 219 | 220 | const [factory, focus] = createRootStoreFactory({ a: conn }); 221 | 222 | const create = () => { 223 | return proxyLens<{ a: Connection }, { a: Connection }>(factory, focus); 224 | }; 225 | 226 | test("throws on first sync call if data is not there", () => { 227 | const lens = create(); 228 | 229 | expect(() => lens.a.getStore().getSnapshot()).not.toThrow(); 230 | expect(() => lens.a().getStore().getSnapshot()).toThrow(); 231 | expect(() => lens.a().b.getStore().getSnapshot()).toThrow(); 232 | expect(() => lens.a().b.c.getStore().getSnapshot()).toThrow(); 233 | }); 234 | 235 | test("does not throw when calling for async data", async () => { 236 | const lens = create(); 237 | const bStore = lens.a().b.getStore(); 238 | 239 | const unsubscribe = bStore.subscribe(); 240 | await new Promise((res) => setTimeout(res)); 241 | 242 | const value = bStore.getSnapshot(); 243 | 244 | expect(value).toEqual({ c: 20 }); 245 | 246 | unsubscribe(); 247 | 248 | await new Promise((res) => setTimeout(res)); 249 | 250 | expect(disconnected).toHaveBeenCalled(); 251 | }); 252 | 253 | test("allows nested connections", () => { 254 | const state = { 255 | b: connection<{ a: Connection }, number>((store, input) => { 256 | const a = connection((s) => { 257 | s.setSnapshot(input + 20); 258 | }); 259 | 260 | store.setSnapshot({ a }); 261 | }), 262 | }; 263 | 264 | const [factory, focus] = createRootStoreFactory(state); 265 | const lens = proxyLens(factory, focus); 266 | 267 | const aStore = lens.b(5).a().getStore(); 268 | 269 | const unsubscribe = aStore.subscribe(); 270 | 271 | expect(aStore.getSnapshot()).toEqual(25); 272 | 273 | unsubscribe(); 274 | }); 275 | 276 | test("allows connectiions to be swapped for other values", () => { 277 | const setup = jest.fn(); 278 | const cleanup = jest.fn(); 279 | 280 | const conn = connection((store, input) => { 281 | setup(); 282 | 283 | store.setSnapshot(50 + input); 284 | 285 | return cleanup; 286 | }); 287 | 288 | const state: { b: Connection | string } = { 289 | b: conn, 290 | }; 291 | 292 | const [factory, focus] = createRootStoreFactory(state); 293 | const lens = proxyLens(factory, focus); 294 | 295 | const bStore = lens.b.getStore(); 296 | const bConnStore = (lens.b as any)(20).getStore(); 297 | 298 | const unsubscribe = bConnStore.subscribe(); 299 | 300 | /** 301 | * Connection works as expected 302 | */ 303 | expect(bConnStore.getSnapshot()).toEqual(70); 304 | 305 | expect(cleanup).not.toHaveBeenCalled(); 306 | 307 | (bStore as any).setSnapshot("gabe"); 308 | 309 | /** 310 | * Cleanup is called when the connection is removed. 311 | */ 312 | expect(cleanup).toHaveBeenCalledTimes(1); 313 | 314 | /** 315 | * Using the connection store will throw an error now because 316 | * the cache is not inserted into the store. 317 | */ 318 | expect(() => bConnStore.getSnapshot()).toThrow(); 319 | 320 | /** 321 | * Immediately connects when connection is re-established. 322 | */ 323 | expect(setup).toHaveBeenCalledTimes(1); 324 | (bStore as any).setSnapshot(conn); 325 | expect(setup).toHaveBeenCalledTimes(2); 326 | 327 | /** 328 | * Value can be accessed again. 329 | */ 330 | expect(bConnStore.getSnapshot()).toEqual(70); 331 | 332 | unsubscribe(); 333 | 334 | /** 335 | * Cleanup function is called again. 336 | */ 337 | expect(cleanup).toHaveBeenCalledTimes(2); 338 | }); 339 | 340 | test("adjacent connections with the same input do not share the same data", () => { 341 | const conn = connection((store, input) => { 342 | store.setSnapshot(input + 100); 343 | }); 344 | 345 | const state = { 346 | a: conn, 347 | b: conn, 348 | }; 349 | 350 | const [factory, focus] = createRootStoreFactory(state); 351 | const lens = proxyLens(factory, focus); 352 | 353 | const aStore = lens.a(20).getStore(); 354 | const bStore = lens.b(20).getStore(); 355 | 356 | const aUnsubscribe = aStore.subscribe(); 357 | const bUnsubscribe = bStore.subscribe(); 358 | 359 | expect(aStore.getSnapshot()).toEqual(120); 360 | expect(bStore.getSnapshot()).toEqual(120); 361 | 362 | aStore.setSnapshot(0); 363 | 364 | expect(aStore.getSnapshot()).toEqual(0); 365 | expect(bStore.getSnapshot()).toEqual(120); 366 | 367 | bStore.setSnapshot(-100); 368 | 369 | expect(aStore.getSnapshot()).toEqual(0); 370 | expect(bStore.getSnapshot()).toEqual(-100); 371 | 372 | aUnsubscribe(); 373 | bUnsubscribe(); 374 | }); 375 | }); 376 | -------------------------------------------------------------------------------- /src/proxy-lens.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from "./connection"; 2 | import { keyPathToString } from "./key-path-to-string"; 3 | import { LensFocus, refineLensFocus } from "./lens-focus"; 4 | import { ProxyValue } from "./proxy-value"; 5 | import { createUseLens } from "./react"; 6 | import { ReactDevtools } from "./react-devtools"; 7 | import { ShouldUpdate } from "./should-update"; 8 | import { createConnectionStoreFactory, Store } from "./store"; 9 | import { AnyArray, AnyConnection, AnyObject, AnyPrimitive, Update } from "./types"; 10 | 11 | type StoreFactory = (focus: LensFocus) => Store; 12 | 13 | type BaseProxyLens = { 14 | /** 15 | * Fetches the store for current focus. 16 | */ 17 | getStore(): Store; 18 | 19 | /** 20 | * Collapses the lens into a React hook. 21 | */ 22 | use(shouldUpdate?: ShouldUpdate): [ProxyValue, Update]; 23 | /** 24 | * A unique key for cases when you need a key. e.g. A React list. 25 | * 26 | * @example 27 | * const [list] = useLens(state); 28 | * 29 | * list.map(value => { 30 | * const lens = value.toLens(); 31 | * 32 | * return ; 33 | * }); 34 | */ 35 | $key: string; 36 | }; 37 | 38 | type ConnectionProxyLens = BaseProxyLens & 39 | (A extends Connection ? (input: I) => ProxyLens : {}); 40 | 41 | type ArrayProxyLens = BaseProxyLens & { [K in number]: ProxyLens }; 42 | type ObjectProxyLens = BaseProxyLens & { [K in keyof A]: ProxyLens }; 43 | type PrimitiveProxyLens = BaseProxyLens; 44 | 45 | // prettier-ignore 46 | export type ProxyLens = 47 | A extends AnyConnection ? ConnectionProxyLens : 48 | A extends AnyObject ? ObjectProxyLens : 49 | A extends AnyArray ? ArrayProxyLens : 50 | A extends AnyPrimitive ? PrimitiveProxyLens : 51 | never; 52 | 53 | const THROW_ON_COPY = Symbol(); 54 | const specialKeys: (keyof BaseProxyLens<{}>)[] = ["use", "getStore", "$key"]; 55 | const functionTrapKeys = ["arguments", "caller", "prototype"]; 56 | 57 | export const proxyLens = (storeFactory: StoreFactory, focus: LensFocus): ProxyLens => { 58 | type KeyCache = { [K in keyof A]?: ProxyLens }; 59 | type ConnectionCache = { [cacheKey: string]: A extends Connection ? ProxyLens : never }; 60 | type Target = Partial>; 61 | 62 | let keyCache: KeyCache; 63 | let connectionCache: ConnectionCache; 64 | let $key: string; 65 | let use: (shouldUpdate?: ShouldUpdate) => [ProxyValue, Update]; 66 | let getStore: () => Store; 67 | 68 | /** 69 | * Use a function here so that we can trick the Proxy into allowing us to use `apply` 70 | * for connections. This won't really impact performance for property access because `ProxyLens` 71 | * never actually accesses target properties. Further, constructing a function is slightly 72 | * slower than constructing an object, but it is only done once and then cached forever. 73 | */ 74 | const proxy = new Proxy(function () {} as Target, { 75 | apply(_target, _thisArg, argsArray) { 76 | const connCache = (connectionCache ??= {}); 77 | const [input] = argsArray; 78 | const cacheKey = JSON.stringify(input); 79 | let next = connCache[cacheKey]; 80 | 81 | if (!next) { 82 | const [nextFactory, nextFocus] = createConnectionStoreFactory(storeFactory, focus as any, input); 83 | next = connCache[cacheKey] = proxyLens(nextFactory, nextFocus) as any; 84 | } 85 | 86 | return next; 87 | }, 88 | 89 | get(_target, key) { 90 | /** 91 | * Block React introspection as it will otherwise produce an infinite chain of 92 | * ProxyLens values in React Devtools. 93 | */ 94 | if (key === "$$typeof") { 95 | return undefined; 96 | } 97 | 98 | if (key === "$key") { 99 | $key ??= keyPathToString(focus.keyPath); 100 | return $key; 101 | } 102 | 103 | if (key === "use") { 104 | use ??= createUseLens(proxy); 105 | return use; 106 | } 107 | 108 | if (key === "getStore") { 109 | getStore ??= () => storeFactory(focus); 110 | return getStore; 111 | } 112 | 113 | keyCache ??= {}; 114 | 115 | if (keyCache[key as keyof A] === undefined) { 116 | const nextFocus = refineLensFocus(focus, [key as keyof A]); 117 | const nextProxy = proxyLens(storeFactory, nextFocus); 118 | keyCache[key as keyof A] = nextProxy; 119 | } 120 | 121 | return keyCache[key as keyof A]; 122 | }, 123 | 124 | ownKeys(_target) { 125 | return [...specialKeys, ...functionTrapKeys, THROW_ON_COPY]; 126 | }, 127 | 128 | has(_target, key) { 129 | return specialKeys.includes(key as keyof BaseProxyLens<{}>); 130 | }, 131 | 132 | getOwnPropertyDescriptor(target, key) { 133 | if (specialKeys.includes(key as keyof BaseProxyLens<{}>)) { 134 | return { 135 | configurable: true, 136 | enumerable: true, 137 | writable: false, 138 | value: proxy[key as keyof Partial>], 139 | }; 140 | } 141 | 142 | if (functionTrapKeys.includes(key as keyof Target)) { 143 | return Reflect.getOwnPropertyDescriptor(target, key); 144 | } 145 | 146 | /** 147 | * This is a hack to ensure that when React Devtools is 148 | * reading all of the props with `getOwnPropertyDescriptors` 149 | * it does not throw an error. 150 | */ 151 | if (ReactDevtools.isCalledInsideReactDevtools()) { 152 | return { 153 | configurable: true, 154 | enumerable: false, 155 | value: undefined, 156 | }; 157 | } 158 | 159 | /** 160 | * We otherwise do not want the lens to be introspected with `Object.getOwnPropertyDescriptors` 161 | * which will happen internally with `{ ...lens }` or `Object.assign({}, lens)`. 162 | * Both of those operations will create a new plain object from the properties that it can retrieve 163 | * off of the lens; however, the lens is a shell around nothing and relies _heavily_ on TypeScript 164 | * telling the developer which attributes are available. Therefore, copying the lens will leave you 165 | * with an object that only has `$key` and `use`. Accessing `lens.user`, for example, will be 166 | * `undefined` and will not be caught by TypeScript because the Proxy is typed as `A & { $key, use }`. 167 | * 168 | * If we've reached here, we are trying to access the property descriptor for `THROW_ON_COPY`, 169 | * which is not a real property on the lens, so just throw. 170 | */ 171 | throw new Error( 172 | "ProxyLens threw because you tried to access all property descriptors—probably through " + 173 | "`{ ...lens }` or `Object.assign({}, lens)`. Doing this will break the type safety offered by " + 174 | "this library so it is forbidden. Sorry, buddy pal." 175 | ); 176 | }, 177 | 178 | getPrototypeOf() { 179 | return null; 180 | }, 181 | preventExtensions() { 182 | return true; 183 | }, 184 | isExtensible() { 185 | return false; 186 | }, 187 | set() { 188 | throw new Error("Cannot set property on ProxyLens"); 189 | }, 190 | deleteProperty() { 191 | throw new Error("Cannot delete property on ProxyLens"); 192 | }, 193 | }) as ProxyLens; 194 | 195 | return proxy; 196 | }; 197 | -------------------------------------------------------------------------------- /src/proxy-value.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from "./is-object"; 2 | import { ProxyLens } from "./proxy-lens"; 3 | import { AnyArray, AnyObject, AnyPrimitive, Key } from "./types"; 4 | 5 | type Proxyable = AnyArray | AnyObject; 6 | 7 | type BaseProxyValue = { 8 | toJSON(): A; 9 | toLens(): ProxyLens; 10 | }; 11 | 12 | type ArrayProxyValue = BaseProxyValue & Array>; 13 | type ObjectProxyValue = BaseProxyValue & { [K in keyof A]: ProxyValue }; 14 | 15 | // prettier-ignore 16 | export type ProxyValue = 17 | A extends AnyArray ? ArrayProxyValue : 18 | A extends AnyObject ? ObjectProxyValue : 19 | A extends AnyPrimitive ? A : 20 | never; 21 | 22 | type Target = { data: A; lens: ProxyLens; toJSON?(): A; toLens?(): ProxyLens }; 23 | 24 | const isProxyable = (obj: any): obj is Proxyable => Array.isArray(obj) || isObject(obj); 25 | 26 | const proxyHandler: ProxyHandler> = { 27 | get(target, key) { 28 | if (key === "toJSON") { 29 | target.toJSON ??= () => target.data; 30 | return target.toJSON; 31 | } 32 | 33 | if (key === "toLens") { 34 | target.toLens ??= () => target.lens; 35 | return target.toLens; 36 | } 37 | 38 | const nextData = target.data[key as keyof typeof target.data]; 39 | const nextLens = (target.lens as any)[key as keyof typeof target.lens]; 40 | 41 | return proxyValue<{}>(nextData, nextLens); 42 | }, 43 | 44 | ownKeys(target) { 45 | return Reflect.ownKeys(target.data).concat(["toLens", "toJSON"]); 46 | }, 47 | 48 | getOwnPropertyDescriptor(target, key) { 49 | /** 50 | * If the key is one of the special ProxyValue keys, 51 | * set the property descriptor to a custom value. 52 | */ 53 | if (key === "toLens" || key === "toJSON") { 54 | return { 55 | configurable: true, 56 | enumerable: true, 57 | writable: false, 58 | value: target[key], 59 | }; 60 | } 61 | 62 | const desc = Object.getOwnPropertyDescriptor(target.data, key); 63 | 64 | /** 65 | * Now bail if the descriptor is `undefined`. This could only 66 | * occur if the key is not `keyof A`. 67 | */ 68 | if (desc === undefined) { 69 | return; 70 | } 71 | 72 | return { 73 | writable: desc.writable, 74 | enumerable: desc.enumerable, 75 | configurable: desc.configurable, 76 | value: target.data[key as keyof typeof target.data], 77 | }; 78 | }, 79 | has(target, key) { 80 | return key in target.data; 81 | }, 82 | getPrototypeOf() { 83 | return null; 84 | }, 85 | preventExtensions() { 86 | return true; 87 | }, 88 | isExtensible() { 89 | return false; 90 | }, 91 | set() { 92 | throw new Error("Cannot set property on ProxyValue"); 93 | }, 94 | deleteProperty() { 95 | throw new Error("Cannot delete property on ProxyValue"); 96 | }, 97 | }; 98 | 99 | const valueCache = new WeakMap<{}, ProxyValue<{}>>(); 100 | 101 | export const proxyValue = (data: A, lens: ProxyLens): ProxyValue => { 102 | if (!isProxyable(data)) { 103 | return data as ProxyValue; 104 | } 105 | 106 | let cached = valueCache.get(data) as ProxyValue; 107 | 108 | if (!cached) { 109 | cached = new Proxy({ data, lens } as any, proxyHandler); 110 | valueCache.set(data, cached as any); 111 | } 112 | 113 | return cached; 114 | }; 115 | -------------------------------------------------------------------------------- /src/react-devtools.ts: -------------------------------------------------------------------------------- 1 | export const ReactDevtools = { 2 | /** 3 | * A shitty hack to check whether the current function call is occurring 4 | * inside React Devtools. 5 | */ 6 | isCalledInsideReactDevtools: () => { 7 | const err = new Error(); 8 | return err.stack?.includes("react_devtools_backend"); 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /src/react.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { act, render, screen, waitFor } from "@testing-library/react"; 6 | import React from "react"; 7 | import ReactDOMServer from "react-dom/server"; 8 | import { createLens } from "./create-lens"; 9 | import { ShouldUpdate } from "./should-update"; 10 | import { ProxyLens } from "./proxy-lens"; 11 | import { connection } from "./connection"; 12 | 13 | type State = { 14 | a: { 15 | b: { 16 | c: string; 17 | }; 18 | d: { 19 | e: number; 20 | }; 21 | }; 22 | f: { g: boolean }[]; 23 | }; 24 | 25 | const initialState: State = { a: { b: { c: "cool" }, d: { e: 0 } }, f: [{ g: true }, { g: false }] }; 26 | 27 | const lens = createLens(initialState); 28 | 29 | beforeEach(() => { 30 | const store = lens.getStore(); 31 | store.setSnapshot(initialState); 32 | }); 33 | 34 | const App = (props: { state: ProxyLens }) => { 35 | const [cState, setC] = props.state.a.b.c.use(); 36 | 37 | const onClick = () => setC((c) => c + "!"); 38 | 39 | return ( 40 |
41 | {cState} 42 |
43 | ); 44 | }; 45 | 46 | test("renders", () => { 47 | render(); 48 | 49 | const el = screen.getByTestId("element"); 50 | 51 | expect(el.innerHTML).toEqual("cool"); 52 | }); 53 | 54 | test("updates", () => { 55 | render(); 56 | 57 | const el = screen.getByTestId("element"); 58 | 59 | expect(el.innerHTML).toEqual("cool"); 60 | 61 | act(() => { 62 | el.click(); 63 | }); 64 | 65 | act(() => { 66 | el.click(); 67 | }); 68 | 69 | act(() => { 70 | el.click(); 71 | }); 72 | 73 | act(() => { 74 | el.click(); 75 | }); 76 | 77 | expect(el.innerHTML).toEqual("cool!!!!"); 78 | }); 79 | 80 | test("does not re-render adjacent that do not listen to same state elements", () => { 81 | let eRenderCount = 0; 82 | let bRenderCount = 0; 83 | 84 | const E = React.memo((props: { state: ProxyLens }) => { 85 | props.state.a.d.e.use(); 86 | 87 | eRenderCount++; 88 | 89 | return
; 90 | }); 91 | 92 | const B = React.memo((props: { state: ProxyLens }) => { 93 | const [b] = props.state.a.b.use(); 94 | 95 | bRenderCount++; 96 | 97 | return
; 98 | }); 99 | 100 | render( 101 | <> 102 | 103 | 104 | 105 | 106 | ); 107 | 108 | const el = screen.getByTestId("element"); 109 | const b = screen.getByTestId("b"); 110 | 111 | expect(eRenderCount).toEqual(1); 112 | expect(bRenderCount).toEqual(1); 113 | 114 | expect(JSON.parse(b.dataset.b ?? "")).toEqual({ c: "cool" }); 115 | 116 | act(() => { 117 | el.click(); 118 | el.click(); 119 | el.click(); 120 | el.click(); 121 | }); 122 | 123 | expect(eRenderCount).toEqual(1); 124 | 125 | /** 126 | * Multiple '!' added to the string, but only re-renders once 127 | */ 128 | expect(bRenderCount).toEqual(2); 129 | expect(JSON.parse(b.dataset.b ?? "")).toEqual({ c: "cool!!!!" }); 130 | }); 131 | 132 | describe("should update", () => { 133 | let gRenderCount = 0; 134 | let fRenderCount = 0; 135 | 136 | type GProps = { 137 | state: ProxyLens<{ g: boolean }>; 138 | }; 139 | 140 | const G = React.memo((props: GProps) => { 141 | const [g, updateG] = props.state.use(); 142 | 143 | const onClick = () => updateG((prev) => ({ ...prev, g: !prev.g })); 144 | 145 | gRenderCount++; 146 | 147 | return ; 148 | }); 149 | 150 | type FProps = { 151 | shouldUpdate?: ShouldUpdate; 152 | }; 153 | 154 | const F = (props: FProps) => { 155 | const [fState, updateF] = lens.f.use(props.shouldUpdate); 156 | 157 | const onClick = () => { 158 | updateF((f) => [...f, { g: true }]); 159 | }; 160 | 161 | fRenderCount++; 162 | 163 | return ( 164 |
165 | {fState.map((g) => { 166 | const lens = g.toLens(); 167 | return ; 168 | })} 169 |
171 | ); 172 | }; 173 | 174 | beforeEach(() => { 175 | gRenderCount = 0; 176 | fRenderCount = 0; 177 | }); 178 | 179 | test("re-renders a list when memebers added to a list", () => { 180 | render(); 181 | 182 | const pushGButton = screen.getByTestId("push-g-button"); 183 | 184 | expect(fRenderCount).toEqual(1); 185 | expect(gRenderCount).toEqual(2); 186 | 187 | act(() => { 188 | pushGButton.click(); 189 | }); 190 | 191 | expect(fRenderCount).toEqual(2); 192 | expect(gRenderCount).toEqual(3); 193 | 194 | act(() => { 195 | pushGButton.click(); 196 | }); 197 | 198 | expect(fRenderCount).toEqual(3); 199 | expect(gRenderCount).toEqual(4); 200 | }); 201 | 202 | test("accepts length for lists", () => { 203 | // noop. just a typecheck here 204 | render(); 205 | }); 206 | 207 | test("function returning false never re-renders", () => { 208 | render( false} />); 209 | 210 | const pushGButton = screen.getByTestId("push-g-button"); 211 | 212 | act(() => { 213 | pushGButton.click(); 214 | }); 215 | 216 | act(() => { 217 | pushGButton.click(); 218 | }); 219 | 220 | act(() => { 221 | pushGButton.click(); 222 | }); 223 | 224 | expect(fRenderCount).toEqual(1); 225 | expect(gRenderCount).toEqual(2); // Gs are never added because F does not re-render 226 | }); 227 | 228 | test.each([(prev, next) => prev.length !== next.length, [], {}] as ShouldUpdate[])( 229 | "does not re-render unless the length has changed", 230 | (shouldUpdate) => { 231 | render(); 232 | 233 | expect(fRenderCount).toEqual(1); 234 | expect(gRenderCount).toEqual(2); 235 | 236 | act(() => { 237 | const pushGButton = screen.getByTestId("push-g-button"); 238 | pushGButton.click(); 239 | }); 240 | 241 | expect(fRenderCount).toEqual(2); 242 | expect(gRenderCount).toEqual(3); 243 | 244 | act(() => { 245 | const toggleGButtons = screen.queryAllByTestId("toggle-g"); 246 | toggleGButtons.forEach((button) => button.click()); 247 | }); 248 | 249 | expect(fRenderCount).toEqual(2); // does not change 250 | expect(gRenderCount).toEqual(6); // re-renders all Gs 251 | } 252 | ); 253 | }); 254 | 255 | test("multiple hooks only trigger one re-render", () => { 256 | let renderCount = 0; 257 | 258 | const Test = () => { 259 | lens.use(); 260 | const [c, setC] = lens.a.b.c.use(); 261 | 262 | renderCount++; 263 | 264 | return