From d9905ff9fbab72c116f47fec721dfdafba856810 Mon Sep 17 00:00:00 2001 From: fynyky Date: Mon, 25 May 2026 18:14:32 +0000 Subject: [PATCH 1/3] Optimise Reactor array performance for sort, forEach, and map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- index.js | 114 +++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 77 insertions(+), 37 deletions(-) diff --git a/index.js b/index.js index e458b7e..d409adc 100644 --- a/index.js +++ b/index.js @@ -26,6 +26,14 @@ const observerCoreExtractor = new WeakMap() // Then clears the batcher again let batcher = null +// Set of reactor cores whose ownKeys need checking before observers fire. +// Owned by the apply trap: each method call (sort, push, …) initialises a +// fresh Set, trigger() adds to it, and the apply trap flushes it once in a +// try/finally — all inside batch's execute, before the observer loop runs. +// null means no apply trap is currently active; trigger() falls back to an +// immediate synchronous check in that case. +let pendingOwnKeyChecks = null + // Cache of objects to their reactor proxies // The same object should always get turned into the same Reactor // This allows for consistent dependency tracking @@ -172,6 +180,26 @@ class Signal extends Function { } } +// Check whether the key set of a reactor has changed and, if so, update its +// selfSignal so that any ownKeys-watching observers are notified. +// Shared by the apply trap (called once per method via pendingOwnKeyChecks) +// and by trigger() when no apply trap is active (direct property writes). +function checkReactorOwnKeys (reactorCore) { + const selfSignalCore = signalCoreExtractor.get(reactorCore.selfSignal) + if (selfSignalCore.dependents.size === 0) return + const currentOwnKeysValue = Reflect.ownKeys(reactorCore.source) + const oldOwnKeysValue = selfSignalCore.value + const currentSet = new Set(currentOwnKeysValue) + const oldSet = new Set(oldOwnKeysValue) + let changed = currentSet.size !== oldSet.size + if (!changed) { + for (const key of currentSet) { + if (!oldSet.has(key)) { changed = true; break } + } + } + if (changed) reactorCore.selfSignal(currentOwnKeysValue) +} + // WeakSet of all Reactors to check if something is a Reactor // Need to implement it this way because you can check instanceof Proxies const Reactors = new WeakSet() @@ -233,6 +261,10 @@ class Reactor { // This allows compound function calls like "Array.push" // to only trigger one round of observer updates return batch(() => { + // Own the pendingOwnKeyChecks lifecycle for this method call. + // Save any outer set (handles nested apply traps), install a fresh + // one so trigger() defers into it, then flush exactly once in the + // finally — still inside batch's execute, so before observers fire. // For native object methods which cant use a Proxy as `this` // try again with the underlying object // Some limitations if the failed attempt has side effects prior to throwing an error @@ -243,21 +275,28 @@ class Reactor { // Also this still wont fix being unable to pass the proxy to static methods // `proxiedMap.keys()` will work because keys gets wrapped by this handler // `Map.prototype.keys.call(proxiedMap)` won't work because it doesnt get wrapped + const savedPendingOwnKeyChecks = pendingOwnKeyChecks + pendingOwnKeyChecks = new Set() try { - return Reflect.apply(this.source, thisArg, argumentsList) - } catch (error) { - if (error.name === 'TypeError' && error.message.includes('called on incompatible receiver #')) { - const core = reactorCoreExtractor.get(thisArg) - if (typeof core !== 'undefined') { - // Note that this.source and core.source are different - // core.source is the underlying object - // this.source is the function which is being called with the object as `this` - return Reflect.apply(this.source, core.source, argumentsList) + try { + return Reflect.apply(this.source, thisArg, argumentsList) + } catch (error) { + if (error.name === 'TypeError' && error.message.includes('called on incompatible receiver #')) { + const core = reactorCoreExtractor.get(thisArg) + if (typeof core !== 'undefined') { + // Note that this.source and core.source are different + // core.source is the underlying object + // this.source is the function which is being called with the object as `this` + return Reflect.apply(this.source, core.source, argumentsList) + } } + // If any other type of error, or if there's nothing to unwrap throw error anyway + // because then its not a problem with Reactor wrapping + throw error } - // If any other type of error, or if there's nothing to unwrap throw error anyway - // because then its not a problem with Reactor wrapping - throw error + } finally { + for (const reactorCore of pendingOwnKeyChecks) checkReactorOwnKeys(reactorCore) + pendingOwnKeyChecks = savedPendingOwnKeyChecks } }) }, @@ -277,18 +316,7 @@ class Reactor { if (descriptor && !descriptor.writable && !descriptor.configurable) { return Reflect.get(this.source, property, receiver) } - // Lazily instantiate accessor signals - this.getSignals[property] = - // Need to use hasOwnProperty instead of a normal get to avoid - // the basic Object prototype properties - // e.g. constructor - Object.prototype.hasOwnProperty.call(this.getSignals, property) - ? this.getSignals[property] - : new Signal() - // User accessor signals to give the actual output - // This enables automatic dependency tracking - const signalCore = signalCoreExtractor.get(this.getSignals[property]) - signalCore.removeSelf = () => delete this.getSignals[property] + // Resolve the raw value first — needed for both paths below const currentValue = (() => { // Handle getters which require hidden/native properties // If putting the proxy as `this` fails then reveal the underlying object @@ -312,6 +340,26 @@ class Reactor { throw error } })() + // Fast path: nothing on the dependency stack means no observer is + // tracking reads right now, so signal machinery is unnecessary. + // This avoids per-element signal creation overhead in forEach/map + // called outside an observer context. + if (dependencyStack.length === 0) { + if (isObject(currentValue)) return new Reactor(currentValue) + return currentValue + } + // Lazily instantiate accessor signals + this.getSignals[property] = + // Need to use hasOwnProperty instead of a normal get to avoid + // the basic Object prototype properties + // e.g. constructor + Object.prototype.hasOwnProperty.call(this.getSignals, property) + ? this.getSignals[property] + : new Signal() + // User accessor signals to give the actual output + // This enables automatic dependency tracking + const signalCore = signalCoreExtractor.get(this.getSignals[property]) + signalCore.removeSelf = () => delete this.getSignals[property] signalCore.value = currentValue return signalCore.read() }, @@ -372,25 +420,17 @@ class Reactor { // This avoids redundant triggering if they were the same const getValue = Reflect.get(this.source, property) const hasValue = Reflect.has(this.source, property) - // For ownKeys you need to manually calculate the set comparison - const currentOwnKeysValue = Reflect.ownKeys(this.source) - const oldOwnKeysValue = signalCoreExtractor.get(this.selfSignal).value - const ownKeysChanged = (() => { - const currentSet = new Set(currentOwnKeysValue) - const oldSet = new Set(oldOwnKeysValue) - if (currentSet.size !== oldSet.size) return true - for (const key of currentSet) { - if (!oldSet.has(key)) return true - } - return false - })() // Batch together to avoid redundant triggering for shared observers // This might be redundant because the only way this happens is by calling native methods // which are already batched anyway. But keeping for safety batch(() => { if (this.getSignals[property]) this.getSignals[property](getValue) if (this.hasSignals[property]) this.hasSignals[property](hasValue) - if (ownKeysChanged) this.selfSignal(currentOwnKeysValue) + // If an apply trap is active it owns pendingOwnKeyChecks and will + // flush once after the whole method finishes (O(1) per write). + // Otherwise (direct property write, user-level batch()) check now. + if (pendingOwnKeyChecks !== null) pendingOwnKeyChecks.add(this) + else checkReactorOwnKeys(this) }) } } From d11523bf3679dcfdfed648e79f112917d25028a0 Mon Sep 17 00:00:00 2001 From: fynyky Date: Mon, 25 May 2026 18:23:11 +0000 Subject: [PATCH 2/3] Five targeted micro-optimisations across the hot paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- index.js | 44 ++++++++++++++++++++------------------------ 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/index.js b/index.js index d409adc..3082e13 100644 --- a/index.js +++ b/index.js @@ -126,7 +126,9 @@ class Signal extends Function { // Build dependency queue // Do not trigger dependents directly and leave it to be handled by the batcher - Array.from(this.dependents).forEach(dependent => { + // Iterate the Set directly — we only modify batcher (not this.dependents) + // so for...of is safe without snapshotting into a temporary Array + for (const dependent of this.dependents) { // Do this so that the dependent is added to the end of the batcher queue // Needed to ensure downstream observers are triggered again when necessary // as we iterate through the batched dependents @@ -134,7 +136,7 @@ class Signal extends Function { // But in this case it's necessary as we can't know all the downstream dependents ahead of time batcher.delete(dependent) batcher.add(dependent) - }) + } // If it's not an object then just return it right away // Cleaner and faster than the alternative approach of constructing a Reactor // and catching an error @@ -305,7 +307,9 @@ class Reactor { // Reactor properties are read through a trivial Signal // This handles dependency tracking and sub-object Reactor wrapping // Accessor Signals need to be stored to allow persistent dependencies - getSignals: {}, + // Null-prototype objects avoid prototype-chain collisions on keys like + // "constructor" and remove the need for hasOwnProperty.call checks + getSignals: Object.create(null), get (property, receiver) { // Disable unnecessary wrapping for unmodifiable properties // Needed because Array prototype checking fails if wrapped @@ -349,13 +353,8 @@ class Reactor { return currentValue } // Lazily instantiate accessor signals - this.getSignals[property] = - // Need to use hasOwnProperty instead of a normal get to avoid - // the basic Object prototype properties - // e.g. constructor - Object.prototype.hasOwnProperty.call(this.getSignals, property) - ? this.getSignals[property] - : new Signal() + // Safe to use plain property access because getSignals has no prototype + if (!this.getSignals[property]) this.getSignals[property] = new Signal() // User accessor signals to give the actual output // This enables automatic dependency tracking const signalCore = signalCoreExtractor.get(this.getSignals[property]) @@ -386,16 +385,13 @@ class Reactor { // Have a map of dummy Signals to keep track of dependents on has // We don't resuse the get Signals to avoid triggering getters - hasSignals: {}, + // Null-prototype avoids prototype collisions (same rationale as getSignals) + hasSignals: Object.create(null), has (property) { + if (dependencyStack.length === 0) return Reflect.has(this.source, property) // Lazily instantiate has signals - this.hasSignals[property] = - // Need to use hasOwnProperty instead of a normal get to avoid - // the basic Object prototype properties - // e.g. constructor - Object.prototype.hasOwnProperty.call(this.hasSignals, property) - ? this.hasSignals[property] - : new Signal(null) + // Safe to use plain property access because hasSignals has no prototype + if (!this.hasSignals[property]) this.hasSignals[property] = new Signal(null) // User accessor signals to give the actual output // This enables automatic dependency tracking const signalCore = signalCoreExtractor.get(this.hasSignals[property]) @@ -407,6 +403,7 @@ class Reactor { // Subscribe to the overall reactor by reading the dummy signal ownKeys () { + if (dependencyStack.length === 0) return Reflect.ownKeys(this.source) const currentKeys = Reflect.ownKeys(this.source) const signalCore = signalCoreExtractor.get(this.selfSignal) signalCore.value = currentKeys @@ -416,16 +413,15 @@ class Reactor { // Force dependencies to trigger // Hack to do this by trivially "redefining" the signal trigger (property) { - // Calculate the actual new values observers will receive - // This avoids redundant triggering if they were the same - const getValue = Reflect.get(this.source, property) - const hasValue = Reflect.has(this.source, property) // Batch together to avoid redundant triggering for shared observers // This might be redundant because the only way this happens is by calling native methods // which are already batched anyway. But keeping for safety batch(() => { - if (this.getSignals[property]) this.getSignals[property](getValue) - if (this.hasSignals[property]) this.hasSignals[property](hasValue) + // Reflect.get/has are computed lazily — only when a signal for that + // property actually exists — so trigger() is cheap for unobserved + // properties (e.g. every element write during sort when nobody watches) + if (this.getSignals[property]) this.getSignals[property](Reflect.get(this.source, property)) + if (this.hasSignals[property]) this.hasSignals[property](Reflect.has(this.source, property)) // If an apply trap is active it owns pendingOwnKeyChecks and will // flush once after the whole method finishes (O(1) per write). // Otherwise (direct property write, user-level batch()) check now. From 1433dabbbbb4d61b40292bc81e4b257afc35bb31 Mon Sep 17 00:00:00 2001 From: fynyky Date: Tue, 26 May 2026 02:45:21 +0000 Subject: [PATCH 3/3] Flatten nested try into try-catch-finally 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 --- index.js | 56 ++++++++++++++++++++++++++------------------------------ 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/index.js b/index.js index 8b1b026..d95a879 100644 --- a/index.js +++ b/index.js @@ -280,39 +280,35 @@ class Reactor { const savedPendingOwnKeyChecks = pendingOwnKeyChecks pendingOwnKeyChecks = new Set() try { - try { - const result = Reflect.apply(this.source, thisArg, argumentsList) - // flat() reads elements through the proxy to build dependencies correctly, - // but sub-arrays at the un-flattened cut-off depth end up reactor-wrapped - // in the result because they were read from inner reactor proxies. - // Calling flat() on the raw source instead would avoid this, but it - // bypasses the proxy entirely so no dependencies are built. - // Instead we call on the proxy and then unwrap any reactor-wrapped arrays - // left in the result. - if (this.source === Array.prototype.flat && Array.isArray(result)) { - const unwrapReactorArrays = (el) => { - if (!Reactors.has(el)) return el - const source = reactorCoreExtractor.get(el).source - if (!Array.isArray(source)) return el - return source.map(unwrapReactorArrays) - } - return result.map(unwrapReactorArrays) + const result = Reflect.apply(this.source, thisArg, argumentsList) + // flat() reads elements through the proxy to build dependencies correctly, + // but sub-arrays at the un-flattened cut-off depth end up reactor-wrapped + // in the result because they were read from inner reactor proxies. + // Calling flat() on the raw source instead would avoid this, but it + // bypasses the proxy entirely so no dependencies are built. + // Instead we call on the proxy and then unwrap any reactor-wrapped arrays + // left in the result. + if (this.source === Array.prototype.flat && Array.isArray(result)) { + const unwrapReactorArrays = (el) => { + if (!Reactors.has(el)) return el + const source = reactorCoreExtractor.get(el).source + if (!Array.isArray(source)) return el + return source.map(unwrapReactorArrays) } - return result - } catch (error) { - if (error.name === 'TypeError' && error.message.includes('called on incompatible receiver #')) { - const core = reactorCoreExtractor.get(thisArg) - if (typeof core !== 'undefined') { - // Note that this.source and core.source are different - // core.source is the underlying object - // this.source is the function which is being called with the object as `this` - return Reflect.apply(this.source, core.source, argumentsList) - } + return result.map(unwrapReactorArrays) + } + return result + } catch (error) { + if (error.name === 'TypeError' && error.message.includes('called on incompatible receiver #')) { + const core = reactorCoreExtractor.get(thisArg) + if (typeof core !== 'undefined') { + // Note that this.source and core.source are different + // core.source is the underlying object + // this.source is the function which is being called with the object as `this` + return Reflect.apply(this.source, core.source, argumentsList) } - // If any other type of error, or if there's nothing to unwrap throw error anyway - // because then its not a problem with Reactor wrapping - throw error } + throw error } finally { for (const reactorCore of pendingOwnKeyChecks) checkReactorOwnKeys(reactorCore) pendingOwnKeyChecks = savedPendingOwnKeyChecks