diff --git a/.changeset/perf-signals-hot-paths.md b/.changeset/perf-signals-hot-paths.md new file mode 100644 index 000000000..49d46cf9d --- /dev/null +++ b/.changeset/perf-signals-hot-paths.md @@ -0,0 +1,18 @@ +--- +"@solidjs/signals": patch +--- + +perf: optimize reactive hot paths + +- O(1) dependency revalidation: replace the `isValidLink` dep-list scan with a + per-recompute generation stamp on links, eliminating O(n²) behavior when a + computation re-reads a dependency it already saw during the same pass +- Skip redundant subscriber walks when a signal is written multiple times in + the same batch (epoch-invalidated by heap consumption, tracked-effect runs, + or new subscribers) +- Reduce reconcile allocations: reuse the existing key array when key sets + match in `getAllKeys`, and skip symbol lookups on primitive leaves in + `unwrap` +- Avoid the `untrack` closure in `getKeys` for plain (non-proxy) sources +- Specialize `snapshotImpl`'s no-override walk to read each property once +- Cache one bound effect runner per effect instead of allocating per update diff --git a/packages/solid-signals/src/core/core.ts b/packages/solid-signals/src/core/core.ts index 8ed4dbb58..4e1b04867 100644 --- a/packages/solid-signals/src/core/core.ts +++ b/packages/solid-signals/src/core/core.ts @@ -39,7 +39,8 @@ import { insertIntoHeap, insertIntoHeapHeight, markHeap, - markNode + markNode, + notifyEpoch } from "./heap.js"; import { findLane, @@ -179,6 +180,7 @@ export function recompute(el: Computed, create: boolean = false): void { const oldcontext = context; context = el; el._depsTail = null; + el._depGen++; el._flags = REACTIVE_RECOMPUTING_DEPS; el._time = clock; let value = el._pendingValue === NOT_PENDING ? el._value : el._pendingValue; @@ -284,7 +286,13 @@ export function recompute(el: Computed, create: boolean = false): void { // effect() can call its runner synchronously for the first run. if (isEffect && valueChanged) { (el as any)._modified = !el._error; - if (!create) el._queue.enqueue(isEffect, GlobalQueue._runEffect.bind(null, el)); + // Reuse one bound runner per effect — runEffect no-ops on a stale + // `_modified`, so double-enqueueing the same function is harmless. + if (!create) + el._queue.enqueue( + isEffect, + ((el as any)._boundRunEffect ??= GlobalQueue._runEffect.bind(null, el)) + ); } if (valueChanged) { @@ -388,6 +396,7 @@ export function computed( _prevHeap: null as any, _deps: null, _depsTail: null, + _depGen: 0, _subs: null, _subsTail: null, _parent: context, @@ -397,6 +406,7 @@ export function computed( _flags: options?.lazy ? REACTIVE_LAZY : REACTIVE_NONE, _statusFlags: STATUS_UNINITIALIZED, _time: clock, + _notifyEpoch: 0, _pendingValue: NOT_PENDING, _pendingDisposal: null, _pendingFirstChild: null, @@ -446,6 +456,7 @@ export function createEffectNode( _prevHeap: null as any, _deps: null, _depsTail: null, + _depGen: 0, _subs: null, _subsTail: null, _parent: context, @@ -455,6 +466,7 @@ export function createEffectNode( _flags: REACTIVE_LAZY, _statusFlags: STATUS_UNINITIALIZED, _time: clock, + _notifyEpoch: 0, _pendingValue: NOT_PENDING, _pendingDisposal: null, _pendingFirstChild: null, @@ -545,6 +557,7 @@ export function signal( _subs: null, _subsTail: null, _time: clock, + _notifyEpoch: 0, _firewall: firewall, _nextChild: firewall?._child || null, _pendingValue: NOT_PENDING @@ -981,7 +994,17 @@ export function setSignal(el: Signal | Computed, v: T | ((prev: T) => T } el._time = clock; - insertSubs(el, isOptimistic); + if (isOptimistic) { + // Optimistic walks mutate subscriber lane state, so they always run and + // never count as a cacheable plain notification. + el._notifyEpoch = 0; + insertSubs(el, true); + } else if (el._notifyEpoch !== notifyEpoch || el._snapshotValue !== undefined) { + insertSubs(el, false); + el._notifyEpoch = notifyEpoch; + } + // else: an earlier write this batch already queued every subscriber and none + // of them has been consumed since — the walk would be a no-op. schedule(); return v; } diff --git a/packages/solid-signals/src/core/effect.ts b/packages/solid-signals/src/core/effect.ts index b791f86d3..679d64b93 100644 --- a/packages/solid-signals/src/core/effect.ts +++ b/packages/solid-signals/src/core/effect.ts @@ -18,6 +18,7 @@ import { } from "./core.js"; import { emitDiagnostic } from "./dev.js"; import { StatusError } from "./error.js"; +import { bumpNotifyEpoch } from "./heap.js"; import { cleanup } from "./owner.js"; import { _hitUnhandledAsync, @@ -170,6 +171,9 @@ export function trackedEffect(fn: () => void | (() => void), options?: NodeOptio if (__DEV__) setTrackedQueueCallback(true); try { node._modified = false; + // Consuming `_modified` invalidates setSignal's skip-walk cache — a + // later write in this batch must re-enqueue this effect. + bumpNotifyEpoch(); recompute(node); } finally { if (__DEV__) setTrackedQueueCallback(false); diff --git a/packages/solid-signals/src/core/graph.ts b/packages/solid-signals/src/core/graph.ts index 6f60eeeb1..6734328cd 100644 --- a/packages/solid-signals/src/core/graph.ts +++ b/packages/solid-signals/src/core/graph.ts @@ -62,13 +62,17 @@ export function link(dep: Signal | Computed, sub: Computed) { if (isRecomputing) { nextDep = prevDep !== null ? prevDep._nextDep : sub._deps; if (nextDep !== null && nextDep._dep === dep) { + nextDep._gen = sub._depGen; sub._depsTail = nextDep; return; } } + // A link stamped with the current pass generation was already created or + // revalidated during this recompute — i.e. it sits in the [head.._depsTail] + // prefix. O(1) replacement for scanning the dep list to check membership. const prevSub = dep._subsTail; - if (prevSub !== null && prevSub._sub === sub && (!isRecomputing || isValidLink(prevSub, sub))) + if (prevSub !== null && prevSub._sub === sub && (!isRecomputing || prevSub._gen === sub._depGen)) return; const newLink = @@ -79,25 +83,15 @@ export function link(dep: Signal | Computed, sub: Computed) { _sub: sub, _nextDep: nextDep, _prevSub: prevSub, - _nextSub: null + _nextSub: null, + _gen: sub._depGen }); if (prevDep !== null) prevDep._nextDep = newLink; else sub._deps = newLink; if (prevSub !== null) prevSub._nextSub = newLink; else dep._subs = newLink; + // New subscriber: the next write must walk the full sub list again. + dep._notifyEpoch = 0; } -// https://github.com/stackblitz/alien-signals/blob/v2.0.3/src/system.ts#L284 -function isValidLink(checkLink: Link, sub: Computed): boolean { - const depsTail = sub._depsTail; - if (depsTail !== null) { - let link = sub._deps!; - do { - if (link === checkLink) return true; - if (link === depsTail) break; - link = link._nextDep!; - } while (link !== null); - } - return false; -} diff --git a/packages/solid-signals/src/core/heap.ts b/packages/solid-signals/src/core/heap.ts index 66b870cfc..28c63dc36 100644 --- a/packages/solid-signals/src/core/heap.ts +++ b/packages/solid-signals/src/core/heap.ts @@ -15,6 +15,19 @@ export interface Heap { _max: number; } +/** + * Monotonic counter that advances whenever a queued notification is consumed — + * a node leaves the heap, or a tracked effect drains its `_modified` flag. + * While it holds still, every subscriber a write already pushed into the heap + * is guaranteed to still be there, so repeat writes to the same signal in one + * batch can skip re-walking the subscriber list entirely (see `setSignal`). + */ +export let notifyEpoch = 1; + +export function bumpNotifyEpoch(): void { + notifyEpoch++; +} + export function increaseHeapSize(n: number, heap: Heap): void { if (n > heap._heap.length) { heap._heap.length = n; @@ -61,6 +74,7 @@ export function insertIntoHeapHeight(n: Computed, heap: Heap) { export function deleteFromHeap(n: Computed, heap: Heap) { const flags = n._flags; if (!(flags & (REACTIVE_IN_HEAP | REACTIVE_IN_HEAP_HEIGHT))) return; + notifyEpoch++; n._flags = flags & ~(REACTIVE_IN_HEAP | REACTIVE_IN_HEAP_HEIGHT); const height = n._height; if (n._prevHeap === n) heap._heap[height] = undefined; diff --git a/packages/solid-signals/src/core/types.ts b/packages/solid-signals/src/core/types.ts index 8fa1e3e09..49681842d 100644 --- a/packages/solid-signals/src/core/types.ts +++ b/packages/solid-signals/src/core/types.ts @@ -11,6 +11,12 @@ export interface Link { _nextDep: Link | null; _prevSub: Link | null; _nextSub: Link | null; + /** + * `_sub._depGen` value at the last time this link was created or + * revalidated. Lets `link()` answer "was this dep already seen during the + * current recompute pass?" in O(1) instead of scanning the dep list. + */ + _gen: number; } export interface NodeOptions { @@ -36,6 +42,13 @@ export interface RawSignal { _config: number; _unobserved?: () => void; _time: number; + /** + * `notifyEpoch` value at the last full subscriber walk from `setSignal`. + * While it still equals the global epoch (and no subscriber was added since + * — `link` resets this to 0), every sub is already queued and repeat writes + * skip the walk. 0 means "must walk". + */ + _notifyEpoch: number; _transition: Transition | null; _pendingValue: T | typeof NOT_PENDING; _overrideValue?: T | typeof NOT_PENDING; @@ -70,6 +83,8 @@ export interface Owner { export interface Computed extends RawSignal, Owner { _deps: Link | null; _depsTail: Link | null; + /** Recompute-pass counter; bumped each time dep revalidation starts. */ + _depGen: number; _flags: number; _blocked?: boolean; _pendingSource?: Computed; diff --git a/packages/solid-signals/src/store/reconcile.ts b/packages/solid-signals/src/store/reconcile.ts index 3cc81ff34..260a0f97f 100644 --- a/packages/solid-signals/src/store/reconcile.ts +++ b/packages/solid-signals/src/store/reconcile.ts @@ -17,7 +17,10 @@ import { } from "./store.js"; function unwrap(value: any) { - return value?.[$TARGET]?.[STORE_NODE] ?? value; + // Primitives can't be store proxies; skip the symbol lookups (which box the + // primitive) for the common leaf case. + if (value === null || typeof value !== "object") return value; + return value[$TARGET]?.[STORE_NODE] ?? value; } function getOverrideValue(value: any, override: any, key: string, optOverride?: any) { @@ -28,7 +31,21 @@ function getOverrideValue(value: any, override: any, key: string, optOverride?: function getAllKeys(value, override, next) { const keys = getKeys(value, override) as string[]; const nextKeys = Object.keys(next); - return Array.from(new Set([...keys, ...nextKeys])); + // Fast path: identical key sets in identical order (the overwhelmingly + // common shape during reconcile) — no Set, no copies. + if (keys.length === nextKeys.length) { + let same = true; + for (let i = 0; i < keys.length; i++) { + if (keys[i] !== nextKeys[i]) { + same = false; + break; + } + } + if (same) return keys; + } + const set = new Set(keys); + for (let i = 0; i < nextKeys.length; i++) set.add(nextKeys[i]); + return Array.from(set); } // Dispatcher: every applyState call (including recursion) checks for the diff --git a/packages/solid-signals/src/store/store.ts b/packages/solid-signals/src/store/store.ts index 374b9a349..b9485c93d 100644 --- a/packages/solid-signals/src/store/store.ts +++ b/packages/solid-signals/src/store/store.ts @@ -281,7 +281,14 @@ export function getKeys( override: Record | undefined, enumerable: boolean = true ): PropertyKey[] { - const baseKeys = untrack(() => (enumerable ? Object.keys(source) : Reflect.ownKeys(source))); + // Plain objects can't trigger proxy traps — only wrap in untrack when the + // source is itself a wrapped store (store-in-store), so the common case + // skips the closure allocation. + const baseKeys = (source as any)[$TARGET] + ? untrack(() => (enumerable ? Object.keys(source) : Reflect.ownKeys(source))) + : enumerable + ? Object.keys(source) + : Reflect.ownKeys(source); if (!override) return baseKeys; const keys = new Set(baseKeys); const overrides = Reflect.ownKeys(override); diff --git a/packages/solid-signals/src/store/utils.ts b/packages/solid-signals/src/store/utils.ts index 027462abc..1013d5bb3 100644 --- a/packages/solid-signals/src/store/utils.ts +++ b/packages/solid-signals/src/store/utils.ts @@ -55,13 +55,32 @@ function snapshotImpl( result[i] = unwrapped; } } + } else if (!override) { + // Specialized walk for the common no-override case: the descriptor gives + // us the value directly, so each property is read once instead of three + // times, with no override membership checks. + const keys = getKeys(item, undefined); + for (let i = 0, l = keys.length; i < l; i++) { + const prop = keys[i]; + const desc = Object.getOwnPropertyDescriptor(item, prop)!; + if (desc.get) continue; + v = desc.value; + if (track && isWrappable(v)) wrap(v, target); + if ((unwrapped = snapshotImpl(v, track, map, lookup)) !== v || result) { + if (!result) { + result = Object.create(Object.getPrototypeOf(item)) as Record; + Object.assign(result, item); + } + result[prop] = unwrapped; + } + } } else { const keys = getKeys(item, override); for (let i = 0, l = keys.length; i < l; i++) { let prop = keys[i]; const desc = getPropertyDescriptor(item, override, prop)!; if (desc.get) continue; - v = override && prop in override ? override[prop] : item[prop]; + v = prop in override ? override[prop] : item[prop]; if (track && isWrappable(v)) wrap(v, target); if ((unwrapped = snapshotImpl(v, track, map, lookup)) !== item[prop] || result) { if (!result) {