From 89ca15e424c0bb8a12b027fb62989f875904c859 Mon Sep 17 00:00:00 2001 From: Diego Borges Date: Fri, 30 Aug 2024 09:03:11 -0300 Subject: [PATCH 01/16] No longer notify subscribers when untracked prop on root node changes --- packages/arbor-store/src/scoping/scope.ts | 8 +++-- .../arbor-store/tests/scoping/store.test.ts | 33 ++++++++++++++++++- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/arbor-store/src/scoping/scope.ts b/packages/arbor-store/src/scoping/scope.ts index 9699d1c..c1c99b4 100644 --- a/packages/arbor-store/src/scoping/scope.ts +++ b/packages/arbor-store/src/scoping/scope.ts @@ -39,9 +39,11 @@ export class Scope { affected(event: MutationEvent) { // Notify all listeners if the root of the store is replaced - // TODO: at the moment I'm assuming this is a desirable behavior, though - // user feedback will likely influence this behavior moving forward. - if (event.mutationPath.isRoot() && event.metadata.operation === "set") { + if ( + event.mutationPath.isRoot() && + event.metadata.operation === "set" && + event.metadata.props.length === 0 + ) { return true } diff --git a/packages/arbor-store/tests/scoping/store.test.ts b/packages/arbor-store/tests/scoping/store.test.ts index fd9fa61..8a1f832 100644 --- a/packages/arbor-store/tests/scoping/store.test.ts +++ b/packages/arbor-store/tests/scoping/store.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest" import { Arbor } from "../../src/arbor" -import { proxiable, detached } from "../../src/decorators" +import { detached, proxiable } from "../../src/decorators" import { ScopedStore } from "../../src/scoping/store" import { unwrap } from "../../src/utilities" @@ -619,4 +619,35 @@ describe("path tracking", () => { expect(node1).toHaveLink(undefined) expect(node2).toHaveLink("0") }) + + it("does not call subscribers on untracked props due to short-circuit logic", () => { + @proxiable + class App { + flag1 = true + flag2 = true + + get check() { + return this.flag1 || this.flag2 + } + } + + const subscriber = vi.fn() + const store = new Arbor(new App()) + const scoped = new ScopedStore(store) + scoped.subscribe(subscriber) + + // warps up the cache + // causes "scoped" to track "flag1" but not "flag2" + scoped.state.check + + store.state.flag2 = false + + expect(scoped).toBeTracking(scoped.state, "flag1") + expect(scoped).not.toBeTracking(scoped.state, "flag2") + expect(subscriber).not.toHaveBeenCalled() + + store.state.flag1 = false + + expect(subscriber).toHaveBeenCalled() + }) }) From 6b3ceb4527506a5f11e0556acc20ca66ce940495 Mon Sep 17 00:00:00 2001 From: Diego Borges Date: Thu, 5 Sep 2024 15:51:51 -0300 Subject: [PATCH 02/16] No longer expose arbor-store modules from arbor-react for simplicity --- packages/arbor-react/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/arbor-react/src/index.ts b/packages/arbor-react/src/index.ts index ba08b27..78a7ed2 100644 --- a/packages/arbor-react/src/index.ts +++ b/packages/arbor-react/src/index.ts @@ -1,2 +1 @@ -export * from "@arborjs/store" export * from "./useArbor" From bb835b03a3afc0216ce07008ed15aaf37a1917e8 Mon Sep 17 00:00:00 2001 From: Diego Borges Date: Fri, 6 Sep 2024 23:40:50 -0300 Subject: [PATCH 03/16] Implement a Cypress example to demo scoping + short-circuit --- .../cypress/component/ShortCircuit.cy.tsx | 9 +++++ examples/short-circuit/App.tsx | 20 ++++++++++ examples/short-circuit/components/Actions.tsx | 20 ++++++++++ .../components/AreAllFlagsTrue.tsx | 19 +++++++++ .../components/AreThereAnyFlagsTrue.tsx | 19 +++++++++ .../short-circuit/components/Highlight.tsx | 21 ++++++++++ .../short-circuit/components/StoreState.tsx | 23 +++++++++++ .../hooks/useAnimatedClassName.ts | 16 ++++++++ examples/short-circuit/store/index.ts | 7 ++++ .../store/models/ShortCircuitApp.ts | 20 ++++++++++ examples/short-circuit/styles.css | 39 +++++++++++++++++++ 11 files changed, 213 insertions(+) create mode 100644 examples/cypress/component/ShortCircuit.cy.tsx create mode 100644 examples/short-circuit/App.tsx create mode 100644 examples/short-circuit/components/Actions.tsx create mode 100644 examples/short-circuit/components/AreAllFlagsTrue.tsx create mode 100644 examples/short-circuit/components/AreThereAnyFlagsTrue.tsx create mode 100644 examples/short-circuit/components/Highlight.tsx create mode 100644 examples/short-circuit/components/StoreState.tsx create mode 100644 examples/short-circuit/hooks/useAnimatedClassName.ts create mode 100644 examples/short-circuit/store/index.ts create mode 100644 examples/short-circuit/store/models/ShortCircuitApp.ts create mode 100644 examples/short-circuit/styles.css diff --git a/examples/cypress/component/ShortCircuit.cy.tsx b/examples/cypress/component/ShortCircuit.cy.tsx new file mode 100644 index 0000000..a72dd3c --- /dev/null +++ b/examples/cypress/component/ShortCircuit.cy.tsx @@ -0,0 +1,9 @@ +import React from "react" + +import { App } from "../../short-circuit/App" + +describe("ShortCircuit App", () => { + it("successfully renders components even when short circuit boolean logic is used", () => { + cy.mount() + }) +}) diff --git a/examples/short-circuit/App.tsx b/examples/short-circuit/App.tsx new file mode 100644 index 0000000..e848aef --- /dev/null +++ b/examples/short-circuit/App.tsx @@ -0,0 +1,20 @@ +import React from "react" + +import { Highlight } from "./components/Highlight" +import { StoreState } from "./components/StoreState" +import { Actions } from "./components/Actions" +import { AreThereAnyFlagsTrue } from "./components/AreThereAnyFlagsTrue" +import { AreAllFlagsTrue } from "./components/AreAllFlagsTrue" + +import "./styles.css" + +export function App() { + return ( + + + + + + + ) +} diff --git a/examples/short-circuit/components/Actions.tsx b/examples/short-circuit/components/Actions.tsx new file mode 100644 index 0000000..42d1ee7 --- /dev/null +++ b/examples/short-circuit/components/Actions.tsx @@ -0,0 +1,20 @@ +import React from "react" + +import { Highlight } from "./Highlight" +import { store } from "../store" + +export function Actions() { + return ( + + + + + + ) +} diff --git a/examples/short-circuit/components/AreAllFlagsTrue.tsx b/examples/short-circuit/components/AreAllFlagsTrue.tsx new file mode 100644 index 0000000..15fb912 --- /dev/null +++ b/examples/short-circuit/components/AreAllFlagsTrue.tsx @@ -0,0 +1,19 @@ +import React from "react" +import { useArbor } from "@arborjs/react" + +import { Highlight } from "./Highlight" +import { store } from "../store" + +export function AreAllFlagsTrue() { + const app = useArbor(store) + return ( + +

+ Are all flags true? +

+
+ {app.flags.flag1 && app.flags.flag2 && app.flags.flag3 ? "Yes" : "No"} +
+
+ ) +} diff --git a/examples/short-circuit/components/AreThereAnyFlagsTrue.tsx b/examples/short-circuit/components/AreThereAnyFlagsTrue.tsx new file mode 100644 index 0000000..10d94dd --- /dev/null +++ b/examples/short-circuit/components/AreThereAnyFlagsTrue.tsx @@ -0,0 +1,19 @@ +import React from "react" +import { useArbor } from "@arborjs/react" + +import { Highlight } from "./Highlight" +import { store } from "../store" + +export function AreThereAnyFlagsTrue() { + const app = useArbor(store) + return ( + +

+ Are there any flags true? +

+
+ {app.flags.flag1 || app.flags.flag2 || app.flags.flag3 ? "Yes" : "No"} +
+
+ ) +} diff --git a/examples/short-circuit/components/Highlight.tsx b/examples/short-circuit/components/Highlight.tsx new file mode 100644 index 0000000..0b8ede1 --- /dev/null +++ b/examples/short-circuit/components/Highlight.tsx @@ -0,0 +1,21 @@ +import React from "react" + +import { useAnimatedClassName } from "../hooks/useAnimatedClassName" + +export function Highlight({ + key, + children, + label, +}: { + key: number + children: React.ReactNode + label: string +}) { + const className = useAnimatedClassName(key) + return ( +
+ {label} + {children} +
+ ) +} diff --git a/examples/short-circuit/components/StoreState.tsx b/examples/short-circuit/components/StoreState.tsx new file mode 100644 index 0000000..fa227eb --- /dev/null +++ b/examples/short-circuit/components/StoreState.tsx @@ -0,0 +1,23 @@ +import React from "react" +import { useArbor } from "@arborjs/react" + +import { Highlight } from "./Highlight" +import { store } from "../store" + +export function StoreState() { + const state = useArbor(store) + + return ( + +
+ state.flags.flag1: {state.flags.flag1.toString()} +
+
+ state.flags.flag2: {state.flags.flag2.toString()} +
+
+ state.flags.flag3: {state.flags.flag3.toString()} +
+
+ ) +} diff --git a/examples/short-circuit/hooks/useAnimatedClassName.ts b/examples/short-circuit/hooks/useAnimatedClassName.ts new file mode 100644 index 0000000..928a9fb --- /dev/null +++ b/examples/short-circuit/hooks/useAnimatedClassName.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from "react" + +export function useAnimatedClassName(key: number) { + const [className, setClassName] = useState("") + + useEffect(() => { + setClassName("animate") + const timeoutId = setTimeout(() => { + setClassName("") + }, 1000) + + return () => clearTimeout(timeoutId) + }, [key]) + + return className +} diff --git a/examples/short-circuit/store/index.ts b/examples/short-circuit/store/index.ts new file mode 100644 index 0000000..69a6399 --- /dev/null +++ b/examples/short-circuit/store/index.ts @@ -0,0 +1,7 @@ +import { Arbor } from "@arborjs/store" + +import { ShortCircuitApp } from "./models/ShortCircuitApp" + +export const store = new Arbor({ + flags: new ShortCircuitApp(), +}) diff --git a/examples/short-circuit/store/models/ShortCircuitApp.ts b/examples/short-circuit/store/models/ShortCircuitApp.ts new file mode 100644 index 0000000..144efea --- /dev/null +++ b/examples/short-circuit/store/models/ShortCircuitApp.ts @@ -0,0 +1,20 @@ +import { proxiable } from "@arborjs/store" + +@proxiable +export class ShortCircuitApp { + flag1 = false + flag2 = false + flag3 = false + + toggleFlag1() { + this.flag1 = !this.flag1 + } + + toggleFlag2() { + this.flag2 = !this.flag2 + } + + toggleFlag3() { + this.flag3 = !this.flag3 + } +} diff --git a/examples/short-circuit/styles.css b/examples/short-circuit/styles.css new file mode 100644 index 0000000..fd08789 --- /dev/null +++ b/examples/short-circuit/styles.css @@ -0,0 +1,39 @@ +@keyframes fadeOut { + 0% { + opacity: 1; + } + 50% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +.animate { + animation: fadeOut 1s ease-in; +} + +button { + padding: 5px 10px; + margin: 5px 10px; +} + +.highlight { + position: relative; + border: 2px solid black; + padding: 15px; + padding-top: 25px; + margin: 10px; +} + +.highlight > span { + top: 0; + left: 0; + border-radius: 0 0 5px 0; + background-color: black; + color: white; + font-size: 10px; + padding: 5px; + position: absolute; +} From cd9451ac6d04e373c9885166883b36a3ba42d07f Mon Sep 17 00:00:00 2001 From: Diego Borges Date: Sat, 7 Sep 2024 00:00:23 -0300 Subject: [PATCH 04/16] Notify subscribers when arrays are changed even if affected items are not tracked --- packages/arbor-store/src/scoping/scope.ts | 10 +-- .../arbor-store/tests/scoping/array.test.ts | 77 +++++++++++++++++++ 2 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 packages/arbor-store/tests/scoping/array.test.ts diff --git a/packages/arbor-store/src/scoping/scope.ts b/packages/arbor-store/src/scoping/scope.ts index c1c99b4..14e900a 100644 --- a/packages/arbor-store/src/scoping/scope.ts +++ b/packages/arbor-store/src/scoping/scope.ts @@ -38,6 +38,10 @@ export class Scope { } affected(event: MutationEvent) { + if (event.metadata.operation !== "set") { + return true + } + // Notify all listeners if the root of the store is replaced if ( event.mutationPath.isRoot() && @@ -47,12 +51,6 @@ export class Scope { return true } - // If there are no props affected by the mutation, then the operation - // is on the node itself (e.g. array#push, array#reverse, etc...) - if (event.metadata.props.length === 0) { - return true - } - // If the affected prop was previously undefined, we know a new prop // is being added to the node, in which case we notify subscribers // since they may need to react to the new prop so it can be "discovered" diff --git a/packages/arbor-store/tests/scoping/array.test.ts b/packages/arbor-store/tests/scoping/array.test.ts new file mode 100644 index 0000000..9218221 --- /dev/null +++ b/packages/arbor-store/tests/scoping/array.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest" +import { Arbor } from "../../src/arbor" +import { ScopedStore } from "../../src/scoping/store" + +describe("Array", () => { + describe("Array#splice", () => { + it("splices multiple items in a row", () => { + const store = new Arbor({ + todos: [ + { id: 1, text: "Do the dishes" }, + { id: 2, text: "Walk the dogs" }, + { id: 3, text: "Walk the dogs" }, + ], + }) + + const scoped = new ScopedStore(store) + + scoped.state.todos.splice(1, 1) + + expect(scoped.state).toEqual({ + todos: [ + { id: 1, text: "Do the dishes" }, + { id: 3, text: "Walk the dogs" }, + ], + }) + + scoped.state.todos.splice(1, 1) + + expect(scoped.state).toEqual({ + todos: [{ id: 1, text: "Do the dishes" }], + }) + }) + + it("notifies subscribers when the array changes even if items are not tracked", () => { + const store = new Arbor({ + todos: [ + { id: 1, text: "Do the dishes" }, + { id: 2, text: "Walk the dogs" }, + { id: 3, text: "Walk the dogs" }, + ], + }) + + const subscriber = vi.fn() + const scoped = new ScopedStore(store) + + scoped.subscribe(subscriber) + + // accessing scoped.state.todos causes the scope to track that path + scoped.state.todos.splice(1, 1) + scoped.state.todos.splice(1, 1) + + expect(subscriber).toHaveBeenCalledTimes(2) + }) + + it("notifies subscribers when the array changes via delete operation even if items are not tracked", () => { + const store = new Arbor({ + todos: [ + { id: 1, text: "Do the dishes" }, + { id: 2, text: "Walk the dogs" }, + { id: 3, text: "Walk the dogs" }, + ], + }) + + const subscriber = vi.fn() + const scoped = new ScopedStore(store) + + scoped.subscribe(subscriber) + + delete scoped.state.todos[1] + delete scoped.state.todos[1] + + expect(scoped).not.toBeTracking(scoped.state.todos, 1) + + expect(subscriber).toHaveBeenCalledTimes(2) + }) + }) +}) From 5141d12e78e82d71626f7ce3682be9cf4a29c292 Mon Sep 17 00:00:00 2001 From: Diego Borges Date: Sat, 7 Sep 2024 09:15:08 -0300 Subject: [PATCH 05/16] Increase test coverage around array nodes within scoped stores --- packages/arbor-store/src/scoping/scope.ts | 25 ++ .../arbor-store/tests/scoping/array.test.ts | 301 +++++++++++++++++- 2 files changed, 320 insertions(+), 6 deletions(-) diff --git a/packages/arbor-store/src/scoping/scope.ts b/packages/arbor-store/src/scoping/scope.ts index 14e900a..b407684 100644 --- a/packages/arbor-store/src/scoping/scope.ts +++ b/packages/arbor-store/src/scoping/scope.ts @@ -37,6 +37,31 @@ export class Scope { this.tracking = new WeakMap>() } + // NOTE: Something to consider in the future as new node handler types + // are introduced, is whether or not this method should be aware of the + // type of node handler targeted by the event so it can better determine + // when a mutation affects that node or not. + // + // For instance, currently we assume that any operation other than "set" + // should affect the node, e.g. subscribers need to be notified, this is + // to deal with scenarios where a subscriber may not be tracking any fields + // of an array, but yet needs to know when it changes. + // + // Imagine in a React app a component that renders the length of an array + // but does not access any items in the array, it needs to re-render whenever + // an item is removed or added to the array.This implementation is not 100% + // ideal at the moment since for instance, that same component would re-render + // should we use Array#reverse or Array#sort, these won't change the array's + // length and yet will cause the component to re-render. + // + // One idea to explore is to provide extra metadata information in the mutation + // event that would allow this logic to tap into in order to determine whether + // subscribers should be notified regardless of which fields on the node they + // are tracking, similar to how we use `MutationMetadata#previouslyUndefined`. + // + // We can try replacing that with `MutationMetadata#structureChanged` to indicate + // when a mutation event alters the structure of the node by either adding new fields + // or removing. affected(event: MutationEvent) { if (event.metadata.operation !== "set") { return true diff --git a/packages/arbor-store/tests/scoping/array.test.ts b/packages/arbor-store/tests/scoping/array.test.ts index 9218221..9102abb 100644 --- a/packages/arbor-store/tests/scoping/array.test.ts +++ b/packages/arbor-store/tests/scoping/array.test.ts @@ -9,7 +9,7 @@ describe("Array", () => { todos: [ { id: 1, text: "Do the dishes" }, { id: 2, text: "Walk the dogs" }, - { id: 3, text: "Walk the dogs" }, + { id: 3, text: "Clean the house" }, ], }) @@ -20,7 +20,7 @@ describe("Array", () => { expect(scoped.state).toEqual({ todos: [ { id: 1, text: "Do the dishes" }, - { id: 3, text: "Walk the dogs" }, + { id: 3, text: "Clean the house" }, ], }) @@ -31,12 +31,12 @@ describe("Array", () => { }) }) - it("notifies subscribers when the array changes even if items are not tracked", () => { + it("notifies subscribers even if items are not tracked", () => { const store = new Arbor({ todos: [ { id: 1, text: "Do the dishes" }, { id: 2, text: "Walk the dogs" }, - { id: 3, text: "Walk the dogs" }, + { id: 3, text: "Clean the house" }, ], }) @@ -51,13 +51,302 @@ describe("Array", () => { expect(subscriber).toHaveBeenCalledTimes(2) }) + }) + + describe("#reverse", () => { + it("reverses the array", () => { + const store = new Arbor({ + todos: [ + { id: 1, text: "Do the dishes" }, + { id: 2, text: "Walk the dogs" }, + { id: 3, text: "Clean the house" }, + ], + }) + + const scoped = new ScopedStore(store) + + scoped.state.todos.reverse() + + expect(scoped.state).toEqual({ + todos: [ + { id: 3, text: "Clean the house" }, + { id: 2, text: "Walk the dogs" }, + { id: 1, text: "Do the dishes" }, + ], + }) + }) + + it("notifies subscribers even if items are not tracked", () => { + const store = new Arbor({ + todos: [ + { id: 1, text: "Do the dishes" }, + { id: 2, text: "Walk the dogs" }, + { id: 3, text: "Clean the house" }, + ], + }) + + const subscriber = vi.fn() + const scoped = new ScopedStore(store) + + scoped.subscribe(subscriber) + + scoped.state.todos.reverse() + + expect(subscriber).toHaveBeenCalledTimes(1) + }) + }) + + describe("#push", () => { + it("pushes a new item into the array", () => { + const store = new Arbor({ + todos: [ + { id: 1, text: "Do the dishes" }, + { id: 2, text: "Walk the dogs" }, + { id: 3, text: "Clean the house" }, + ], + }) + + const scoped = new ScopedStore(store) + + scoped.state.todos.push({ id: 4, text: "New todo" }) + + expect(scoped.state).toEqual({ + todos: [ + { id: 1, text: "Do the dishes" }, + { id: 2, text: "Walk the dogs" }, + { id: 3, text: "Clean the house" }, + { id: 4, text: "New todo" }, + ], + }) + }) + + it("notifies subscribers even if items are not tracked", () => { + const store = new Arbor({ + todos: [ + { id: 1, text: "Do the dishes" }, + { id: 2, text: "Walk the dogs" }, + { id: 3, text: "Clean the house" }, + ], + }) + + const subscriber = vi.fn() + const scoped = new ScopedStore(store) + + scoped.subscribe(subscriber) + + scoped.state.todos.push({ id: 4, text: "New todo" }) + + expect(subscriber).toHaveBeenCalledTimes(1) + }) + }) + + describe("#pop", () => { + it("removes the last item in the array", () => { + const store = new Arbor({ + todos: [ + { id: 1, text: "Do the dishes" }, + { id: 2, text: "Walk the dogs" }, + { id: 3, text: "Clean the house" }, + ], + }) + + const scoped = new ScopedStore(store) + + scoped.state.todos.pop() + + expect(scoped.state).toEqual({ + todos: [ + { id: 1, text: "Do the dishes" }, + { id: 2, text: "Walk the dogs" }, + ], + }) + }) + + it("notifies subscribers even if items are not tracked", () => { + const store = new Arbor({ + todos: [ + { id: 1, text: "Do the dishes" }, + { id: 2, text: "Walk the dogs" }, + { id: 3, text: "Clean the house" }, + ], + }) + + const subscriber = vi.fn() + const scoped = new ScopedStore(store) + + scoped.subscribe(subscriber) + + scoped.state.todos.pop() + + expect(subscriber).toHaveBeenCalledTimes(1) + }) + }) + + describe("#shift", () => { + it("shifts the array by one item", () => { + const store = new Arbor({ + todos: [ + { id: 1, text: "Do the dishes" }, + { id: 2, text: "Walk the dogs" }, + { id: 3, text: "Clean the house" }, + ], + }) + + const scoped = new ScopedStore(store) + + scoped.state.todos.shift() + + expect(scoped.state).toEqual({ + todos: [ + { id: 2, text: "Walk the dogs" }, + { id: 3, text: "Clean the house" }, + ], + }) + }) + + it("notifies subscribers even if items are not tracked", () => { + const store = new Arbor({ + todos: [ + { id: 1, text: "Do the dishes" }, + { id: 2, text: "Walk the dogs" }, + { id: 3, text: "Clean the house" }, + ], + }) + + const subscriber = vi.fn() + const scoped = new ScopedStore(store) + + scoped.subscribe(subscriber) + + scoped.state.todos.shift() + + expect(subscriber).toHaveBeenCalledTimes(1) + }) + }) + + describe("#unshift", () => { + it("unshifts the array by one item including two new items in its place", () => { + const store = new Arbor({ + todos: [ + { id: 1, text: "Do the dishes" }, + { id: 2, text: "Walk the dogs" }, + { id: 3, text: "Clean the house" }, + ], + }) + + const scoped = new ScopedStore(store) + + scoped.state.todos.unshift( + { id: 4, text: "New todo 4" }, + { id: 5, text: "New todo 5" } + ) + + expect(scoped.state).toEqual({ + todos: [ + { id: 4, text: "New todo 4" }, + { id: 5, text: "New todo 5" }, + { id: 1, text: "Do the dishes" }, + { id: 2, text: "Walk the dogs" }, + { id: 3, text: "Clean the house" }, + ], + }) + }) + + it("notifies subscribers even if items are not tracked", () => { + const store = new Arbor({ + todos: [ + { id: 1, text: "Do the dishes" }, + { id: 2, text: "Walk the dogs" }, + { id: 3, text: "Clean the house" }, + ], + }) + + const subscriber = vi.fn() + const scoped = new ScopedStore(store) + + scoped.subscribe(subscriber) + + scoped.state.todos.unshift() + + expect(subscriber).toHaveBeenCalledTimes(1) + }) + }) + + describe("#sort", () => { + it("sorts the array by the text field", () => { + const store = new Arbor({ + todos: [ + { id: 1, text: "Do the dishes" }, + { id: 3, text: "Clean the house" }, + { id: 2, text: "Walk the dogs" }, + ], + }) + + const scoped = new ScopedStore(store) + + scoped.state.todos.sort((a, b) => { + if (a.text > b.text) return 1 + if (a.text < b.text) return -1 + return 0 + }) + + expect(scoped.state).toEqual({ + todos: [ + { id: 3, text: "Clean the house" }, + { id: 1, text: "Do the dishes" }, + { id: 2, text: "Walk the dogs" }, + ], + }) + }) + + it("notifies subscribers even if items are not tracked", () => { + const store = new Arbor({ + todos: [ + { id: 1, text: "Do the dishes" }, + { id: 2, text: "Walk the dogs" }, + { id: 3, text: "Clean the house" }, + ], + }) + + const subscriber = vi.fn() + const scoped = new ScopedStore(store) + + scoped.subscribe(subscriber) + + scoped.state.todos.sort() + + expect(subscriber).toHaveBeenCalledTimes(1) + }) + }) + + describe("delete trap", () => { + it("deletes an item from the array using the delete keyword", () => { + const store = new Arbor({ + todos: [ + { id: 1, text: "Do the dishes" }, + { id: 3, text: "Clean the house" }, + { id: 2, text: "Walk the dogs" }, + ], + }) + + const scoped = new ScopedStore(store) + + delete scoped.state.todos[1] + + expect(scoped.state).toEqual({ + todos: [ + { id: 1, text: "Do the dishes" }, + { id: 2, text: "Walk the dogs" }, + ], + }) + }) - it("notifies subscribers when the array changes via delete operation even if items are not tracked", () => { + it("notifies subscribers even if items are not tracked", () => { const store = new Arbor({ todos: [ { id: 1, text: "Do the dishes" }, { id: 2, text: "Walk the dogs" }, - { id: 3, text: "Walk the dogs" }, + { id: 3, text: "Clean the house" }, ], }) From 91c1d6c42e1b3ab9b94bf841790bfb48f4c111e0 Mon Sep 17 00:00:00 2001 From: Diego Borges Date: Sat, 7 Sep 2024 10:04:54 -0300 Subject: [PATCH 06/16] Fix imports in examples code --- examples/react-counter/Counter.tsx | 3 ++- examples/react-todo/components/NewTodoForm.tsx | 2 +- examples/react-todo/store/useNewTodo.ts | 3 ++- examples/react-todo/store/useTodos.ts | 2 +- examples/react-todo/store/useTodosFilter.ts | 3 ++- examples/short-circuit/components/AreAllFlagsTrue.tsx | 8 ++++---- .../short-circuit/components/AreThereAnyFlagsTrue.tsx | 8 ++++---- 7 files changed, 16 insertions(+), 13 deletions(-) diff --git a/examples/react-counter/Counter.tsx b/examples/react-counter/Counter.tsx index c3ac1b8..4db0f61 100644 --- a/examples/react-counter/Counter.tsx +++ b/examples/react-counter/Counter.tsx @@ -1,4 +1,5 @@ -import { Arbor, useArbor } from "@arborjs/react" +import { Arbor } from "@arborjs/store" +import { useArbor } from "@arborjs/react" import React, { memo } from "react" import "./styles.css" diff --git a/examples/react-todo/components/NewTodoForm.tsx b/examples/react-todo/components/NewTodoForm.tsx index 7e5c853..f64d923 100644 --- a/examples/react-todo/components/NewTodoForm.tsx +++ b/examples/react-todo/components/NewTodoForm.tsx @@ -1,7 +1,7 @@ import React, { memo, SyntheticEvent } from "react" import useNewTodo from "../store/useNewTodo" -import { store, Todo } from "../store/useTodos" +import { store } from "../store/useTodos" import { activate, store as filterStore } from "../store/useTodosFilter" export default memo(function NewTodoForm() { diff --git a/examples/react-todo/store/useNewTodo.ts b/examples/react-todo/store/useNewTodo.ts index 5109e35..27eafeb 100644 --- a/examples/react-todo/store/useNewTodo.ts +++ b/examples/react-todo/store/useNewTodo.ts @@ -1,5 +1,6 @@ +import { Arbor } from "@arborjs/store" +import { useArbor } from "@arborjs/react" import { LocalStorage, Logger } from "@arborjs/plugins" -import { Arbor, useArbor } from "@arborjs/react" export interface Input { value: string diff --git a/examples/react-todo/store/useTodos.ts b/examples/react-todo/store/useTodos.ts index cfb6493..f904599 100644 --- a/examples/react-todo/store/useTodos.ts +++ b/examples/react-todo/store/useTodos.ts @@ -1,6 +1,6 @@ import { Json, SerializedBy, serializable } from "@arborjs/json" import { LocalStorage, Logger } from "@arborjs/plugins" -import { Arbor, ArborNode, detach, proxiable } from "@arborjs/react" +import { Arbor, ArborNode, detach, proxiable } from "@arborjs/store" import { v4 as uuid } from "uuid" export type Status = "completed" | "active" diff --git a/examples/react-todo/store/useTodosFilter.ts b/examples/react-todo/store/useTodosFilter.ts index 06ba647..d65873e 100644 --- a/examples/react-todo/store/useTodosFilter.ts +++ b/examples/react-todo/store/useTodosFilter.ts @@ -1,5 +1,6 @@ import { LocalStorage, Logger } from "@arborjs/plugins" -import { Arbor, useArbor } from "@arborjs/react" +import { useArbor } from "@arborjs/react" +import { Arbor } from "@arborjs/store" import { TodoList } from "./useTodos" diff --git a/examples/short-circuit/components/AreAllFlagsTrue.tsx b/examples/short-circuit/components/AreAllFlagsTrue.tsx index 15fb912..9905aca 100644 --- a/examples/short-circuit/components/AreAllFlagsTrue.tsx +++ b/examples/short-circuit/components/AreAllFlagsTrue.tsx @@ -8,11 +8,11 @@ export function AreAllFlagsTrue() { const app = useArbor(store) return ( -

- Are all flags true? -

- {app.flags.flag1 && app.flags.flag2 && app.flags.flag3 ? "Yes" : "No"} + + app.flags.flag1 && app.flags.flag2 && app.flags.flag3 ={" "} + {(app.flags.flag1 && app.flags.flag2 && app.flags.flag3).toString()} +
) diff --git a/examples/short-circuit/components/AreThereAnyFlagsTrue.tsx b/examples/short-circuit/components/AreThereAnyFlagsTrue.tsx index 10d94dd..5ec0e7e 100644 --- a/examples/short-circuit/components/AreThereAnyFlagsTrue.tsx +++ b/examples/short-circuit/components/AreThereAnyFlagsTrue.tsx @@ -8,11 +8,11 @@ export function AreThereAnyFlagsTrue() { const app = useArbor(store) return ( -

- Are there any flags true? -

- {app.flags.flag1 || app.flags.flag2 || app.flags.flag3 ? "Yes" : "No"} + + app.flags.flag1 || app.flags.flag2 || app.flags.flag3 ={" "} + {(app.flags.flag1 || app.flags.flag2 || app.flags.flag3).toString()} +
) From bf5f80e44e1a17666db7f17bd97466a963c69ccc Mon Sep 17 00:00:00 2001 From: Diego Borges Date: Sat, 7 Sep 2024 12:15:22 -0300 Subject: [PATCH 07/16] Fix infinite loop in Map#iterator --- packages/arbor-store/src/handlers/map.ts | 7 +-- packages/arbor-store/src/scoping/scope.ts | 4 ++ .../arbor-store/tests/scoping/map.test.ts | 43 +++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 packages/arbor-store/tests/scoping/map.test.ts diff --git a/packages/arbor-store/src/handlers/map.ts b/packages/arbor-store/src/handlers/map.ts index 6558173..75ca610 100644 --- a/packages/arbor-store/src/handlers/map.ts +++ b/packages/arbor-store/src/handlers/map.ts @@ -11,10 +11,11 @@ export class MapHandler extends DefaultHandler< } *[Symbol.iterator]() { - if (!isNode>(this)) throw new NotAnArborNodeError() + const mapProxy = this.$tree.getNodeFor(this) as unknown as Map - for (const entry of this.entries()) { - yield entry + for (const entry of this.$value.entries()) { + const childProxy = mapProxy.get(entry[0]) + yield [entry[0], childProxy] } } diff --git a/packages/arbor-store/src/scoping/scope.ts b/packages/arbor-store/src/scoping/scope.ts index b407684..545ff6c 100644 --- a/packages/arbor-store/src/scoping/scope.ts +++ b/packages/arbor-store/src/scoping/scope.ts @@ -125,6 +125,10 @@ export class Scope { return target } + // TODO: handlers/map.ts must intercept child access so it can proxy them + // otherwise, accessing a child from a scoped map will not yield a scoped + // child but the actual underlying value. + const child = Reflect.get(target, prop, proxy) const descriptor = Object.getOwnPropertyDescriptor(target, prop) diff --git a/packages/arbor-store/tests/scoping/map.test.ts b/packages/arbor-store/tests/scoping/map.test.ts new file mode 100644 index 0000000..2a5f245 --- /dev/null +++ b/packages/arbor-store/tests/scoping/map.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest" +import { Arbor } from "../../src/arbor" +import { ScopedStore } from "../../src/scoping/store" + +describe("map", () => { + describe("Symbol.iterator", () => { + it("can convert a map node from a scoped store to array", () => { + const store = new Arbor( + new Map([ + [1, "Alice"], + [2, "Bob"], + ]) + ) + + const scope = new ScopedStore(store) + const list = Array.from(scope.state) + + expect(list).toEqual([ + [1, "Alice"], + [2, "Bob"], + ]) + }) + + it.skip("exposes scoped nodes", () => { + const bob = { name: "Bob" } + const alice = { name: "Alice" } + const store = new Arbor( + new Map([ + [1, alice], + [2, bob], + ]) + ) + + const scope = new ScopedStore(store) + const list = Array.from(scope.state) + + expect(list[0][1]).toBeNodeOf(alice) + expect(list[1][1]).toBeNodeOf(bob) + expect(list[0][1]).toBeTrackedNode() + expect(list[1][1]).toBeTrackedNode() + }) + }) +}) From 44b5a18012c3e03bd79ac409805cec5679585882 Mon Sep 17 00:00:00 2001 From: Diego Borges Date: Sat, 7 Sep 2024 20:42:34 -0300 Subject: [PATCH 08/16] Allow scoped store to properly traverse OST and track nodes --- packages/arbor-store/src/scoping/scope.ts | 25 ++++++++++++++++++- .../arbor-store/tests/scoping/map.test.ts | 16 +++++++----- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/arbor-store/src/scoping/scope.ts b/packages/arbor-store/src/scoping/scope.ts index 545ff6c..bd16344 100644 --- a/packages/arbor-store/src/scoping/scope.ts +++ b/packages/arbor-store/src/scoping/scope.ts @@ -2,7 +2,7 @@ import { Arbor } from "../arbor" import { isDetachedProperty } from "../decorators" import { isNode } from "../guards" import { Seed } from "../path" -import { ArborNode, MutationEvent } from "../types" +import { ArborNode, Link, MutationEvent } from "../types" import { isGetter, recursivelyUnwrap } from "../utilities" export type Tracked = T & { @@ -125,6 +125,29 @@ export class Scope { return target } + if (prop === "get") { + return (link: Link) => { + const child = target.get(link) + + if ( + child == null || + // There's no point in tracking access to Arbor stores being referenced + // without other stores since they are not connected to each other. + // Also, we cannot proxy Arbor instance since itself relies on #private + // fields to hide internal concerns which gets in the way of the proxying + // mechanism. + // + // See "Private Properties" section of the Caveats.md for more details. + child instanceof Arbor || + typeof child !== "object" + ) { + return child + } + + return getOrCache(child) + } + } + // TODO: handlers/map.ts must intercept child access so it can proxy them // otherwise, accessing a child from a scoped map will not yield a scoped // child but the actual underlying value. diff --git a/packages/arbor-store/tests/scoping/map.test.ts b/packages/arbor-store/tests/scoping/map.test.ts index 2a5f245..b87d3ea 100644 --- a/packages/arbor-store/tests/scoping/map.test.ts +++ b/packages/arbor-store/tests/scoping/map.test.ts @@ -21,7 +21,7 @@ describe("map", () => { ]) }) - it.skip("exposes scoped nodes", () => { + it("exposes scoped nodes", () => { const bob = { name: "Bob" } const alice = { name: "Alice" } const store = new Arbor( @@ -32,12 +32,16 @@ describe("map", () => { ) const scope = new ScopedStore(store) - const list = Array.from(scope.state) - expect(list[0][1]).toBeNodeOf(alice) - expect(list[1][1]).toBeNodeOf(bob) - expect(list[0][1]).toBeTrackedNode() - expect(list[1][1]).toBeTrackedNode() + expect(scope.state.get(1)).toBeTrackedNode() + expect(scope.state.get(2)).toBeTrackedNode() + + // const list = Array.from(scope.state) + + // expect(list[0][1]).toBeNodeOf(alice) + // expect(list[1][1]).toBeNodeOf(bob) + // expect(list[0][1]).toBeTrackedNode() + // expect(list[1][1]).toBeTrackedNode() }) }) }) From 219ae51732219c7ed8b4d46ada5316163044c7e8 Mon Sep 17 00:00:00 2001 From: Diego Borges Date: Sat, 7 Sep 2024 20:43:41 -0300 Subject: [PATCH 09/16] Increase test coverage around node handlers --- .../arbor-store/tests/handlers/array.test.ts | 68 ++++++++++++++ .../tests/handlers/default.test.ts | 46 ++++++++++ .../arbor-store/tests/handlers/map.test.ts | 88 +++++++++++++++++++ .../arbor-store/tests/scoping/map.test.ts | 27 +++++- 4 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 packages/arbor-store/tests/handlers/array.test.ts create mode 100644 packages/arbor-store/tests/handlers/default.test.ts create mode 100644 packages/arbor-store/tests/handlers/map.test.ts diff --git a/packages/arbor-store/tests/handlers/array.test.ts b/packages/arbor-store/tests/handlers/array.test.ts new file mode 100644 index 0000000..27a83ab --- /dev/null +++ b/packages/arbor-store/tests/handlers/array.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest" + +import { Arbor } from "../../src/arbor" + +describe("ArrayHandler", () => { + describe("Symbol.iterator", () => { + it("exposes child nodes via iterator", () => { + const store = new Arbor([ + { name: "Alice" }, + { name: "Bob" }, + { name: "Carol" }, + ]) + + const alice = store.state[0] + const bob = store.state[1] + const carol = store.state[2] + + const iterator = store.state[Symbol.iterator]() + + const user1 = iterator.next().value + const user2 = iterator.next().value + const user3 = iterator.next().value + + expect(user1).toBe(alice) + expect(user2).toBe(bob) + expect(user3).toBe(carol) + }) + + it("exposes child nodes", () => { + const store = new Arbor([ + { name: "Alice" }, + { name: "Bob" }, + { name: "Carol" }, + ]) + + const alice = store.state[0] + const bob = store.state[1] + const carol = store.state[2] + + const entries = Object.entries(store.state) + + expect(entries[0][0]).toEqual("0") + expect(entries[0][1]).toBe(alice) + expect(entries[1][0]).toEqual("1") + expect(entries[1][1]).toBe(bob) + expect(entries[2][0]).toEqual("2") + expect(entries[2][1]).toBe(carol) + }) + + it("exposes child nodes via spread operator", () => { + const store = new Arbor([ + { name: "Alice" }, + { name: "Bob" }, + { name: "Carol" }, + ]) + + const alice = store.state[0] + const bob = store.state[1] + const carol = store.state[2] + + const users = [...store.state] + + expect(users[0]).toBe(alice) + expect(users[1]).toBe(bob) + expect(users[2]).toBe(carol) + }) + }) +}) diff --git a/packages/arbor-store/tests/handlers/default.test.ts b/packages/arbor-store/tests/handlers/default.test.ts new file mode 100644 index 0000000..f3b239f --- /dev/null +++ b/packages/arbor-store/tests/handlers/default.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest" + +import { Arbor } from "../../src/arbor" + +describe("DefaultHandler", () => { + describe("Symbol.iterator", () => { + it("exposes child nodes via Object.entries", () => { + const store = new Arbor({ + user1: { name: "Alice" }, + user2: { name: "Bob" }, + user3: { name: "Carol" }, + }) + + const alice = store.state.user1 + const bob = store.state.user2 + const carol = store.state.user3 + + const entries = Object.entries(store.state) + + expect(entries[0][0]).toEqual("user1") + expect(entries[0][1]).toBe(alice) + expect(entries[1][0]).toEqual("user2") + expect(entries[1][1]).toBe(bob) + expect(entries[2][0]).toEqual("user3") + expect(entries[2][1]).toBe(carol) + }) + + it("exposes child nodes via spread operator", () => { + const store = new Arbor({ + user1: { name: "Alice" }, + user2: { name: "Bob" }, + user3: { name: "Carol" }, + }) + + const alice = store.state.user1 + const bob = store.state.user2 + const carol = store.state.user3 + + const users = { ...store.state } + + expect(users.user1).toBe(alice) + expect(users.user2).toBe(bob) + expect(users.user3).toBe(carol) + }) + }) +}) diff --git a/packages/arbor-store/tests/handlers/map.test.ts b/packages/arbor-store/tests/handlers/map.test.ts new file mode 100644 index 0000000..aa9db41 --- /dev/null +++ b/packages/arbor-store/tests/handlers/map.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest" + +import { Arbor } from "../../src/arbor" + +describe("MapHandler", () => { + describe("Symbol.iterator", () => { + it("exposes child nodes via Map#entries", () => { + const store = new Arbor( + new Map([ + [0, { name: "Alice" }], + [1, { name: "Bob" }], + [2, { name: "Carol" }], + ]) + ) + + const alice = store.state.get(0) + const bob = store.state.get(1) + const carol = store.state.get(2) + + const entries = store.state.entries() + + const entry1 = entries.next().value + const entry2 = entries.next().value + const entry3 = entries.next().value + + expect(entry1[0]).toEqual(0) + expect(entry1[1]).toBe(alice) + expect(entry2[0]).toEqual(1) + expect(entry2[1]).toBe(bob) + expect(entry3[0]).toEqual(2) + expect(entry3[1]).toBe(carol) + }) + + it("exposes child nodes via iterator", () => { + const store = new Arbor( + new Map([ + [0, { name: "Alice" }], + [1, { name: "Bob" }], + [2, { name: "Carol" }], + ]) + ) + + const alice = store.state.get(0) + const bob = store.state.get(1) + const carol = store.state.get(2) + + const entries = store.state[Symbol.iterator]() + + const entry1 = entries.next().value + const entry2 = entries.next().value + const entry3 = entries.next().value + + expect(entry1[0]).toEqual(0) + expect(entry1[1]).toBe(alice) + expect(entry2[0]).toEqual(1) + expect(entry2[1]).toBe(bob) + expect(entry3[0]).toEqual(2) + expect(entry3[1]).toBe(carol) + }) + + it("exposes child nodes via spread operator", () => { + const store = new Arbor( + new Map([ + [0, { name: "Alice" }], + [1, { name: "Bob" }], + [2, { name: "Carol" }], + ]) + ) + + const alice = store.state.get(0) + const bob = store.state.get(1) + const carol = store.state.get(2) + + const entries = [...store.state] + + const entry1 = entries[0] + const entry2 = entries[1] + const entry3 = entries[2] + + expect(entry1[0]).toEqual(0) + expect(entry1[1]).toBe(alice) + expect(entry2[0]).toEqual(1) + expect(entry2[1]).toBe(bob) + expect(entry3[0]).toEqual(2) + expect(entry3[1]).toBe(carol) + }) + }) +}) diff --git a/packages/arbor-store/tests/scoping/map.test.ts b/packages/arbor-store/tests/scoping/map.test.ts index b87d3ea..f36d528 100644 --- a/packages/arbor-store/tests/scoping/map.test.ts +++ b/packages/arbor-store/tests/scoping/map.test.ts @@ -1,8 +1,32 @@ import { describe, expect, it } from "vitest" import { Arbor } from "../../src/arbor" import { ScopedStore } from "../../src/scoping/store" +import { unwrap } from "../../src/utilities" describe("map", () => { + describe("#get", () => { + it("access children nodes", () => { + const bob = { name: "Bob" } + const alice = { name: "Alice" } + const store = new Arbor( + new Map([ + [1, alice], + [2, bob], + ]) + ) + + const scope = new ScopedStore(store) + + const scopedNode1 = scope.state.get(1) + const scopedNode2 = scope.state.get(2) + + expect(unwrap(scopedNode1)).toBeNodeOf(alice) + expect(unwrap(scopedNode2)).toBeNodeOf(bob) + expect(scopedNode1).toBeTrackedNode() + expect(scopedNode2).toBeTrackedNode() + }) + }) + describe("Symbol.iterator", () => { it("can convert a map node from a scoped store to array", () => { const store = new Arbor( @@ -33,9 +57,6 @@ describe("map", () => { const scope = new ScopedStore(store) - expect(scope.state.get(1)).toBeTrackedNode() - expect(scope.state.get(2)).toBeTrackedNode() - // const list = Array.from(scope.state) // expect(list[0][1]).toBeNodeOf(alice) From da26ff12f6a52d64e2d86bd1110e7db80c688e08 Mon Sep 17 00:00:00 2001 From: Diego Borges Date: Sun, 8 Sep 2024 08:40:51 -0300 Subject: [PATCH 10/16] Intercept iterator logic when called from scoped node This allows scoped contexts to intercept and track nodes being iterated so path tracking works as expected. --- packages/arbor-store/src/guards.ts | 7 +++ packages/arbor-store/src/handlers/array.ts | 16 ++++++- packages/arbor-store/src/handlers/map.ts | 20 ++++---- packages/arbor-store/src/scoping/scope.ts | 48 ++++++++++--------- packages/arbor-store/src/types.ts | 1 + .../arbor-store/tests/scoping/array.test.ts | 26 ++++++++++ .../arbor-store/tests/scoping/map.test.ts | 13 ++--- .../arbor-store/tests/scoping/store.test.ts | 4 +- 8 files changed, 96 insertions(+), 39 deletions(-) diff --git a/packages/arbor-store/src/guards.ts b/packages/arbor-store/src/guards.ts index c25617a..263d0e7 100644 --- a/packages/arbor-store/src/guards.ts +++ b/packages/arbor-store/src/guards.ts @@ -10,6 +10,13 @@ export function isNode(value: unknown): value is Node { export function isProxiable(value: unknown): value is object { if (value == null) return false + // TODO: Look into decoupling this logic from Array and Map classes. + // If we can make this decision based on which node handlers are supported + // e.g. ArrayHandler, MapHandler, SetHandler (coming soon): + // + // PoC: + // supportedNodeHandlers.some(handler => handler.accept(value)) + // return ( Array.isArray(value) || value instanceof Map || diff --git a/packages/arbor-store/src/handlers/array.ts b/packages/arbor-store/src/handlers/array.ts index 77f9822..719a622 100644 --- a/packages/arbor-store/src/handlers/array.ts +++ b/packages/arbor-store/src/handlers/array.ts @@ -1,5 +1,6 @@ import { Arbor } from "arbor" -import { Node } from "../types" +import { isNode } from "../guards" +import { Node, IteratorWrapper } from "../types" import { pathFor } from "../utilities" import { DefaultHandler } from "./default" @@ -21,6 +22,19 @@ export class ArrayHandler extends DefaultHandler< return Array.isArray(value) } + /** + * Provides an iterator that can be used to traverse the underlying Map data. + * + * @param wrap an optional wrapping function that can be used by scoped stores + * to wrap items with their own scope providing path tracking behavior. + */ + *[Symbol.iterator](wrap: IteratorWrapper = (n) => n) { + for (const link of this.$value.keys()) { + const child = this[link] + yield isNode(child) ? wrap(child) : child + } + } + deleteProperty(target: T[], prop: string): boolean { this.$tree.detachNodeFor(target[prop]) diff --git a/packages/arbor-store/src/handlers/map.ts b/packages/arbor-store/src/handlers/map.ts index 75ca610..a3e5b88 100644 --- a/packages/arbor-store/src/handlers/map.ts +++ b/packages/arbor-store/src/handlers/map.ts @@ -1,6 +1,5 @@ -import { NotAnArborNodeError } from "../errors" import { isNode, isProxiable } from "../guards" -import type { Link, Node } from "../types" +import type { IteratorWrapper, Link, Node } from "../types" import { DefaultHandler } from "./default" export class MapHandler extends DefaultHandler< @@ -10,12 +9,17 @@ export class MapHandler extends DefaultHandler< return value instanceof Map } - *[Symbol.iterator]() { - const mapProxy = this.$tree.getNodeFor(this) as unknown as Map - - for (const entry of this.$value.entries()) { - const childProxy = mapProxy.get(entry[0]) - yield [entry[0], childProxy] + /** + * Provides an iterator that can be used to traverse the underlying Map data. + * + * @param wrap an optional wrapping function that can be used by scoped stores + * to wrap items with their own scope providing path tracking behavior. + */ + *[Symbol.iterator](wrap: IteratorWrapper = (n) => n) { + const node = this as unknown as Map + for (const link of this.$value.keys()) { + const child = node.get(link) + yield [link, isNode(child) ? wrap(child) : child] } } diff --git a/packages/arbor-store/src/scoping/scope.ts b/packages/arbor-store/src/scoping/scope.ts index bd16344..7a1d5b0 100644 --- a/packages/arbor-store/src/scoping/scope.ts +++ b/packages/arbor-store/src/scoping/scope.ts @@ -2,7 +2,7 @@ import { Arbor } from "../arbor" import { isDetachedProperty } from "../decorators" import { isNode } from "../guards" import { Seed } from "../path" -import { ArborNode, Link, MutationEvent } from "../types" +import { ArborNode, Link, MutationEvent, Node } from "../types" import { isGetter, recursivelyUnwrap } from "../utilities" export type Tracked = T & { @@ -116,7 +116,7 @@ export class Scope { const getOrCache = this.getOrCache.bind(this) return new Proxy(node, { - get(target, prop: string, proxy) { + get(target: Node, prop, proxy) { if (prop === "$tracked") { return true } @@ -125,32 +125,36 @@ export class Scope { return target } - if (prop === "get") { - return (link: Link) => { - const child = target.get(link) - - if ( - child == null || - // There's no point in tracking access to Arbor stores being referenced - // without other stores since they are not connected to each other. - // Also, we cannot proxy Arbor instance since itself relies on #private - // fields to hide internal concerns which gets in the way of the proxying - // mechanism. - // - // See "Private Properties" section of the Caveats.md for more details. - child instanceof Arbor || - typeof child !== "object" - ) { - return child + // Scopes must be able to intercept iteration logic so that items being traversed + // can be wrapped within a scope proxy so that path tracking can happen. + // + // To achieve that, in Arbor, every NodeHandler that needs to support iterators, + // like arrays and maps, implement their own *[Symbol.iterator] method that accepts + // a proxy function as their argument, this function is provided by the scope and + // allows the iterator to wrap each item being iterated within a scope that provides + // path tracking behavior. + if (prop === Symbol.iterator && target[prop] != null) { + return function* iterator() { + const iterate = target[Symbol.iterator].bind(target) + for (const entry of iterate(getOrCache)) { + yield entry } - - return getOrCache(child) } } + // TODO: find a solution that does not involve overriding a possible get method + // on the target... + // // TODO: handlers/map.ts must intercept child access so it can proxy them // otherwise, accessing a child from a scoped map will not yield a scoped // child but the actual underlying value. + if (target instanceof Map && prop === "get") { + return (link: Link) => { + const child = target.get(link) + + return isNode(child) ? getOrCache(child) : child + } + } const child = Reflect.get(target, prop, proxy) const descriptor = Object.getOwnPropertyDescriptor(target, prop) @@ -186,7 +190,7 @@ export class Scope { if ( isNode(target) && !isGetter(target, prop as string) && - !isDetachedProperty(target, prop) + !isDetachedProperty(target, prop as string) ) { track(target, prop) } diff --git a/packages/arbor-store/src/types.ts b/packages/arbor-store/src/types.ts index a0374fc..b0c5f29 100644 --- a/packages/arbor-store/src/types.ts +++ b/packages/arbor-store/src/types.ts @@ -35,6 +35,7 @@ export type ArborNode = { export type Link = string | number export type Unsubscribe = () => void +export type IteratorWrapper = (child: Node) => Node export type Node = T & { readonly $value: T readonly $tree: Arbor diff --git a/packages/arbor-store/tests/scoping/array.test.ts b/packages/arbor-store/tests/scoping/array.test.ts index 9102abb..00f2c2e 100644 --- a/packages/arbor-store/tests/scoping/array.test.ts +++ b/packages/arbor-store/tests/scoping/array.test.ts @@ -3,6 +3,32 @@ import { Arbor } from "../../src/arbor" import { ScopedStore } from "../../src/scoping/store" describe("Array", () => { + describe("Symbol.iterator", () => { + it("exposes child nodes via iterator", () => { + const store = new Arbor([ + { name: "Alice" }, + { name: "Bob" }, + { name: "Carol" }, + ]) + + const scoped = new ScopedStore(store) + + const alice = scoped.state[0] + const bob = scoped.state[1] + const carol = scoped.state[2] + + const iterator = scoped.state[Symbol.iterator]() + + const user1 = iterator.next().value + const user2 = iterator.next().value + const user3 = iterator.next().value + + expect(user1).toBe(alice) + expect(user2).toBe(bob) + expect(user3).toBe(carol) + }) + }) + describe("Array#splice", () => { it("splices multiple items in a row", () => { const store = new Arbor({ diff --git a/packages/arbor-store/tests/scoping/map.test.ts b/packages/arbor-store/tests/scoping/map.test.ts index f36d528..5650d90 100644 --- a/packages/arbor-store/tests/scoping/map.test.ts +++ b/packages/arbor-store/tests/scoping/map.test.ts @@ -45,8 +45,9 @@ describe("map", () => { ]) }) - it("exposes scoped nodes", () => { + it.only("exposes scoped nodes", () => { const bob = { name: "Bob" } + const alice = { name: "Alice" } const store = new Arbor( new Map([ @@ -57,12 +58,12 @@ describe("map", () => { const scope = new ScopedStore(store) - // const list = Array.from(scope.state) + const list = Array.from(scope.state) - // expect(list[0][1]).toBeNodeOf(alice) - // expect(list[1][1]).toBeNodeOf(bob) - // expect(list[0][1]).toBeTrackedNode() - // expect(list[1][1]).toBeTrackedNode() + expect(unwrap(list[0][1])).toBeNodeOf(alice) + expect(unwrap(list[1][1])).toBeNodeOf(bob) + expect(list[0][1]).toBeTrackedNode() + expect(list[1][1]).toBeTrackedNode() }) }) }) diff --git a/packages/arbor-store/tests/scoping/store.test.ts b/packages/arbor-store/tests/scoping/store.test.ts index 8a1f832..e826bf2 100644 --- a/packages/arbor-store/tests/scoping/store.test.ts +++ b/packages/arbor-store/tests/scoping/store.test.ts @@ -112,13 +112,13 @@ describe("path tracking", () => { const scopedStore1 = new ScopedStore(store) - expect(scopedStore1.state).toEqual(store.state) + expect(scopedStore1.state).toBeTrackedNode() expect(scopedStore1.state).not.toBe(store.state) expect(unwrap(scopedStore1.state)).toBe(store.state) const scopedStore2 = new ScopedStore(scopedStore1.state.todos[0]) - expect(scopedStore2.state).toEqual(store.state.todos[0]) + expect(scopedStore2.state).toBeTrackedNode() expect(scopedStore2.state).not.toBe(store.state.todos[0]) expect(scopedStore2.state).not.toBe(scopedStore1.state.todos[0]) expect(unwrap(scopedStore2.state)).toBe(store.state.todos[0]) From f83c8afb30db01ebd727b71404824bb5d7b2613d Mon Sep 17 00:00:00 2001 From: Diego Borges Date: Tue, 10 Sep 2024 16:05:40 -0300 Subject: [PATCH 11/16] Remove no longer needed check for Arbor instances when scoping child access --- packages/arbor-store/src/handlers/default.ts | 2 ++ packages/arbor-store/src/scoping/scope.ts | 26 ++++++-------------- 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/packages/arbor-store/src/handlers/default.ts b/packages/arbor-store/src/handlers/default.ts index 7d41353..1802ca9 100644 --- a/packages/arbor-store/src/handlers/default.ts +++ b/packages/arbor-store/src/handlers/default.ts @@ -18,6 +18,8 @@ const PROXY_HANDLER_API = ["apply", "get", "set", "deleteProperty"] export class DefaultHandler implements ProxyHandler { + // TODO: Move $ prefixed props to Symbol properties. + // This will mitigate potential naming clashes with user-defined data. constructor( readonly $tree: Arbor, readonly $value: T, diff --git a/packages/arbor-store/src/scoping/scope.ts b/packages/arbor-store/src/scoping/scope.ts index 7a1d5b0..25e24f1 100644 --- a/packages/arbor-store/src/scoping/scope.ts +++ b/packages/arbor-store/src/scoping/scope.ts @@ -1,4 +1,3 @@ -import { Arbor } from "../arbor" import { isDetachedProperty } from "../decorators" import { isNode } from "../guards" import { Seed } from "../path" @@ -117,10 +116,12 @@ export class Scope { return new Proxy(node, { get(target: Node, prop, proxy) { + // TODO: Rename $tracked to Symbol.for("ArborScoped") if (prop === "$tracked") { return true } + // Exposes the node wrapped by the proxy if (prop === "$value") { return target } @@ -142,12 +143,10 @@ export class Scope { } } - // TODO: find a solution that does not involve overriding a possible get method - // on the target... - // - // TODO: handlers/map.ts must intercept child access so it can proxy them - // otherwise, accessing a child from a scoped map will not yield a scoped - // child but the actual underlying value. + // TODO: Look into making this logic generic. + // We need a mechanism where node handlers can declare a few methods in the underlying + // object signaling that it returns a child node, allowing Arbor to intercept that access + // adding extra behavior such as scoping/path tracking capabilities like below. if (target instanceof Map && prop === "get") { return (link: Link) => { const child = target.get(link) @@ -195,18 +194,7 @@ export class Scope { track(target, prop) } - if ( - child == null || - // There's no point in tracking access to Arbor stores being referenced - // without other stores since they are not connected to each other. - // Also, we cannot proxy Arbor instance since itself relies on #private - // fields to hide internal concerns which gets in the way of the proxying - // mechanism. - // - // See "Private Properties" section of the Caveats.md for more details. - child instanceof Arbor || - typeof child !== "object" - ) { + if (!isNode(child)) { return child } From c9209b2ded62b5ba18b4d8522c67172e505f39a5 Mon Sep 17 00:00:00 2001 From: Diego Borges Date: Fri, 13 Sep 2024 09:12:05 -0300 Subject: [PATCH 12/16] Use scoping terminology in code to increase consistency --- packages/arbor-react/tests/matchers/index.ts | 2 +- .../{toBeTrackedNode.ts => toBeScopedNode.ts} | 8 +-- packages/arbor-react/tests/useArbor.test.ts | 8 +-- packages/arbor-react/tests/vitest.d.ts | 2 +- packages/arbor-store/src/handlers/default.ts | 11 ++++ packages/arbor-store/src/scoping/scope.ts | 40 ++++++------- packages/arbor-store/tests/matchers/index.ts | 4 +- .../tests/matchers/toBeScopedNode.ts | 15 +++++ .../{toBeTracking.ts => toBeScoping.ts} | 16 ++--- .../tests/matchers/toBeTrackedNode.ts | 15 ----- .../arbor-store/tests/scoping/array.test.ts | 2 +- .../arbor-store/tests/scoping/map.test.ts | 8 +-- .../arbor-store/tests/scoping/store.test.ts | 58 +++++++++---------- packages/arbor-store/tests/vitest.d.ts | 4 +- 14 files changed, 102 insertions(+), 91 deletions(-) rename packages/arbor-react/tests/matchers/{toBeTrackedNode.ts => toBeScopedNode.ts} (50%) create mode 100644 packages/arbor-store/tests/matchers/toBeScopedNode.ts rename packages/arbor-store/tests/matchers/{toBeTracking.ts => toBeScoping.ts} (53%) delete mode 100644 packages/arbor-store/tests/matchers/toBeTrackedNode.ts diff --git a/packages/arbor-react/tests/matchers/index.ts b/packages/arbor-react/tests/matchers/index.ts index eca36c3..f836aca 100644 --- a/packages/arbor-react/tests/matchers/index.ts +++ b/packages/arbor-react/tests/matchers/index.ts @@ -1 +1 @@ -import "./toBeTrackedNode" +import "./toBeScopedNode" diff --git a/packages/arbor-react/tests/matchers/toBeTrackedNode.ts b/packages/arbor-react/tests/matchers/toBeScopedNode.ts similarity index 50% rename from packages/arbor-react/tests/matchers/toBeTrackedNode.ts rename to packages/arbor-react/tests/matchers/toBeScopedNode.ts index 0f8b253..ec73ec3 100644 --- a/packages/arbor-react/tests/matchers/toBeTrackedNode.ts +++ b/packages/arbor-react/tests/matchers/toBeScopedNode.ts @@ -2,13 +2,13 @@ import { expect } from "vitest" expect.extend({ // eslint-disable-next-line @typescript-eslint/no-explicit-any - toBeTrackedNode(received: any) { - const isTracked = received.$tracked === true + toBeScopedNode(received: any) { + const isScoped = received.$scoped === true return { - pass: isTracked, + pass: isScoped, actual: received, message: () => - `Received value is ${isTracked ? "" : "not"} a tracked Arbor node`, + `Received value is ${isScoped ? "" : "not"} a tracked Arbor node`, } }, }) diff --git a/packages/arbor-react/tests/useArbor.test.ts b/packages/arbor-react/tests/useArbor.test.ts index 73c944c..e4ea468 100644 --- a/packages/arbor-react/tests/useArbor.test.ts +++ b/packages/arbor-react/tests/useArbor.test.ts @@ -16,10 +16,10 @@ describe("useArbor", () => { const { result } = renderHook(() => useArbor(store)) - expect(result.current).toBeTrackedNode() - expect(result.current[0]).toBeTrackedNode() - expect(result.current[1]).toBeTrackedNode() - expect(result.current[2]).toBeTrackedNode() + expect(result.current).toBeScopedNode() + expect(result.current[0]).toBeScopedNode() + expect(result.current[1]).toBeScopedNode() + expect(result.current[2]).toBeScopedNode() }) it("returns the current state of the store", () => { diff --git a/packages/arbor-react/tests/vitest.d.ts b/packages/arbor-react/tests/vitest.d.ts index e6463b2..a5131ce 100644 --- a/packages/arbor-react/tests/vitest.d.ts +++ b/packages/arbor-react/tests/vitest.d.ts @@ -4,7 +4,7 @@ declare module "vitest" { interface Assertion { toBeDetached: () => T toBeArborNode: () => T - toBeTrackedNode: () => T + toBeScopedNode: () => T toBeProxiedExactlyOnce: () => T toBeNodeOf: (expected: unknown) => T toHaveNodeFor: (expected: unknown) => T diff --git a/packages/arbor-store/src/handlers/default.ts b/packages/arbor-store/src/handlers/default.ts index 1802ca9..b6dd328 100644 --- a/packages/arbor-store/src/handlers/default.ts +++ b/packages/arbor-store/src/handlers/default.ts @@ -40,6 +40,17 @@ export class DefaultHandler return true } + // TODO: expose seed value via a getter so user-defined data can have access to it. + // Example: + // get $seed() { return Seed.from(this) } + // + // User-defined classes could then: + // + // @proxiable + // class MyNode { + // $seed: number + // } + $getChildNode(link: Link): Node { return this[link] } diff --git a/packages/arbor-store/src/scoping/scope.ts b/packages/arbor-store/src/scoping/scope.ts index 25e24f1..aa157e4 100644 --- a/packages/arbor-store/src/scoping/scope.ts +++ b/packages/arbor-store/src/scoping/scope.ts @@ -4,16 +4,16 @@ import { Seed } from "../path" import { ArborNode, Link, MutationEvent, Node } from "../types" import { isGetter, recursivelyUnwrap } from "../utilities" -export type Tracked = T & { - $tracked?: boolean +export type Scoped = T & { + $scoped?: boolean } export class Scope { private readonly bindings = new WeakMap>() - private readonly cache = new WeakMap() - private tracking = new WeakMap>() + private readonly cache = new WeakMap() + private scopes = new WeakMap>() - getOrCache(node: ArborNode): Tracked { + getOrCache(node: ArborNode): Scoped { if (!this.cache.has(node)) { this.cache.set(node, this.wrap(node)) } @@ -21,19 +21,19 @@ export class Scope { return this.cache.get(node) } - isTracking(node: ArborNode, prop: string) { + toBeScoping(node: ArborNode, prop: string) { const seed = Seed.from(node) - const tracked = this.tracking.get(seed) + const scoped = this.scopes.get(seed) - if (!tracked) { + if (!scoped) { return false } - return tracked.has(prop) + return scoped.has(prop) } reset() { - this.tracking = new WeakMap>() + this.scopes = new WeakMap>() } // NOTE: Something to consider in the future as new node handler types @@ -84,28 +84,28 @@ export class Scope { const rootSeed = Seed.from(event.state) const targetSeed = event.mutationPath.seeds.at(-1) - const tracked = this.tracking.get(targetSeed || rootSeed) + const scoped = this.scopes.get(targetSeed || rootSeed) - // If the affected node is not being tracked, then no need to notify + // If the affected node is not being scoped, then no need to notify // any subscribers. - if (!tracked) { + if (!scoped) { return false } // Lastly, subscribers will be notified if any of the mutated props are - // being tracked. - return event.metadata.props.some((prop) => tracked.has(prop as string)) + // being scoped. + return event.metadata.props.some((prop) => scoped.has(prop as string)) } track(value: object, prop?: string) { const seed = Seed.from(value) - if (!this.tracking.has(seed)) { - this.tracking.set(seed, new Set()) + if (!this.scopes.has(seed)) { + this.scopes.set(seed, new Set()) } if (prop != null) { - this.tracking.get(seed).add(prop) + this.scopes.get(seed).add(prop) } } @@ -116,8 +116,8 @@ export class Scope { return new Proxy(node, { get(target: Node, prop, proxy) { - // TODO: Rename $tracked to Symbol.for("ArborScoped") - if (prop === "$tracked") { + // TODO: Rename $scoped to Symbol.for("ArborScoped") + if (prop === "$scoped") { return true } diff --git a/packages/arbor-store/tests/matchers/index.ts b/packages/arbor-store/tests/matchers/index.ts index fa56a99..34e0730 100644 --- a/packages/arbor-store/tests/matchers/index.ts +++ b/packages/arbor-store/tests/matchers/index.ts @@ -3,8 +3,8 @@ import "./toBeDetached" import "./toBeNodeOf" import "./toBeProxiedExactlyOnce" import "./toBeSeeded" -import "./toBeTrackedNode" -import "./toBeTracking" +import "./toBeScopedNode" +import "./toBeScoping" import "./toHaveLink" import "./toHaveLinkFor" import "./toHaveNodeFor" diff --git a/packages/arbor-store/tests/matchers/toBeScopedNode.ts b/packages/arbor-store/tests/matchers/toBeScopedNode.ts new file mode 100644 index 0000000..c4244c8 --- /dev/null +++ b/packages/arbor-store/tests/matchers/toBeScopedNode.ts @@ -0,0 +1,15 @@ +import { expect } from "vitest" + +import { Scoped } from "../../src/scoping/scope" + +expect.extend({ + toBeScopedNode(received) { + const isScoped = (received as Scoped)?.$scoped === true + return { + pass: isScoped, + actual: received, + message: () => + `Received value is ${isScoped ? "" : "not"} a scoped Arbor node`, + } + }, +}) diff --git a/packages/arbor-store/tests/matchers/toBeTracking.ts b/packages/arbor-store/tests/matchers/toBeScoping.ts similarity index 53% rename from packages/arbor-store/tests/matchers/toBeTracking.ts rename to packages/arbor-store/tests/matchers/toBeScoping.ts index 2c6c45d..657bb72 100644 --- a/packages/arbor-store/tests/matchers/toBeTracking.ts +++ b/packages/arbor-store/tests/matchers/toBeScoping.ts @@ -1,10 +1,10 @@ import { expect } from "vitest" import { ArborError, isNode, NotAnArborNodeError, ScopedStore } from "../../src" -import { Tracked } from "../../src/scoping/scope" +import { Scoped } from "../../src/scoping/scope" expect.extend({ - toBeTracking(scopedStore, node, prop) { + toBeScoping(scopedStore, node, prop) { if (!(scopedStore instanceof ScopedStore)) { throw new ArborError("received value is not an instance of ScopedStore") } @@ -13,17 +13,17 @@ expect.extend({ throw new NotAnArborNodeError() } - const isTrackedNode = (node as Tracked)?.$tracked === true - const isTrackingNodeProp = - isTrackedNode && scopedStore.scope.isTracking(node, prop) + const isScopedNode = (node as Scoped)?.$scoped === true + const toBeScopingNodeProp = + isScopedNode && scopedStore.scope.toBeScoping(node, prop) return { - pass: isTrackingNodeProp, + pass: toBeScopingNodeProp, actual: scopedStore, message: () => `ScopedStore is ${ - isTrackingNodeProp ? "" : "not" - } tracking prop ${prop} for node ${node}`, + toBeScopingNodeProp ? "" : "not" + } scoping updates to ${prop}`, } }, }) diff --git a/packages/arbor-store/tests/matchers/toBeTrackedNode.ts b/packages/arbor-store/tests/matchers/toBeTrackedNode.ts deleted file mode 100644 index e552634..0000000 --- a/packages/arbor-store/tests/matchers/toBeTrackedNode.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { expect } from "vitest" - -import { Tracked } from "../../src/scoping/scope" - -expect.extend({ - toBeTrackedNode(received) { - const isTracked = (received as Tracked)?.$tracked === true - return { - pass: isTracked, - actual: received, - message: () => - `Received value is ${isTracked ? "" : "not"} a tracked Arbor node`, - } - }, -}) diff --git a/packages/arbor-store/tests/scoping/array.test.ts b/packages/arbor-store/tests/scoping/array.test.ts index 00f2c2e..446c2a6 100644 --- a/packages/arbor-store/tests/scoping/array.test.ts +++ b/packages/arbor-store/tests/scoping/array.test.ts @@ -384,7 +384,7 @@ describe("Array", () => { delete scoped.state.todos[1] delete scoped.state.todos[1] - expect(scoped).not.toBeTracking(scoped.state.todos, 1) + expect(scoped).not.toBeScoping(scoped.state.todos, 1) expect(subscriber).toHaveBeenCalledTimes(2) }) diff --git a/packages/arbor-store/tests/scoping/map.test.ts b/packages/arbor-store/tests/scoping/map.test.ts index 5650d90..ee89d80 100644 --- a/packages/arbor-store/tests/scoping/map.test.ts +++ b/packages/arbor-store/tests/scoping/map.test.ts @@ -22,8 +22,8 @@ describe("map", () => { expect(unwrap(scopedNode1)).toBeNodeOf(alice) expect(unwrap(scopedNode2)).toBeNodeOf(bob) - expect(scopedNode1).toBeTrackedNode() - expect(scopedNode2).toBeTrackedNode() + expect(scopedNode1).toBeScopedNode() + expect(scopedNode2).toBeScopedNode() }) }) @@ -62,8 +62,8 @@ describe("map", () => { expect(unwrap(list[0][1])).toBeNodeOf(alice) expect(unwrap(list[1][1])).toBeNodeOf(bob) - expect(list[0][1]).toBeTrackedNode() - expect(list[1][1]).toBeTrackedNode() + expect(list[0][1]).toBeScopedNode() + expect(list[1][1]).toBeScopedNode() }) }) }) diff --git a/packages/arbor-store/tests/scoping/store.test.ts b/packages/arbor-store/tests/scoping/store.test.ts index e826bf2..25cf0d7 100644 --- a/packages/arbor-store/tests/scoping/store.test.ts +++ b/packages/arbor-store/tests/scoping/store.test.ts @@ -96,10 +96,10 @@ describe("path tracking", () => { const scopedStore1 = new ScopedStore(store) - expect(scopedStore1.state).toBeTrackedNode() - expect(scopedStore1.state.todos).toBeTrackedNode() - expect(scopedStore1.state.todos[0]).toBeTrackedNode() - expect(scopedStore1.state.todos[1]).toBeTrackedNode() + expect(scopedStore1.state).toBeScopedNode() + expect(scopedStore1.state.todos).toBeScopedNode() + expect(scopedStore1.state.todos[0]).toBeScopedNode() + expect(scopedStore1.state.todos[1]).toBeScopedNode() }) it("automatically unwraps tracked node when creating a derived tracking scope", () => { @@ -112,13 +112,13 @@ describe("path tracking", () => { const scopedStore1 = new ScopedStore(store) - expect(scopedStore1.state).toBeTrackedNode() + expect(scopedStore1.state).toBeScopedNode() expect(scopedStore1.state).not.toBe(store.state) expect(unwrap(scopedStore1.state)).toBe(store.state) const scopedStore2 = new ScopedStore(scopedStore1.state.todos[0]) - expect(scopedStore2.state).toBeTrackedNode() + expect(scopedStore2.state).toBeScopedNode() expect(scopedStore2.state).not.toBe(store.state.todos[0]) expect(scopedStore2.state).not.toBe(scopedStore1.state.todos[0]) expect(unwrap(scopedStore2.state)).toBe(store.state.todos[0]) @@ -176,7 +176,7 @@ describe("path tracking", () => { const activeTodo = scopedStore.state.todos.find((t) => t.active) - expect(activeTodo).toBeTrackedNode() + expect(activeTodo).toBeScopedNode() expect(activeTodo).toBe(scopedStore.state.todos[1]) }) @@ -265,10 +265,10 @@ describe("path tracking", () => { const tracked = new ScopedStore(store.state) store.state.filter // binds filter to the original store - const filterBoundToTrackedStore = tracked.state.filter - const activeUsers = filterBoundToTrackedStore((u) => u.active) + const filterBoundToScopedStore = tracked.state.filter + const activeUsers = filterBoundToScopedStore((u) => u.active) - expect(activeUsers[0]).toBeTrackedNode() + expect(activeUsers[0]).toBeScopedNode() }) it("preserves path tracking on nodes 'plucked' from the state tree", () => { @@ -285,7 +285,7 @@ describe("path tracking", () => { carol.active = false - expect(carol).toBeTrackedNode() + expect(carol).toBeScopedNode() expect(subscriber).toHaveBeenCalledTimes(1) }) @@ -299,7 +299,7 @@ describe("path tracking", () => { const tracked = new ScopedStore(store) const carol = tracked.state.users[0] - expect(carol).toBeTrackedNode() + expect(carol).toBeScopedNode() }) it("ensure node methods have stable memory reference across updates", () => { @@ -370,8 +370,8 @@ describe("path tracking", () => { store.state.untrackedProp store.state.trackedProp - expect(store.scope.isTracking(store.state, "trackedProp")).toBe(true) - expect(store.scope.isTracking(store.state, "untrackedProp")).toBe(false) + expect(store.scope.toBeScoping(store.state, "trackedProp")).toBe(true) + expect(store.scope.toBeScoping(store.state, "untrackedProp")).toBe(false) }) it("binds methods to the path tracking proxy", () => { @@ -501,14 +501,14 @@ describe("path tracking", () => { const scoped = new ScopedStore(new Arbor(new TodoApp())) const todo = scoped.state.activeTodos[0] - expect(scoped).toBeTracking(todo, "done") - expect(scoped).not.toBeTracking(todo, "text") + expect(scoped).toBeScoping(todo, "done") + expect(scoped).not.toBeScoping(todo, "text") // access the first todo in the result so the scoped store // start tracking changes to the todo's "text" prop. todo.text - expect(scoped).toBeTracking(todo, "text") + expect(scoped).toBeScoping(todo, "text") }) it("tracks changes to boolean fields accessed via a getter on a node", () => { @@ -533,11 +533,11 @@ describe("path tracking", () => { const node = scoped.state.node // warms up the cache - expect(scoped).not.toBeTracking(node, "flag") + expect(scoped).not.toBeScoping(node, "flag") node.flag // warms up the cache - expect(scoped).toBeTracking(node, "flag") + expect(scoped).toBeScoping(node, "flag") node.flag = true @@ -561,15 +561,15 @@ describe("path tracking", () => { const scoped = new ScopedStore(new Arbor(new App())) const node = scoped.state - expect(scoped).not.toBeTracking(node, "flag1") - expect(scoped).not.toBeTracking(node, "flag2") + expect(scoped).not.toBeScoping(node, "flag1") + expect(scoped).not.toBeScoping(node, "flag2") // warms up the cache // causes "scoped" to track "flag1" but not "flag2" scoped.state.check - expect(scoped).toBeTracking(node, "flag1") - expect(scoped).not.toBeTracking(node, "flag2") + expect(scoped).toBeScoping(node, "flag1") + expect(scoped).not.toBeScoping(node, "flag2") }) it("tracks eagarly accessed fields prior to short-circuit logic", () => { @@ -590,15 +590,15 @@ describe("path tracking", () => { const scoped = new ScopedStore(new Arbor(new App())) const node = scoped.state - expect(scoped).not.toBeTracking(node, "flag1") - expect(scoped).not.toBeTracking(node, "flag2") + expect(scoped).not.toBeScoping(node, "flag1") + expect(scoped).not.toBeScoping(node, "flag2") // warms up the cache // causes "scoped" to track "flag1" but not "flag2" scoped.state.check - expect(scoped).toBeTracking(node, "flag1") - expect(scoped).toBeTracking(node, "flag2") + expect(scoped).toBeScoping(node, "flag1") + expect(scoped).toBeScoping(node, "flag2") }) it("refreshes array node links successfully", () => { @@ -642,8 +642,8 @@ describe("path tracking", () => { store.state.flag2 = false - expect(scoped).toBeTracking(scoped.state, "flag1") - expect(scoped).not.toBeTracking(scoped.state, "flag2") + expect(scoped).toBeScoping(scoped.state, "flag1") + expect(scoped).not.toBeScoping(scoped.state, "flag2") expect(subscriber).not.toHaveBeenCalled() store.state.flag1 = false diff --git a/packages/arbor-store/tests/vitest.d.ts b/packages/arbor-store/tests/vitest.d.ts index b8ac386..eda6a34 100644 --- a/packages/arbor-store/tests/vitest.d.ts +++ b/packages/arbor-store/tests/vitest.d.ts @@ -6,8 +6,8 @@ declare module "vitest" { toBeSeeded: () => T toBeDetached: () => T toBeArborNode: () => T - toBeTrackedNode: () => T - toBeTracking: (node: ArborNode, prop: keyof D) => T + toBeScopedNode: () => T + toBeScoping: (node: ArborNode, prop: keyof D) => T toBeProxiedExactlyOnce: () => T toBeNodeOf: (expected: unknown) => T toHaveNodeFor: (expected: unknown) => T From 24315aaa57dde8ef55cb321ff28860a09cfd4d41 Mon Sep 17 00:00:00 2001 From: Diego Borges Date: Sat, 14 Sep 2024 20:55:41 -0300 Subject: [PATCH 13/16] Remove spec focus pushed by mistake --- packages/arbor-store/tests/scoping/map.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/arbor-store/tests/scoping/map.test.ts b/packages/arbor-store/tests/scoping/map.test.ts index ee89d80..51bef6b 100644 --- a/packages/arbor-store/tests/scoping/map.test.ts +++ b/packages/arbor-store/tests/scoping/map.test.ts @@ -45,7 +45,7 @@ describe("map", () => { ]) }) - it.only("exposes scoped nodes", () => { + it("exposes scoped nodes", () => { const bob = { name: "Bob" } const alice = { name: "Alice" } From fa8ec270715975644c5c0195b6fdd6f817bb8e3a Mon Sep 17 00:00:00 2001 From: Diego Borges Date: Fri, 20 Sep 2024 17:28:01 -0300 Subject: [PATCH 14/16] Increase test coverage around short circuit logic and scoped stores --- .../cypress/component/ShortCircuit.cy.tsx | 54 +++++++++++++++++++ examples/short-circuit/components/Actions.tsx | 15 ++++-- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/examples/cypress/component/ShortCircuit.cy.tsx b/examples/cypress/component/ShortCircuit.cy.tsx index a72dd3c..55dfcb7 100644 --- a/examples/cypress/component/ShortCircuit.cy.tsx +++ b/examples/cypress/component/ShortCircuit.cy.tsx @@ -5,5 +5,59 @@ import { App } from "../../short-circuit/App" describe("ShortCircuit App", () => { it("successfully renders components even when short circuit boolean logic is used", () => { cy.mount() + + cy.contains("state.flags.flag1: false") + cy.contains("state.flags.flag2: false") + cy.contains("state.flags.flag3: false") + cy.contains("app.flags.flag1 || app.flags.flag2 || app.flags.flag3 = false") + cy.contains("app.flags.flag1 && app.flags.flag2 && app.flags.flag3 = false") + + cy.get("[data-testid='toggle-flag3']").click() + + cy.contains("state.flags.flag1: false") + cy.contains("state.flags.flag2: false") + cy.contains("state.flags.flag3: true") + cy.contains("app.flags.flag1 || app.flags.flag2 || app.flags.flag3 = true") + cy.contains("app.flags.flag1 && app.flags.flag2 && app.flags.flag3 = false") + + cy.get("[data-testid='toggle-flag2']").click() + + cy.contains("state.flags.flag1: false") + cy.contains("state.flags.flag2: true") + cy.contains("state.flags.flag3: true") + cy.contains("app.flags.flag1 || app.flags.flag2 || app.flags.flag3 = true") + cy.contains("app.flags.flag1 && app.flags.flag2 && app.flags.flag3 = false") + + cy.get("[data-testid='toggle-flag1']").click() + + cy.contains("state.flags.flag1: true") + cy.contains("state.flags.flag2: true") + cy.contains("state.flags.flag3: true") + cy.contains("app.flags.flag1 || app.flags.flag2 || app.flags.flag3 = true") + cy.contains("app.flags.flag1 && app.flags.flag2 && app.flags.flag3 = true") + + cy.get("[data-testid='toggle-flag3']").click() + + cy.contains("state.flags.flag1: true") + cy.contains("state.flags.flag2: true") + cy.contains("state.flags.flag3: false") + cy.contains("app.flags.flag1 || app.flags.flag2 || app.flags.flag3 = true") + cy.contains("app.flags.flag1 && app.flags.flag2 && app.flags.flag3 = false") + + cy.get("[data-testid='toggle-flag2']").click() + + cy.contains("state.flags.flag1: true") + cy.contains("state.flags.flag2: false") + cy.contains("state.flags.flag3: false") + cy.contains("app.flags.flag1 || app.flags.flag2 || app.flags.flag3 = true") + cy.contains("app.flags.flag1 && app.flags.flag2 && app.flags.flag3 = false") + + cy.get("[data-testid='toggle-flag1']").click() + + cy.contains("state.flags.flag1: false") + cy.contains("state.flags.flag2: false") + cy.contains("state.flags.flag3: false") + cy.contains("app.flags.flag1 || app.flags.flag2 || app.flags.flag3 = false") + cy.contains("app.flags.flag1 && app.flags.flag2 && app.flags.flag3 = false") }) }) diff --git a/examples/short-circuit/components/Actions.tsx b/examples/short-circuit/components/Actions.tsx index 42d1ee7..7b02afc 100644 --- a/examples/short-circuit/components/Actions.tsx +++ b/examples/short-circuit/components/Actions.tsx @@ -6,13 +6,22 @@ import { store } from "../store" export function Actions() { return ( - - - From 05f779703d5f0d6acdfd10538916b491b26a47df Mon Sep 17 00:00:00 2001 From: Diego Borges Date: Fri, 20 Sep 2024 20:15:32 -0300 Subject: [PATCH 15/16] Simplify state structure --- .../cypress/component/ShortCircuit.cy.tsx | 98 ++++++++++++------- examples/short-circuit/components/Actions.tsx | 15 +-- .../components/AreAllFlagsTrue.tsx | 10 +- .../components/AreThereAnyFlagsTrue.tsx | 10 +- .../short-circuit/components/StoreState.tsx | 6 +- examples/short-circuit/store/index.ts | 4 +- 6 files changed, 78 insertions(+), 65 deletions(-) diff --git a/examples/cypress/component/ShortCircuit.cy.tsx b/examples/cypress/component/ShortCircuit.cy.tsx index 55dfcb7..92cb958 100644 --- a/examples/cypress/component/ShortCircuit.cy.tsx +++ b/examples/cypress/component/ShortCircuit.cy.tsx @@ -6,58 +6,86 @@ describe("ShortCircuit App", () => { it("successfully renders components even when short circuit boolean logic is used", () => { cy.mount() - cy.contains("state.flags.flag1: false") - cy.contains("state.flags.flag2: false") - cy.contains("state.flags.flag3: false") - cy.contains("app.flags.flag1 || app.flags.flag2 || app.flags.flag3 = false") - cy.contains("app.flags.flag1 && app.flags.flag2 && app.flags.flag3 = false") + cy.contains("store.state.flag1: false") + cy.contains("store.state.flag2: false") + cy.contains("store.state.flag3: false") + cy.contains( + "store.state.flag1 || store.state.flag2 || store.state.flag3 = false" + ) + cy.contains( + "store.state.flag1 && store.state.flag2 && store.state.flag3 = false" + ) cy.get("[data-testid='toggle-flag3']").click() - cy.contains("state.flags.flag1: false") - cy.contains("state.flags.flag2: false") - cy.contains("state.flags.flag3: true") - cy.contains("app.flags.flag1 || app.flags.flag2 || app.flags.flag3 = true") - cy.contains("app.flags.flag1 && app.flags.flag2 && app.flags.flag3 = false") + cy.contains("store.state.flag1: false") + cy.contains("store.state.flag2: false") + cy.contains("store.state.flag3: true") + cy.contains( + "store.state.flag1 || store.state.flag2 || store.state.flag3 = true" + ) + cy.contains( + "store.state.flag1 && store.state.flag2 && store.state.flag3 = false" + ) cy.get("[data-testid='toggle-flag2']").click() - cy.contains("state.flags.flag1: false") - cy.contains("state.flags.flag2: true") - cy.contains("state.flags.flag3: true") - cy.contains("app.flags.flag1 || app.flags.flag2 || app.flags.flag3 = true") - cy.contains("app.flags.flag1 && app.flags.flag2 && app.flags.flag3 = false") + cy.contains("store.state.flag1: false") + cy.contains("store.state.flag2: true") + cy.contains("store.state.flag3: true") + cy.contains( + "store.state.flag1 || store.state.flag2 || store.state.flag3 = true" + ) + cy.contains( + "store.state.flag1 && store.state.flag2 && store.state.flag3 = false" + ) cy.get("[data-testid='toggle-flag1']").click() - cy.contains("state.flags.flag1: true") - cy.contains("state.flags.flag2: true") - cy.contains("state.flags.flag3: true") - cy.contains("app.flags.flag1 || app.flags.flag2 || app.flags.flag3 = true") - cy.contains("app.flags.flag1 && app.flags.flag2 && app.flags.flag3 = true") + cy.contains("store.state.flag1: true") + cy.contains("store.state.flag2: true") + cy.contains("store.state.flag3: true") + cy.contains( + "store.state.flag1 || store.state.flag2 || store.state.flag3 = true" + ) + cy.contains( + "store.state.flag1 && store.state.flag2 && store.state.flag3 = true" + ) cy.get("[data-testid='toggle-flag3']").click() - cy.contains("state.flags.flag1: true") - cy.contains("state.flags.flag2: true") - cy.contains("state.flags.flag3: false") - cy.contains("app.flags.flag1 || app.flags.flag2 || app.flags.flag3 = true") - cy.contains("app.flags.flag1 && app.flags.flag2 && app.flags.flag3 = false") + cy.contains("store.state.flag1: true") + cy.contains("store.state.flag2: true") + cy.contains("store.state.flag3: false") + cy.contains( + "store.state.flag1 || store.state.flag2 || store.state.flag3 = true" + ) + cy.contains( + "store.state.flag1 && store.state.flag2 && store.state.flag3 = false" + ) cy.get("[data-testid='toggle-flag2']").click() - cy.contains("state.flags.flag1: true") - cy.contains("state.flags.flag2: false") - cy.contains("state.flags.flag3: false") - cy.contains("app.flags.flag1 || app.flags.flag2 || app.flags.flag3 = true") - cy.contains("app.flags.flag1 && app.flags.flag2 && app.flags.flag3 = false") + cy.contains("store.state.flag1: true") + cy.contains("store.state.flag2: false") + cy.contains("store.state.flag3: false") + cy.contains( + "store.state.flag1 || store.state.flag2 || store.state.flag3 = true" + ) + cy.contains( + "store.state.flag1 && store.state.flag2 && store.state.flag3 = false" + ) cy.get("[data-testid='toggle-flag1']").click() - cy.contains("state.flags.flag1: false") - cy.contains("state.flags.flag2: false") - cy.contains("state.flags.flag3: false") - cy.contains("app.flags.flag1 || app.flags.flag2 || app.flags.flag3 = false") - cy.contains("app.flags.flag1 && app.flags.flag2 && app.flags.flag3 = false") + cy.contains("store.state.flag1: false") + cy.contains("store.state.flag2: false") + cy.contains("store.state.flag3: false") + cy.contains( + "store.state.flag1 || store.state.flag2 || store.state.flag3 = false" + ) + cy.contains( + "store.state.flag1 && store.state.flag2 && store.state.flag3 = false" + ) }) }) diff --git a/examples/short-circuit/components/Actions.tsx b/examples/short-circuit/components/Actions.tsx index 7b02afc..7c582e3 100644 --- a/examples/short-circuit/components/Actions.tsx +++ b/examples/short-circuit/components/Actions.tsx @@ -6,22 +6,13 @@ import { store } from "../store" export function Actions() { return ( - - - diff --git a/examples/short-circuit/components/AreAllFlagsTrue.tsx b/examples/short-circuit/components/AreAllFlagsTrue.tsx index 9905aca..e7630bd 100644 --- a/examples/short-circuit/components/AreAllFlagsTrue.tsx +++ b/examples/short-circuit/components/AreAllFlagsTrue.tsx @@ -8,12 +8,10 @@ export function AreAllFlagsTrue() { const app = useArbor(store) return ( -
- - app.flags.flag1 && app.flags.flag2 && app.flags.flag3 ={" "} - {(app.flags.flag1 && app.flags.flag2 && app.flags.flag3).toString()} - -
+ + store.state.flag1 && store.state.flag2 && store.state.flag3 ={" "} + {(app.flag1 && app.flag2 && app.flag3).toString()} +
) } diff --git a/examples/short-circuit/components/AreThereAnyFlagsTrue.tsx b/examples/short-circuit/components/AreThereAnyFlagsTrue.tsx index 5ec0e7e..29d10c6 100644 --- a/examples/short-circuit/components/AreThereAnyFlagsTrue.tsx +++ b/examples/short-circuit/components/AreThereAnyFlagsTrue.tsx @@ -8,12 +8,10 @@ export function AreThereAnyFlagsTrue() { const app = useArbor(store) return ( -
- - app.flags.flag1 || app.flags.flag2 || app.flags.flag3 ={" "} - {(app.flags.flag1 || app.flags.flag2 || app.flags.flag3).toString()} - -
+ + store.state.flag1 || store.state.flag2 || store.state.flag3 ={" "} + {(app.flag1 || app.flag2 || app.flag3).toString()} +
) } diff --git a/examples/short-circuit/components/StoreState.tsx b/examples/short-circuit/components/StoreState.tsx index fa227eb..e40fa7d 100644 --- a/examples/short-circuit/components/StoreState.tsx +++ b/examples/short-circuit/components/StoreState.tsx @@ -10,13 +10,13 @@ export function StoreState() { return (
- state.flags.flag1: {state.flags.flag1.toString()} + store.state.flag1: {state.flag1.toString()}
- state.flags.flag2: {state.flags.flag2.toString()} + store.state.flag2: {state.flag2.toString()}
- state.flags.flag3: {state.flags.flag3.toString()} + store.state.flag3: {state.flag3.toString()}
) diff --git a/examples/short-circuit/store/index.ts b/examples/short-circuit/store/index.ts index 69a6399..232a51c 100644 --- a/examples/short-circuit/store/index.ts +++ b/examples/short-circuit/store/index.ts @@ -2,6 +2,4 @@ import { Arbor } from "@arborjs/store" import { ShortCircuitApp } from "./models/ShortCircuitApp" -export const store = new Arbor({ - flags: new ShortCircuitApp(), -}) +export const store = new Arbor(new ShortCircuitApp()) From a1cdf97fedbfdecf3f75fe25abb221cf667c34ae Mon Sep 17 00:00:00 2001 From: Diego Borges Date: Sat, 12 Oct 2024 17:18:41 -0300 Subject: [PATCH 16/16] Increase test coverage --- .../cypress/component/SimpleTodoList.cy.tsx | 33 +++++++++++++++++++ examples/cypress/component/TodoList.cy.tsx | 25 ++++++++++++++ examples/react-todo/SimpleApp.tsx | 24 ++++++++++++++ .../react-todo/components/SimpleTodoList.tsx | 17 ++++++++++ examples/react-todo/components/TodoView.tsx | 2 +- .../arbor-store/tests/scoping/array.test.ts | 15 +++++++++ 6 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 examples/cypress/component/SimpleTodoList.cy.tsx create mode 100644 examples/react-todo/SimpleApp.tsx create mode 100644 examples/react-todo/components/SimpleTodoList.tsx diff --git a/examples/cypress/component/SimpleTodoList.cy.tsx b/examples/cypress/component/SimpleTodoList.cy.tsx new file mode 100644 index 0000000..9417e17 --- /dev/null +++ b/examples/cypress/component/SimpleTodoList.cy.tsx @@ -0,0 +1,33 @@ +import React from "react" + +import { TodoList, store } from "../../react-todo/store/useTodos" +import SimpleTodoListApp from "../../react-todo/SimpleApp" + +describe("SimpleTodoList", () => { + beforeEach(() => { + store.setState(new TodoList()) + }) + + it("preserves list items references within store upon removal", () => { + cy.mount() + + cy.findByTestId("add-todo-input").type("Clean the house") + cy.findByText("Add").click() + + cy.findByTestId("add-todo-input").type("Walk the dogs") + cy.findByText("Add") + .click() + .then(() => { + const todo1 = store.state[0] + const todo2 = store.state[1] + + cy.findByTestId(`todo-${todo1.id}`).within(() => { + cy.findByText("Delete").click() + }) + + cy.findByTestId(`todo-${todo2.id}`).within(() => { + cy.findByText("Delete").click() + }) + }) + }) +}) diff --git a/examples/cypress/component/TodoList.cy.tsx b/examples/cypress/component/TodoList.cy.tsx index 5f71e37..c57ca69 100644 --- a/examples/cypress/component/TodoList.cy.tsx +++ b/examples/cypress/component/TodoList.cy.tsx @@ -79,4 +79,29 @@ describe("TodoList", () => { cy.findByText("0 of 1 completed").should("exist") }) }) + + it("preserves list items references within store upon removal", () => { + cy.mount() + + cy.findByTestId("add-todo-input").type("Clean the house") + cy.findByText("Add").click() + + cy.findByTestId("add-todo-input").type("Walk the dogs") + cy.findByText("Add").click() + + cy.findByText("All") + .click() + .then(() => { + const todo1 = store.state[0] + const todo2 = store.state[1] + + cy.findByTestId(`todo-${todo1.id}`).within(() => { + cy.findByText("Delete").click() + }) + + cy.findByTestId(`todo-${todo2.id}`).within(() => { + cy.findByText("Delete").click() + }) + }) + }) }) diff --git a/examples/react-todo/SimpleApp.tsx b/examples/react-todo/SimpleApp.tsx new file mode 100644 index 0000000..5d4bdfd --- /dev/null +++ b/examples/react-todo/SimpleApp.tsx @@ -0,0 +1,24 @@ +import React from "react" + +import Summary from "./components/Summary" +import NewTodoForm from "./components/NewTodoForm" +import SimpleTodoList from "./components/SimpleTodoList" + +import "./index.css" + +export default function SimpleApp() { + return ( +
+

todos

+ +
+ + + +
+ +
+
+
+ ) +} diff --git a/examples/react-todo/components/SimpleTodoList.tsx b/examples/react-todo/components/SimpleTodoList.tsx new file mode 100644 index 0000000..e79de57 --- /dev/null +++ b/examples/react-todo/components/SimpleTodoList.tsx @@ -0,0 +1,17 @@ +import React, { memo } from "react" +import { useArbor } from "@arborjs/react" + +import { TodoList, store } from "../store/useTodos" +import TodoView from "./TodoView" + +export default memo(function SimpleTodoList() { + const todos = useArbor(store) as TodoList + + return ( +
+ {todos.map((todo) => ( + + ))} +
+ ) +}) diff --git a/examples/react-todo/components/TodoView.tsx b/examples/react-todo/components/TodoView.tsx index 53be51a..9303351 100644 --- a/examples/react-todo/components/TodoView.tsx +++ b/examples/react-todo/components/TodoView.tsx @@ -10,7 +10,7 @@ export interface TodoProps { export default memo(function TodoView({ id }: TodoProps) { const [editing, setEditing] = useState(false) - const todo = useArbor(store.state.find(t => t.uuid === id)!) + const todo = useArbor(store.state.find((t) => t.uuid === id)!) return (
{ describe("Symbol.iterator", () => { @@ -389,4 +390,18 @@ describe("Array", () => { expect(subscriber).toHaveBeenCalledTimes(2) }) }) + + it("preserves tree links when deleting items via detach", () => { + const state = [{ id: 1 }, { id: 2 }] + + const store = new Arbor(state) + const scoped = new ScopedStore(store) + const item1 = scoped.state[0] + const item2 = scoped.state[1] + + detach(item1) + detach(item2) + + expect(store.state).toEqual([]) + }) })