Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/perf-signals-hot-paths.md
Original file line number Diff line number Diff line change
@@ -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
29 changes: 26 additions & 3 deletions packages/solid-signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ import {
insertIntoHeap,
insertIntoHeapHeight,
markHeap,
markNode
markNode,
notifyEpoch
} from "./heap.js";
import {
findLane,
Expand Down Expand Up @@ -179,6 +180,7 @@ export function recompute(el: Computed<any>, 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;
Expand Down Expand Up @@ -284,7 +286,13 @@ export function recompute(el: Computed<any>, 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) {
Expand Down Expand Up @@ -388,6 +396,7 @@ export function computed<T>(
_prevHeap: null as any,
_deps: null,
_depsTail: null,
_depGen: 0,
_subs: null,
_subsTail: null,
_parent: context,
Expand All @@ -397,6 +406,7 @@ export function computed<T>(
_flags: options?.lazy ? REACTIVE_LAZY : REACTIVE_NONE,
_statusFlags: STATUS_UNINITIALIZED,
_time: clock,
_notifyEpoch: 0,
_pendingValue: NOT_PENDING,
_pendingDisposal: null,
_pendingFirstChild: null,
Expand Down Expand Up @@ -446,6 +456,7 @@ export function createEffectNode<T>(
_prevHeap: null as any,
_deps: null,
_depsTail: null,
_depGen: 0,
_subs: null,
_subsTail: null,
_parent: context,
Expand All @@ -455,6 +466,7 @@ export function createEffectNode<T>(
_flags: REACTIVE_LAZY,
_statusFlags: STATUS_UNINITIALIZED,
_time: clock,
_notifyEpoch: 0,
_pendingValue: NOT_PENDING,
_pendingDisposal: null,
_pendingFirstChild: null,
Expand Down Expand Up @@ -545,6 +557,7 @@ export function signal<T>(
_subs: null,
_subsTail: null,
_time: clock,
_notifyEpoch: 0,
_firewall: firewall,
_nextChild: firewall?._child || null,
_pendingValue: NOT_PENDING
Expand Down Expand Up @@ -981,7 +994,17 @@ export function setSignal<T>(el: Signal<T> | Computed<T>, 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;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/solid-signals/src/core/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
24 changes: 9 additions & 15 deletions packages/solid-signals/src/core/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,17 @@ export function link(dep: Signal<any> | Computed<any>, sub: Computed<any>) {
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 =
Expand All @@ -79,25 +83,15 @@ export function link(dep: Signal<any> | Computed<any>, sub: Computed<any>) {
_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<unknown>): 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;
}
14 changes: 14 additions & 0 deletions packages/solid-signals/src/core/heap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -61,6 +74,7 @@ export function insertIntoHeapHeight(n: Computed<unknown>, heap: Heap) {
export function deleteFromHeap(n: Computed<unknown>, 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;
Expand Down
15 changes: 15 additions & 0 deletions packages/solid-signals/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
Expand All @@ -36,6 +42,13 @@ export interface RawSignal<T> {
_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;
Expand Down Expand Up @@ -70,6 +83,8 @@ export interface Owner {
export interface Computed<T> extends RawSignal<T>, Owner {
_deps: Link | null;
_depsTail: Link | null;
/** Recompute-pass counter; bumped each time dep revalidation starts. */
_depGen: number;
_flags: number;
_blocked?: boolean;
_pendingSource?: Computed<any>;
Expand Down
21 changes: 19 additions & 2 deletions packages/solid-signals/src/store/reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion packages/solid-signals/src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,14 @@ export function getKeys(
override: Record<PropertyKey, any> | 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);
Expand Down
21 changes: 20 additions & 1 deletion packages/solid-signals/src/store/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,32 @@ function snapshotImpl<T>(
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<PropertyKey, any>;
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) {
Expand Down
Loading