Repository navigation
Conversation
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
force-pushed
the
claude/hopeful-mayer-34f9ex
branch
from
October 5, 2026 00:38
f9b8aed to
d4c18c6
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #119.
Problem
If you assign a
DOMExceptioninto a tree, readingerror.messagethrows. Browsers say "Illegal invocation" and Node saysERR_INVALID_THIS. Retree proxies the exception, and its accessors brand-checkthis. In Neo Compose this took down a whole page when aDataCloneErrorfromWorker.postMessagewas rendered.DOMException isn't the only object affected. I stored each of these in a tree on
mainand read it back:mainDOMException.messageValue of "this" must be of DOMExceptionURL.href,.toString()Receiver must be an instance of class URLRegExp.source,.test()Uint8Array.lengthArrayBuffer.byteLengthPromise.then()WeakMap.has()AbortController.signalCannot read private member #signalHeaders.get()Cannot read private member #headersListError.messagemessageis 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.tsaddsisOpaqueBuiltin(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 coversAbortSignal, DOM nodes andWorker),AbortController,Headers,Request,ResponseandFormData. Constructors that don't exist in the current runtime are skipped. Subclasses match through the chain.isLeafObject(value)isObject.isFrozen(value) || isOpaqueBuiltin(value). It replaces the bareObject.isFrozencheck 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 checkisOpaqueBuiltintoo.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.@ignoreread path still checks only for frozen objects. Built-ins likeAbortSignalandURLcan 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,sourceandlength. Methods would still break, because Retree binds methods to the proxy, sourl.toString(),regex.test(),promise.then()andweakMap.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 implementsDOMException,URL,AbortControllerandHeadersin 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:
Errorand userErrorsubclasses. Their fields are own data properties, so proxying works and stays reactive.DOMExceptionmatches beforeErrorin its prototype chain.ReactiveNode.Performance
resolveStoredObjectalready 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 cachedmap.getand Set iteration are unaffected.WeakMap. A megamorphic micro-benchmark over plain objects, class instances, arrays and Maps measured about 14 ns forObject.isFrozenand about 45 ns forisLeafObject. The extra ~30 ns only applies to writes or first materialization, and each write costs about 2–4 µs.array.push,map.setfollowed by a firstget, and constructingReactiveNoderoots 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.Tests
builtin-leaves.spec.tsruns in both thecoreandcore-compiledprojects. All of its tests fail onmainand pass with this change. They cover:array.push, a Map value, a Set member, and aReactiveNodeconstructor field. These useURL,RegExp,Uint8Array,FileandAbortController.Retree.rootrejection.Checks run:
npm run build:packages,npm run typecheck,npm run lint:architecture,npm run lint:packagesandnpm run doctorall 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.