Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/builtin-leaves.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@retreejs/core": patch
---

Built-ins that keep their state in internal slots are now leaves, so reading them from a tree works.

A `DOMException` stored in a tree threw on `error.message` ("Illegal invocation" in browsers, `ERR_INVALID_THIS` in Node) because Retree proxied it and its accessors reject a proxy as `this`. The same broke `URL`, `Blob`, `RegExp`, typed arrays, `AbortController`, `Promise`, and other platform objects. Retree now stores these as-is, like frozen objects: reads return the object itself, and assigning a new one notifies. `Retree.root` rejects them because a root must be a node.
16 changes: 15 additions & 1 deletion packages/retree-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Use this as a quick map before choosing an API:
- [`Retree.managed`](#read-fast-with-raw) resolves a raw value back to its managed node. Use it to recover the write surface after a raw scan or from a change payload.
- [`Retree.peekInto`](#read-fast-with-raw) runs a read-only query against a node's raw object and resolves the result to its managed node when one exists.
- [`Retree.untracked`](#read-fast-with-raw) pauses dependency tracking during a synchronous callback. Use it for bulk reads inside tracked selectors and memo getters.
- [Frozen objects](#immutable-leaves) are immutable leaves: stored and returned as-is, never proxied. Use `Object.freeze` for server snapshots, sentinels, and constants that you replace wholesale.
- [Frozen objects](#immutable-leaves) are immutable leaves: stored and returned as-is, never proxied. Use `Object.freeze` for server snapshots, sentinels, and constants that you replace wholesale. Built-ins such as `DOMException`, `URL`, and `Blob` are leaves too.
- [`Retree.version` and `Retree.treeVersion`](#node-identity-and-versions) return a node's own-field or subtree version as a number. Use them as cache keys instead of holding node identities.
- [`Retree.move`](#move-link-or-clone-existing-nodes) transfers an existing node to a new structural parent. Use it when ownership should change.
- [`Retree.link` and `@link`](#move-link-or-clone-existing-nodes) store a reactive pointer to a node without reparenting it. Use it for selected items and cross-references.
Expand Down Expand Up @@ -460,6 +460,20 @@ shared across records, a config constant. Reads are raw-speed and the tree
allocates nothing for the subtree. Freeze before storing; `Retree.root`
rejects a frozen object because a root must be a node.

Built-ins that keep their state in internal slots are leaves too, frozen or
not: `DOMException`, `URL`, `URLSearchParams`, `Blob` and `File`, `RegExp`,
`Promise`, `WeakMap`, `WeakSet`, `WeakRef`, `ArrayBuffer`, `DataView`, typed
arrays, `EventTarget` (including `AbortSignal` and DOM nodes),
`AbortController`, `Headers`, `Request`, `Response`, and `FormData`. Their
methods and accessors reject a proxy as `this`, so Retree stores them as-is.
Retree cannot see changes inside them; assign a new one to notify.

```ts
const upload = Retree.root({ error: null as Error | null });
upload.error = new DOMException("could not be cloned", "DataCloneError");
upload.error.message; // "could not be cloned"
```

## Reactive dependencies

`ReactiveNode.dependencies` lets one node emit `nodeChanged` when another node changes. Use it when a node exposes derived state but you do not want broad `treeChanged` subscriptions.
Expand Down
7 changes: 7 additions & 0 deletions packages/retree-core/src/Retree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
setManagedKey,
} from "./internals/proxy.js";
import { getStructureVersion } from "./internals/snapshot-version.js";
import { isOpaqueBuiltin } from "./internals/leaves.js";
import {
deleteReactiveDependencies,
deleteReactiveDependent,
Expand Down Expand Up @@ -424,6 +425,12 @@ export class Retree {
"Retree.root: frozen objects are immutable leaves and cannot become a root. Pass a mutable object, or store the frozen object in a field of a mutable root."
);
}
if (isOpaqueBuiltin(object)) {
// @retree-throws
throw new Error(
`Retree.root: a ${object.constructor.name} is a built-in leaf and cannot become a root. Store it in a field of a mutable root.`
);
}
return buildProxy<T>(object, this.nodeChangeEmitter);
}

Expand Down
73 changes: 73 additions & 0 deletions packages/retree-core/src/builtin-leaves.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*!
* Copyright (c) Ryan Bliss. All rights reserved.
* Licensed under the MIT License.
*/

import { describe, expect, it, vi } from "vitest";
import { Retree } from "./Retree.js";
import { ReactiveNode } from "./ReactiveNode.js";

describe("built-ins that cannot work behind a proxy are leaves", () => {
it("reads a DOMException assigned into a tree (#119)", () => {
const state = Retree.root({ error: null as Error | null });
const error = new DOMException("could not be cloned", "DataCloneError");
state.error = error;
expect(state.error).toBe(error);
expect(state.error.message).toBe("could not be cloned");
expect(state.error.name).toBe("DataCloneError");
expect(Retree.isNode(state.error)).toBe(false);
});

it("keeps built-ins usable from every storage path", () => {
class Holder {
public url = new URL("https://retree.dev/a");
}
const pattern = /a+/g;
const bytes = new Uint8Array([1, 2, 3]);
const file = new File(["abc"], "a.txt");
const controller = new AbortController();
const root = Retree.root({
holder: new Holder(),
list: [pattern],
controllers: [] as AbortController[],
map: new Map([["bytes", bytes]]),
set: new Set([file]),
});
root.controllers.push(controller);

expect(root.holder.url.toString()).toBe("https://retree.dev/a");
expect(root.list[0]).toBe(pattern);
expect(root.list[0]?.test("aa")).toBe(true);
expect(root.map.get("bytes")?.length).toBe(3);
expect([...root.set][0]?.size).toBe(3);
expect(root.controllers[0]).toBe(controller);
expect(root.controllers[0]?.signal.aborted).toBe(false);
});

it("re-runs tracked readers when the leaf is replaced", () => {
const root = Retree.root({ error: new DOMException("first") });
const callback = vi.fn();
const stop = Retree.select(() => root.error.message, callback);
root.error = new DOMException("second");
expect(callback).toHaveBeenCalledWith("second", "first");
stop();
});

it("stores a built-in assigned in a ReactiveNode constructor raw", () => {
class Upload extends ReactiveNode {
public signal = new AbortController().signal;
get dependencies() {
return [];
}
}
const upload = Retree.root(new Upload());
expect(upload.signal).toBe(Retree.raw(upload).signal);
expect(upload.signal.aborted).toBe(false);
});

it("rejects a built-in root", () => {
expect(() => Retree.root(new URL("https://retree.dev"))).toThrow(
"Retree.root: a URL is a built-in leaf and cannot become a root. Store it in a field of a mutable root."
);
});
});
88 changes: 88 additions & 0 deletions packages/retree-core/src/internals/leaves.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*!
* Copyright (c) Ryan Bliss. All rights reserved.
* Licensed under the MIT License.
*/

/**
* Built-ins whose state lives in internal slots or private fields. Their
* methods and accessors brand-check `this`, so a proxy receiver throws
* ("Illegal invocation"). Map, Set, and Date have the same constraint but
* are proxied with dedicated method wrappers instead.
*/
const OPAQUE_CONSTRUCTOR_NAMES = [
"ArrayBuffer",
"SharedArrayBuffer",
"DataView",
"RegExp",
"Promise",
"WeakMap",
"WeakSet",
"WeakRef",
"DOMException",
"URL",
"URLSearchParams",
"Blob",
"EventTarget",
"AbortController",
"Headers",
"Request",
"Response",
"FormData",
] as const;

const opaquePrototypes = collectOpaquePrototypes();
const opaqueByPrototype = new WeakMap<object, boolean>();

function collectOpaquePrototypes(): ReadonlySet<object> {
// %TypedArray%.prototype covers every typed array.
const prototypes = new Set<object>([
Object.getPrototypeOf(Uint8Array.prototype),
]);
for (const name of OPAQUE_CONSTRUCTOR_NAMES) {
const ctor: unknown = Reflect.get(globalThis, name);
if (typeof ctor !== "function") continue;
const prototype: unknown = Reflect.get(ctor, "prototype");
if (typeof prototype === "object" && prototype !== null) {
prototypes.add(prototype);
}
}
return prototypes;
}

function inheritsOpaquePrototype(prototype: object): boolean {
for (
let current: object | null = prototype;
current !== null;
current = Object.getPrototypeOf(current)
) {
if (opaquePrototypes.has(current)) return true;
}
return false;
}

/**
* Whether `value` is a built-in that cannot work behind a proxy, such as a
* `DOMException`, `URL`, `Blob`, `RegExp`, or typed array. Decided once per
* prototype.
*/
export function isOpaqueBuiltin(value: object): boolean {
const prototype: object | null = Object.getPrototypeOf(value);
if (prototype === Object.prototype) return false;
if (prototype === Array.prototype) return false;
if (prototype === null) return false;
let opaque = opaqueByPrototype.get(prototype);
if (opaque === undefined) {
opaque = inheritsOpaquePrototype(prototype);
opaqueByPrototype.set(prototype, opaque);
}
return opaque;
}

/**
* A leaf is stored and returned as-is, never proxied, and replacing it is
* the change: a frozen object, or a built-in that cannot work behind a proxy.
*/
export function isLeafObject(value: object): boolean {
if (Object.isFrozen(value)) return true;
return isOpaqueBuiltin(value);
}
17 changes: 13 additions & 4 deletions packages/retree-core/src/internals/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
trackDependencyPropertyWrite,
} from "./dependency-tracking.js";
import { Transactions } from "./transactions.js";
import { isLeafObject, isOpaqueBuiltin } from "./leaves.js";
import { createRegisteredHandler } from "./handler-factories.js";
import { bumpGlobalWriteVersion } from "./write-version.js";
import { prepareSnapshotParentChange } from "./snapshot-version.js";
Expand Down Expand Up @@ -823,7 +824,7 @@ export class BaseProxyHandler<T extends TreeNode>
prop,
reparentProxy(newValue, this, prop)
);
} else if (Object.isFrozen(newValue)) {
} else if (isLeafObject(newValue)) {
deleteProxiedChild(this, prop);
} else if (
getManagedProxyForUnproxiedNode(newValue) !== undefined
Expand Down Expand Up @@ -1333,7 +1334,7 @@ function adoptStoredValue(
if (storedHandler === undefined && hasLazilyProxiedShape(storedValue)) {
return;
}
if (Object.isFrozen(storedValue)) {
if (isLeafObject(storedValue)) {
return;
}
const value: object =
Expand Down Expand Up @@ -1626,7 +1627,7 @@ function preparePropertyValue(
deleteProxiedChild(proxyHandler, prop);
return value;
}
if (Object.isFrozen(value)) {
if (isLeafObject(value)) {
deleteProxiedChild(proxyHandler, prop);
return value;
}
Expand Down Expand Up @@ -1847,6 +1848,10 @@ function getOrCreateMapValueProxy(
if (cached !== undefined && getUnproxiedNodeFromProxy(cached) === value) {
return cached;
}
// After the cache so a cached child read skips the prototype check.
if (isOpaqueBuiltin(value)) {
return value;
}
const valueToRead = createStructuralProxyForValue(
value,
handler,
Expand Down Expand Up @@ -2127,6 +2132,10 @@ function getOrCreateSetValueProxy(
if (cached !== undefined) {
return cached;
}
// After the cache so a cached child read skips the prototype check.
if (isOpaqueBuiltin(value)) {
return value;
}
const valueToRead = createStructuralProxyForValue(
value,
handler,
Expand Down Expand Up @@ -2377,7 +2386,7 @@ function prepareInsertedArrayValue(
deleteProxiedChild(handler, propName);
return value;
}
if (Object.isFrozen(value)) {
if (isLeafObject(value)) {
deleteProxiedChild(handler, propName);
return value;
}
Expand Down
16 changes: 15 additions & 1 deletion skills/retree/references/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Use this as a quick map before choosing an API:
- [`Retree.managed`](#read-fast-with-raw) resolves a raw value back to its managed node. Use it to recover the write surface after a raw scan or from a change payload.
- [`Retree.peekInto`](#read-fast-with-raw) runs a read-only query against a node's raw object and resolves the result to its managed node when one exists.
- [`Retree.untracked`](#read-fast-with-raw) pauses dependency tracking during a synchronous callback. Use it for bulk reads inside tracked selectors and memo getters.
- [Frozen objects](#immutable-leaves) are immutable leaves: stored and returned as-is, never proxied. Use `Object.freeze` for server snapshots, sentinels, and constants that you replace wholesale.
- [Frozen objects](#immutable-leaves) are immutable leaves: stored and returned as-is, never proxied. Use `Object.freeze` for server snapshots, sentinels, and constants that you replace wholesale. Built-ins such as `DOMException`, `URL`, and `Blob` are leaves too.
- [`Retree.version` and `Retree.treeVersion`](#node-identity-and-versions) return a node's own-field or subtree version as a number. Use them as cache keys instead of holding node identities.
- [`Retree.move`](#move-link-or-clone-existing-nodes) transfers an existing node to a new structural parent. Use it when ownership should change.
- [`Retree.link` and `@link`](#move-link-or-clone-existing-nodes) store a reactive pointer to a node without reparenting it. Use it for selected items and cross-references.
Expand Down Expand Up @@ -465,6 +465,20 @@ shared across records, a config constant. Reads are raw-speed and the tree
allocates nothing for the subtree. Freeze before storing; `Retree.root`
rejects a frozen object because a root must be a node.

Built-ins that keep their state in internal slots are leaves too, frozen or
not: `DOMException`, `URL`, `URLSearchParams`, `Blob` and `File`, `RegExp`,
`Promise`, `WeakMap`, `WeakSet`, `WeakRef`, `ArrayBuffer`, `DataView`, typed
arrays, `EventTarget` (including `AbortSignal` and DOM nodes),
`AbortController`, `Headers`, `Request`, `Response`, and `FormData`. Their
methods and accessors reject a proxy as `this`, so Retree stores them as-is.
Retree cannot see changes inside them; assign a new one to notify.

```ts
const upload = Retree.root({ error: null as Error | null });
upload.error = new DOMException("could not be cloned", "DataCloneError");
upload.error.message; // "could not be cloned"
```

## Reactive dependencies

`ReactiveNode.dependencies` lets one node emit `nodeChanged` when another node changes. Use it when a node exposes derived state but you do not want broad `treeChanged` subscriptions.
Expand Down
Loading