Skip to content

Store slot-backed built-ins like DOMException as leaves - #120

Closed
ryanbliss wants to merge 1 commit into
mainfrom
claude/hopeful-mayer-34f9ex
Closed

ryanbliss wants to merge 1 commit into
mainfrom
claude/hopeful-mayer-34f9ex

Conversation

@ryanbliss

@ryanbliss ryanbliss commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

Fixes #119.

Problem

If you assign a DOMException into a tree, reading error.message throws. Browsers say "Illegal invocation" and Node says ERR_INVALID_THIS. Retree proxies the exception, and its accessors brand-check this. In Neo Compose this took down a whole page when a DataCloneError from Worker.postMessage was rendered.

DOMException isn't the only object affected. I stored each of these in a tree on main and read it back:

Value Read Result on main
DOMException .message ❌ Value of "this" must be of DOMException
URL .href, .toString() ❌ Receiver must be an instance of class URL
RegExp .source, .test() ❌ incompatible receiver
Uint8Array .length ❌ incompatible receiver
ArrayBuffer .byteLength ❌ incompatible receiver
Promise .then() ❌ incompatible receiver
WeakMap .has() ❌ incompatible receiver
AbortController .signal ❌ Cannot read private member #signal
Headers .get() ❌ Cannot read private member #headersList
Error .message ✅ (message is an own data property)

Fix

Built-ins that keep their state in internal slots are now leaves. They work the same way as frozen objects (immutable leaves): Retree stores them raw and returns them by identity, never proxies them, and treats assigning a new one as the change. Tracked reads such as Retree.select(() => root.error.message) re-run when the error is replaced.

  • internals/leaves.ts adds isOpaqueBuiltin(value). It checks whether the value's prototype chain contains a known slot-backed prototype: DOMException, URL, URLSearchParams, Blob/File, RegExp, Promise, WeakMap, WeakSet, WeakRef, ArrayBuffer, SharedArrayBuffer, DataView, %TypedArray%, EventTarget (which covers AbortSignal, DOM nodes and Worker), AbortController, Headers, Request, Response and FormData. Constructors that don't exist in the current runtime are skipped. Subclasses match through the chain.
  • isLeafObject(value) is Object.isFrozen(value) || isOpaqueBuiltin(value). It replaces the bare Object.isFrozen check everywhere Retree decides a value is a leaf: the set trap, attaching a property value, array insertion, and the eager walk over a constructed class instance. Map values and Set members check isOpaqueBuiltin too.
  • Retree.root(new URL(...)) now throws a dedicated error, because a root has to be a node. This mirrors the existing error for frozen roots.
  • The @ignore read path still checks only for frozen objects. Built-ins like AbortSignal and URL can change internally without notifying, so reading one there should still mark the run partial.

Why this approach

The issue suggested two options. This PR takes the first.

Option 2 (call accessors with the raw target as receiver) only covers part of the problem. It fixes getters such as message, source and length. Methods would still break, because Retree binds methods to the proxy, so url.toString(), regex.test(), promise.then() and weakMap.has() would keep throwing. Fixing methods too would need a native-ness check on every read. You can't detect native-ness in general either: Node implements DOMException, URL, AbortController and Headers in JavaScript, so a [native code] check misses the exact case in this issue. Running inherited getters against the raw target would also break dependency tracking inside user-class getters.

Option 1 adds no new mechanism. The frozen-leaf path already has the right behavior: reads return the object itself, writes are ordinary change records, replacing the value notifies, and nothing is allocated for the subtree. A proxy around one of these objects could never observe their internal state anyway, so treating them as leaves doesn't lose any reactivity that worked before. Every read either threw or went straight through to internal slots.

Why a list instead of a heuristic. Because Node implements these classes in JavaScript, no structural check behaves the same in browsers and Node. A list behaves the same in every runtime, is easy to document, and covers the error and file/network objects apps actually put in state.

What stays proxied:

  • Error and user Error subclasses. Their fields are own data properties, so proxying works and stays reactive. DOMException matches before Error in its prototype chain.
  • Map, Set and Date, which already have dedicated wrappers.
  • User classes and ReactiveNode.

Performance

  • Hot read paths are unchanged. Reads of cached children, plain objects and arrays don't run the check. resolveStoredObject already returned non-plain values as-is. In Map and Set reads, the check runs only after a cache miss, right before Retree would build a proxy, so cached map.get and Set iteration are unaffected.
  • The check is cheap and bounded. Plain objects and arrays exit on a prototype identity compare. Any other prototype is resolved once and cached in a WeakMap. A megamorphic micro-benchmark over plain objects, class instances, arrays and Maps measured about 14 ns for Object.isFrozen and about 45 ns for isLeafObject. The extra ~30 ns only applies to writes or first materialization, and each write costs about 2–4 µs.
  • End-to-end runs show no measurable change. I ran an A/B over 100k ops each: assigning plain objects and class instances to a field, array.push, map.set followed by a first get, and constructing ReactiveNode roots with class-instance children. All results were within run-to-run noise. Some cases ran faster after the change, some slower, and none moved consistently.
  • These built-ins are now faster. Instead of a handler, a proxy and a parent edge (when reads didn't throw outright), they now cost nothing.

Tests

builtin-leaves.spec.ts runs in both the core and core-compiled projects. All of its tests fail on main and pass with this change. They cover:

  • The repro from the issue.
  • Every storage path: a class-instance field, an array element, array.push, a Map value, a Set member, and a ReactiveNode constructor field. These use URL, RegExp, Uint8Array, File and AbortController.
  • A tracked reader re-running when the leaf is replaced.
  • The Retree.root rejection.

Checks run:

  • npm run build:packages, npm run typecheck, npm run lint:architecture, npm run lint:packages and npm run doctor all pass.
  • npm run test: 1718/1718 pass.

The core README section on immutable leaves documents the change, and its skill copy (skills/retree/references/core.md) is synced. A changeset is included.

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.
@ryanbliss
ryanbliss force-pushed the claude/hopeful-mayer-34f9ex branch from f9b8aed to d4c18c6 Compare October 5, 2026 00:38
@ryanbliss ryanbliss closed this Oct 5, 2026
@ryanbliss
ryanbliss deleted the claude/hopeful-mayer-34f9ex branch October 5, 2026 00:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reading a DOMException stored in a tree throws Illegal invocation

1 participant