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..3b9846f 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, TraverseCallback } from "../types" import { pathFor } from "../utilities" import { DefaultHandler } from "./default" @@ -21,6 +22,13 @@ export class ArrayHandler extends DefaultHandler< return Array.isArray(value) } + *[Symbol.iterator](cb: TraverseCallback = (n) => n) { + for (const link of this.$value.keys()) { + const child = this[link] + yield isNode(child) ? cb(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..73f5568 100644 --- a/packages/arbor-store/src/handlers/map.ts +++ b/packages/arbor-store/src/handlers/map.ts @@ -1,21 +1,19 @@ -import { NotAnArborNodeError } from "../errors" import { isNode, isProxiable } from "../guards" -import type { Link, Node } from "../types" +import type { Link, Node, TraverseCallback } from "../types" import { DefaultHandler } from "./default" export class MapHandler extends DefaultHandler< - Map + Map > { static accepts(value: unknown) { 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] + *[Symbol.iterator](cb: TraverseCallback = (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) ? cb(child) : child] } } diff --git a/packages/arbor-store/src/scoping/scope.ts b/packages/arbor-store/src/scoping/scope.ts index bd16344..73c3970 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,28 @@ 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 + 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 +182,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..db7c92e 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 TraverseCallback = (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])