Skip to content

Commit

Permalink
Intercept iterator logic when called from scoped node
Browse files Browse the repository at this point in the history
This allows scoped contexts to intercept and track nodes being iterated
so path tracking works as expected.
  • Loading branch information
drborges committed Sep 9, 2024
1 parent 219ae51 commit 4c58fc5
Show file tree
Hide file tree
Showing 8 changed files with 77 additions and 40 deletions.
7 changes: 7 additions & 0 deletions packages/arbor-store/src/guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ export function isNode<T extends object>(value: unknown): value is Node<T> {
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 ||
Expand Down
10 changes: 9 additions & 1 deletion packages/arbor-store/src/handlers/array.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -21,6 +22,13 @@ export class ArrayHandler<T extends object = object> 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])

Expand Down
16 changes: 7 additions & 9 deletions packages/arbor-store/src/handlers/map.ts
Original file line number Diff line number Diff line change
@@ -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<T extends object = object> extends DefaultHandler<
Map<unknown, T>
Map<Link, T>
> {
static accepts(value: unknown) {
return value instanceof Map
}

*[Symbol.iterator]() {
const mapProxy = this.$tree.getNodeFor(this) as unknown as Map<unknown, T>

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<Link, T>
for (const link of this.$value.keys()) {
const child = node.get(link)
yield [link, isNode(child) ? cb(child) : child]
}
}

Expand Down
40 changes: 18 additions & 22 deletions packages/arbor-store/src/scoping/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 extends object = object> = T & {
Expand Down Expand Up @@ -116,7 +116,7 @@ export class Scope<T extends object> {
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
}
Expand All @@ -125,32 +125,28 @@ export class Scope<T extends object> {
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)
Expand Down Expand Up @@ -186,7 +182,7 @@ export class Scope<T extends object> {
if (
isNode(target) &&
!isGetter(target, prop as string) &&
!isDetachedProperty(target, prop)
!isDetachedProperty(target, prop as string)
) {
track(target, prop)
}
Expand Down
1 change: 1 addition & 0 deletions packages/arbor-store/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type ArborNode<T extends object = object> = {

export type Link = string | number
export type Unsubscribe = () => void
export type TraverseCallback = (child: Node) => Node
export type Node<T extends object = object> = T & {
readonly $value: T
readonly $tree: Arbor
Expand Down
26 changes: 26 additions & 0 deletions packages/arbor-store/tests/scoping/array.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
13 changes: 7 additions & 6 deletions packages/arbor-store/tests/scoping/map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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()
})
})
})
4 changes: 2 additions & 2 deletions packages/arbor-store/tests/scoping/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down

0 comments on commit 4c58fc5

Please sign in to comment.