Conversation
Three distinct problems caused Reactor arrays to be slow: 1. sort() was O(n² log n) instead of O(n log n) Array.prototype.sort() performs O(n log n) element swaps. Each swap goes through the defineProperty trap, which called trigger(), which unconditionally called Reflect.ownKeys() — an O(n) scan — then built two full Sets to compare them. The result was O(n) work per swap: O(n log n) swaps × O(n) per swap = O(n² log n) overall. For n=5000 this was ~6800ms; after the fix it is ~80ms. 2. forEach/map had high constant-factor overhead outside observers Every element read through the get trap created or looked up a Signal, called signalCore.read(), and checked the dependency stack, even when no observer was tracking the read. For large arrays this added up to a ~10–15× overhead over a direct array access. 3. ownKeys check was coupled into the public batch() API The initial fix for (1) deferred the ownKeys scan to the end of each batch() call, which required batch() to know about pendingOwnKeyChecks — an internal reactor concern that had no place in a public utility. --- How the new system works --- checkReactorOwnKeys(reactorCore) [new top-level helper] Computes the current key set of a reactor's source, compares it against the previously stored set using a Set-difference check, and writes to selfSignal only if the set actually changed. Used in two places: the apply trap's flush loop and trigger()'s synchronous fallback. pendingOwnKeyChecks [global, null when no apply trap is active] A Set of reactor cores that need their ownKeys checked before observers fire. Non-null only while an apply trap's batch execute is running. null is the signal to trigger() that no apply trap is in the call stack. apply trap [owns the pendingOwnKeyChecks lifecycle] On entry to its batch execute it saves the current pendingOwnKeyChecks (handles nested apply traps, e.g. a map callback that calls sort) and installs a fresh empty Set. The native method runs; every trigger() call during that method finds a non-null Set and defers into it rather than computing ownKeys immediately. A try/finally then iterates the Set and calls checkReactorOwnKeys() once per unique reactor — still inside batch's execute phase, so before the observer loop runs. The saved Set is restored on exit, keeping the lifecycles properly nested. get trap [fast path when no observer is watching] If dependencyStack.length === 0 no observer is tracking reads, so the entire signal machinery (lazy Signal creation, signalCore lookup, read registration) can be skipped. The raw value is resolved and returned directly (wrapped in a Reactor if it is an object). The slow path with full signal tracking is taken only when an observer is present. trigger() [defers or checks synchronously] After updating the per-property getSignal and hasSignal, it checks pendingOwnKeyChecks. If non-null (apply trap is active), it adds the reactor core to the set and returns — O(1), deduplicated automatically by Set semantics. If null (direct property write, or a user-level batch() call with no apply trap), it calls checkReactorOwnKeys() immediately so that ownKeys-watching observers are still notified. batch() [unchanged from its original form] The public API knows nothing about ownKeys. It only manages the batcher Set and the observer trigger loop. All ownKeys logic lives in the apply trap and trigger(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. Signal.write() — eliminate throwaway Array allocation
Array.from(this.dependents).forEach(...) was called on every signal
write, allocating a temporary Array purely to iterate it once. The
only reason for the snapshot was to allow safe modification of the
batcher Set while iterating — but batcher is a *different* Set from
this.dependents, so the snapshot was never necessary. Replaced with
a plain for...of loop directly over the Set.
2. Object.create(null) for getSignals and hasSignals
Both maps were plain {} objects, so accessing a key like "constructor"
or "toString" would traverse the prototype chain and find Object
prototype properties instead of returning undefined. The workaround
was Object.prototype.hasOwnProperty.call(map, key) on every read —
a long call chain for every property access. Switching both to
Object.create(null) gives prototype-free maps, so a simple truthiness
check (!map[key]) is sufficient and safe.
3. trigger() — lazy Reflect.get / Reflect.has
trigger() previously pre-computed getValue and hasValue unconditionally,
paying for two Reflect calls on every property write even when neither
getSignals[property] nor hasSignals[property] existed (i.e. nobody was
watching that property). Moved both inside their respective if-guards so
they are only evaluated when the corresponding signal is actually present.
For sort() on an unwatched array this saves O(n log n) Reflect calls.
4. has trap — fast path when no observer is active
The `in` operator went through full signal machinery (lazy Signal
creation, signalCore lookups, read registration) even outside any
observer context. Added dependencyStack.length === 0 early return that
delegates straight to Reflect.has, matching the pattern already used by
the get trap. Measured improvement: 31ms → 7ms for 100k checks.
5. ownKeys trap — fast path when no observer is active
Object.keys() / for…in / Reflect.ownKeys() on a Reactor was
going through selfSignal machinery on every call regardless of whether
any observer was watching, making it 1731ms for 100k calls. Added the
same dependencyStack.length === 0 fast path. Measured improvement:
1731ms → 204ms (the residual cost is the Proxy runtime's internal
getOwnPropertyDescriptor calls for enumerability filtering, which are
unavoidable without adding a getOwnPropertyDescriptor trap).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- apply handler: nested try structure (HEAD) + flat() fix (master) - get handler: keep HEAD's fast-path optimization, drop master's pre-optimization signal instantiation - hasSignals: keep HEAD's Object.create(null) null-prototype approach - has handler: keep HEAD's plain property access (safe with null-prototype) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The outer try-finally and inner try-catch had identical behavior to a single try-catch-finally, since finally runs unconditionally regardless of whether the catch returns or re-throws. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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.
No description provided.