diff --git a/examples/cypress/component/ShortCircuit.cy.tsx b/examples/cypress/component/ShortCircuit.cy.tsx new file mode 100644 index 00000000..92cb9587 --- /dev/null +++ b/examples/cypress/component/ShortCircuit.cy.tsx @@ -0,0 +1,91 @@ +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() + + 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("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("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("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("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("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("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/cypress/component/SimpleTodoList.cy.tsx b/examples/cypress/component/SimpleTodoList.cy.tsx new file mode 100644 index 00000000..9417e172 --- /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 5f71e37f..c57ca69e 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-counter/Counter.tsx b/examples/react-counter/Counter.tsx index c3ac1b88..4db0f61f 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/SimpleApp.tsx b/examples/react-todo/SimpleApp.tsx new file mode 100644 index 00000000..5d4bdfd7 --- /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/NewTodoForm.tsx b/examples/react-todo/components/NewTodoForm.tsx index 7e5c853b..f64d9234 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/components/SimpleTodoList.tsx b/examples/react-todo/components/SimpleTodoList.tsx new file mode 100644 index 00000000..e79de579 --- /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 53be51a4..9303351a 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 (
+ + + + + + ) +} diff --git a/examples/short-circuit/components/Actions.tsx b/examples/short-circuit/components/Actions.tsx new file mode 100644 index 00000000..7c582e34 --- /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 00000000..e7630bdf --- /dev/null +++ b/examples/short-circuit/components/AreAllFlagsTrue.tsx @@ -0,0 +1,17 @@ +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 ( + + + 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 new file mode 100644 index 00000000..29d10c64 --- /dev/null +++ b/examples/short-circuit/components/AreThereAnyFlagsTrue.tsx @@ -0,0 +1,17 @@ +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 ( + + + store.state.flag1 || store.state.flag2 || store.state.flag3 ={" "} + {(app.flag1 || app.flag2 || app.flag3).toString()} + + + ) +} diff --git a/examples/short-circuit/components/Highlight.tsx b/examples/short-circuit/components/Highlight.tsx new file mode 100644 index 00000000..0b8ede1d --- /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 00000000..e40fa7da --- /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 ( + +
+ store.state.flag1: {state.flag1.toString()} +
+
+ store.state.flag2: {state.flag2.toString()} +
+
+ store.state.flag3: {state.flag3.toString()} +
+
+ ) +} diff --git a/examples/short-circuit/hooks/useAnimatedClassName.ts b/examples/short-circuit/hooks/useAnimatedClassName.ts new file mode 100644 index 00000000..928a9fb8 --- /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 00000000..232a51c2 --- /dev/null +++ b/examples/short-circuit/store/index.ts @@ -0,0 +1,5 @@ +import { Arbor } from "@arborjs/store" + +import { ShortCircuitApp } from "./models/ShortCircuitApp" + +export const store = new Arbor(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 00000000..144efea3 --- /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 00000000..fd087892 --- /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; +} diff --git a/packages/arbor-react/src/index.ts b/packages/arbor-react/src/index.ts index ba08b278..78a7ed29 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" diff --git a/packages/arbor-react/tests/matchers/index.ts b/packages/arbor-react/tests/matchers/index.ts index eca36c3a..f836acab 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 0f8b2536..ec73ec34 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 73c944c2..e4ea4684 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 e6463b2f..a5131cef 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/guards.ts b/packages/arbor-store/src/guards.ts index c25617a4..263d0e7b 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 77f9822a..719a6227 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/default.ts b/packages/arbor-store/src/handlers/default.ts index 7d413534..b6dd328f 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, @@ -38,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/handlers/map.ts b/packages/arbor-store/src/handlers/map.ts index 65581735..a3e5b881 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,11 +9,17 @@ export class MapHandler extends DefaultHandler< return value instanceof Map } - *[Symbol.iterator]() { - if (!isNode>(this)) throw new NotAnArborNodeError() - - for (const entry of this.entries()) { - yield entry + /** + * 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 9699d1cf..aa157e4f 100644 --- a/packages/arbor-store/src/scoping/scope.ts +++ b/packages/arbor-store/src/scoping/scope.ts @@ -1,20 +1,19 @@ -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, 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)) } @@ -22,32 +21,57 @@ 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 + // 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) { - // 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.metadata.operation !== "set") { 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) { + // Notify all listeners if the root of the store is replaced + if ( + event.mutationPath.isRoot() && + event.metadata.operation === "set" && + event.metadata.props.length === 0 + ) { return true } @@ -60,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) } } @@ -91,15 +115,46 @@ export class Scope { const getOrCache = this.getOrCache.bind(this) return new Proxy(node, { - get(target, prop: string, proxy) { - if (prop === "$tracked") { + get(target: Node, prop, proxy) { + // TODO: Rename $scoped to Symbol.for("ArborScoped") + if (prop === "$scoped") { return true } + // Exposes the node wrapped by the proxy if (prop === "$value") { return target } + // 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 + } + } + } + + // 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) + + return isNode(child) ? getOrCache(child) : child + } + } + const child = Reflect.get(target, prop, proxy) const descriptor = Object.getOwnPropertyDescriptor(target, prop) @@ -134,23 +189,12 @@ export class Scope { if ( isNode(target) && !isGetter(target, prop as string) && - !isDetachedProperty(target, prop) + !isDetachedProperty(target, prop as string) ) { 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 } diff --git a/packages/arbor-store/src/types.ts b/packages/arbor-store/src/types.ts index a0374fc7..b0c5f29f 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/handlers/array.test.ts b/packages/arbor-store/tests/handlers/array.test.ts new file mode 100644 index 00000000..27a83ab4 --- /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 00000000..f3b239fa --- /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 00000000..aa9db413 --- /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/matchers/index.ts b/packages/arbor-store/tests/matchers/index.ts index fa56a998..34e07308 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 00000000..c4244c8f --- /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 2c6c45dd..657bb721 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 e5526341..00000000 --- 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 new file mode 100644 index 00000000..f7b97312 --- /dev/null +++ b/packages/arbor-store/tests/scoping/array.test.ts @@ -0,0 +1,407 @@ +import { describe, expect, it, vi } from "vitest" +import { Arbor } from "../../src/arbor" +import { ScopedStore } from "../../src/scoping/store" +import { detach } from "../../src/utilities" + +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({ + 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.splice(1, 1) + + expect(scoped.state).toEqual({ + todos: [ + { id: 1, text: "Do the dishes" }, + { id: 3, text: "Clean the house" }, + ], + }) + + scoped.state.todos.splice(1, 1) + + expect(scoped.state).toEqual({ + todos: [{ 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) + + // 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) + }) + }) + + 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 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) + + delete scoped.state.todos[1] + delete scoped.state.todos[1] + + expect(scoped).not.toBeScoping(scoped.state.todos, 1) + + 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([]) + }) +}) 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 00000000..51bef6b4 --- /dev/null +++ b/packages/arbor-store/tests/scoping/map.test.ts @@ -0,0 +1,69 @@ +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).toBeScopedNode() + expect(scopedNode2).toBeScopedNode() + }) + }) + + 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("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(unwrap(list[0][1])).toBeNodeOf(alice) + expect(unwrap(list[1][1])).toBeNodeOf(bob) + 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 fd9fa616..25cf0d7e 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" @@ -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).toEqual(store.state) + 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).toEqual(store.state.todos[0]) + 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", () => { @@ -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).toBeScoping(scoped.state, "flag1") + expect(scoped).not.toBeScoping(scoped.state, "flag2") + expect(subscriber).not.toHaveBeenCalled() + + store.state.flag1 = false + + expect(subscriber).toHaveBeenCalled() + }) }) diff --git a/packages/arbor-store/tests/vitest.d.ts b/packages/arbor-store/tests/vitest.d.ts index b8ac386b..eda6a341 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