From d4c18c61ac65606150c800c08a1858b8996a2821 Mon Sep 17 00:00:00 2001 From: Ryan Bliss Date: Sun, 4 Oct 2026 22:36:06 +0000 Subject: [PATCH] Store slot-backed built-ins like DOMException as leaves (#119) A DOMException stored in a tree threw on .message because Retree proxied it and its accessors reject a proxy as this. URL, Blob, RegExp, typed arrays, AbortController, Promise, and similar built-ins broke the same way. They now follow the frozen-object leaf path: stored and returned as-is, and replacing one is the change. --- .changeset/builtin-leaves.md | 7 ++ packages/retree-core/README.md | 16 +++- packages/retree-core/src/Retree.ts | 7 ++ .../retree-core/src/builtin-leaves.spec.ts | 73 +++++++++++++++ packages/retree-core/src/internals/leaves.ts | 88 +++++++++++++++++++ packages/retree-core/src/internals/proxy.ts | 17 +++- skills/retree/references/core.md | 16 +++- 7 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 .changeset/builtin-leaves.md create mode 100644 packages/retree-core/src/builtin-leaves.spec.ts create mode 100644 packages/retree-core/src/internals/leaves.ts diff --git a/.changeset/builtin-leaves.md b/.changeset/builtin-leaves.md new file mode 100644 index 0000000..7e95873 --- /dev/null +++ b/.changeset/builtin-leaves.md @@ -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. diff --git a/packages/retree-core/README.md b/packages/retree-core/README.md index c215932..8a0d68f 100644 --- a/packages/retree-core/README.md +++ b/packages/retree-core/README.md @@ -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. @@ -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. diff --git a/packages/retree-core/src/Retree.ts b/packages/retree-core/src/Retree.ts index e5bcaa2..0f34782 100644 --- a/packages/retree-core/src/Retree.ts +++ b/packages/retree-core/src/Retree.ts @@ -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, @@ -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(object, this.nodeChangeEmitter); } diff --git a/packages/retree-core/src/builtin-leaves.spec.ts b/packages/retree-core/src/builtin-leaves.spec.ts new file mode 100644 index 0000000..8a22ce0 --- /dev/null +++ b/packages/retree-core/src/builtin-leaves.spec.ts @@ -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." + ); + }); +}); diff --git a/packages/retree-core/src/internals/leaves.ts b/packages/retree-core/src/internals/leaves.ts new file mode 100644 index 0000000..b896a10 --- /dev/null +++ b/packages/retree-core/src/internals/leaves.ts @@ -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(); + +function collectOpaquePrototypes(): ReadonlySet { + // %TypedArray%.prototype covers every typed array. + const prototypes = new Set([ + 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); +} diff --git a/packages/retree-core/src/internals/proxy.ts b/packages/retree-core/src/internals/proxy.ts index 91a1f4e..9d7364a 100644 --- a/packages/retree-core/src/internals/proxy.ts +++ b/packages/retree-core/src/internals/proxy.ts @@ -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"; @@ -823,7 +824,7 @@ export class BaseProxyHandler prop, reparentProxy(newValue, this, prop) ); - } else if (Object.isFrozen(newValue)) { + } else if (isLeafObject(newValue)) { deleteProxiedChild(this, prop); } else if ( getManagedProxyForUnproxiedNode(newValue) !== undefined @@ -1333,7 +1334,7 @@ function adoptStoredValue( if (storedHandler === undefined && hasLazilyProxiedShape(storedValue)) { return; } - if (Object.isFrozen(storedValue)) { + if (isLeafObject(storedValue)) { return; } const value: object = @@ -1626,7 +1627,7 @@ function preparePropertyValue( deleteProxiedChild(proxyHandler, prop); return value; } - if (Object.isFrozen(value)) { + if (isLeafObject(value)) { deleteProxiedChild(proxyHandler, prop); return value; } @@ -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, @@ -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, @@ -2377,7 +2386,7 @@ function prepareInsertedArrayValue( deleteProxiedChild(handler, propName); return value; } - if (Object.isFrozen(value)) { + if (isLeafObject(value)) { deleteProxiedChild(handler, propName); return value; } diff --git a/skills/retree/references/core.md b/skills/retree/references/core.md index 4500bd1..4a6d449 100644 --- a/skills/retree/references/core.md +++ b/skills/retree/references/core.md @@ -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. @@ -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.