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 (
+
+
+
+
+
+
+ )
+}
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