From 2144f44c10bccdca15f0f16abbc12671f834590a Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 4 Jun 2026 19:50:48 -0700 Subject: [PATCH 01/95] Add alien-signals --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 3b9816286..b44a608a1 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ }, "dependencies": { "@types/use-sync-external-store": "^0.0.6", + "alien-signals": "^3.2.1", "use-sync-external-store": "^1.4.0" }, "devDependencies": { From 947ca875ea489e88a07d4308ff37ebce9dc7688b Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 4 Jun 2026 19:53:59 -0700 Subject: [PATCH 02/95] Implement initial alien-signals path registry --- src/signals/engine.ts | 60 +++++++ src/signals/pathSignalRegistry.ts | 72 ++++++++ src/signals/types.ts | 23 +++ test/signals/engine.spec.ts | 218 ++++++++++++++++++++++++ test/signals/pathSignalRegistry.spec.ts | 213 +++++++++++++++++++++++ 5 files changed, 586 insertions(+) create mode 100644 src/signals/engine.ts create mode 100644 src/signals/pathSignalRegistry.ts create mode 100644 src/signals/types.ts create mode 100644 test/signals/engine.spec.ts create mode 100644 test/signals/pathSignalRegistry.spec.ts diff --git a/src/signals/engine.ts b/src/signals/engine.ts new file mode 100644 index 000000000..36b63862f --- /dev/null +++ b/src/signals/engine.ts @@ -0,0 +1,60 @@ +import { + signal as alienSignal, + computed as alienComputed, + effect as alienEffect, + effectScope as alienEffectScope, + startBatch, + endBatch, +} from 'alien-signals' +import type { + SignalEngine, + ReactiveSignal, + ReactiveComputed, + SignalScope, +} from './types' + +export const alienEngine: SignalEngine = { + signal(value: T): ReactiveSignal { + const s = alienSignal(value) + return { + get: () => s(), + set: (v: T) => s(v), + } + }, + + computed(fn: () => T): ReactiveComputed { + const c = alienComputed(fn) + return { get: () => c() } + }, + + effect(fn: () => void): void { + alienEffect(fn) + }, + + batch(fn: () => void): void { + startBatch() + try { + fn() + } finally { + endBatch() + } + }, + + createScope(): SignalScope { + let dispose: (() => void) | undefined + + return { + run(fn: () => T): T { + let result: T + dispose = alienEffectScope(() => { + result = fn() + }) + return result! + }, + stop(): void { + dispose?.() + dispose = undefined + }, + } + }, +} diff --git a/src/signals/pathSignalRegistry.ts b/src/signals/pathSignalRegistry.ts new file mode 100644 index 000000000..1f466f409 --- /dev/null +++ b/src/signals/pathSignalRegistry.ts @@ -0,0 +1,72 @@ +import type { PathKey, ReactiveSignal, SignalEngine } from './types' + +export interface PathSignalRegistry { + /** Get or create a signal for a path. Object/array values use version counters. */ + getOrCreate(pathKey: PathKey, currentValue: unknown): ReactiveSignal + + /** Update a signal's value (called during diff). */ + update(pathKey: PathKey, newValue: unknown): void + + /** Remove signal for a path and all child paths. */ + prune(pathKey: PathKey): void + + /** Number of active signals. */ + size(): number + + /** Check if a signal exists for a path (without creating one). */ + has(pathKey: PathKey): boolean +} + +function isObjectOrArray(v: unknown): v is object { + return v !== null && typeof v === 'object' +} + +export function createPathSignalRegistry( + engine: SignalEngine, +): PathSignalRegistry { + const signals = new Map>() + + return { + getOrCreate(pathKey: PathKey, currentValue: unknown): ReactiveSignal { + let sig = signals.get(pathKey) + if (!sig) { + // For objects/arrays, store a version counter rather than the object itself. + // The actual value is always read from the frozen state. + // The signal's purpose is dependency tracking + change notification. + const initialValue = isObjectOrArray(currentValue) ? 0 : currentValue + sig = engine.signal(initialValue) + signals.set(pathKey, sig) + } + return sig + }, + + update(pathKey: PathKey, newValue: unknown): void { + const sig = signals.get(pathKey) + if (!sig) return // no one is tracking this path — skip + + if (isObjectOrArray(newValue)) { + // Bump version counter — signals something in this subtree changed + sig.set((sig.get() as number) + 1) + } else { + sig.set(newValue) + } + }, + + prune(pathKey: PathKey): void { + const prefix = pathKey + '.' + for (const key of signals.keys()) { + if (key === pathKey || key.startsWith(prefix)) { + signals.delete(key) + } + } + }, + + size(): number { + return signals.size + }, + + has(pathKey: PathKey): boolean { + return signals.has(pathKey) + }, + } +} diff --git a/src/signals/types.ts b/src/signals/types.ts new file mode 100644 index 000000000..e42791794 --- /dev/null +++ b/src/signals/types.ts @@ -0,0 +1,23 @@ +export type PathKey = string // dot-joined: "todos.0.text", "counters.counter1.value" + +export interface ReactiveSignal { + get(): T + set(value: T): void +} + +export interface ReactiveComputed { + get(): T +} + +export interface SignalEngine { + signal(value: T): ReactiveSignal + computed(fn: () => T): ReactiveComputed + effect(fn: () => void): void + batch(fn: () => void): void + createScope(): SignalScope +} + +export interface SignalScope { + run(fn: () => T): T + stop(): void +} diff --git a/test/signals/engine.spec.ts b/test/signals/engine.spec.ts new file mode 100644 index 000000000..39c0e39b6 --- /dev/null +++ b/test/signals/engine.spec.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, vi } from 'vitest' +import { alienEngine } from '../../src/signals/engine' + +describe('SignalEngine (alien-signals)', () => { + describe('signal', () => { + it('reads initial value', () => { + const s = alienEngine.signal(42) + expect(s.get()).toBe(42) + }) + + it('writes and reads new value', () => { + const s = alienEngine.signal(0) + s.set(10) + expect(s.get()).toBe(10) + }) + + it('stores different value types', () => { + const sNum = alienEngine.signal(1) + const sStr = alienEngine.signal('hello') + const sBool = alienEngine.signal(true) + const sNull = alienEngine.signal(null) + + expect(sNum.get()).toBe(1) + expect(sStr.get()).toBe('hello') + expect(sBool.get()).toBe(true) + expect(sNull.get()).toBe(null) + }) + }) + + describe('computed', () => { + it('derives value from signal', () => { + const s = alienEngine.signal(5) + const c = alienEngine.computed(() => s.get() * 2) + expect(c.get()).toBe(10) + }) + + it('updates when dependency changes', () => { + const s = alienEngine.signal(1) + const c = alienEngine.computed(() => s.get() + 100) + expect(c.get()).toBe(101) + s.set(2) + expect(c.get()).toBe(102) + }) + + it('provides propagation cutoff (same computed value does not notify)', () => { + const s = alienEngine.signal(5) + const c = alienEngine.computed(() => s.get() > 3) // true for 5 + + let effectCount = 0 + const scope = alienEngine.createScope() + scope.run(() => { + alienEngine.effect(() => { + c.get() + effectCount++ + }) + }) + effectCount = 0 + + s.set(6) // still > 3 → computed returns true → no change + expect(effectCount).toBe(0) + + s.set(2) // now ≤ 3 → computed returns false → change + expect(effectCount).toBe(1) + + scope.stop() + }) + + it('chains multiple computeds', () => { + const s = alienEngine.signal(2) + const c1 = alienEngine.computed(() => s.get() * 3) + const c2 = alienEngine.computed(() => c1.get() + 1) + expect(c2.get()).toBe(7) + s.set(4) + expect(c2.get()).toBe(13) + }) + }) + + describe('effect', () => { + it('runs immediately on creation (within scope)', () => { + const effectFn = vi.fn() + const scope = alienEngine.createScope() + scope.run(() => { + alienEngine.effect(effectFn) + }) + expect(effectFn).toHaveBeenCalledTimes(1) + scope.stop() + }) + + it('re-runs when tracked signals change', () => { + const s = alienEngine.signal(0) + let lastSeen = -1 + + const scope = alienEngine.createScope() + scope.run(() => { + alienEngine.effect(() => { + lastSeen = s.get() + }) + }) + expect(lastSeen).toBe(0) + + s.set(5) + expect(lastSeen).toBe(5) + + scope.stop() + }) + + it('stops running after scope is disposed', () => { + const s = alienEngine.signal(0) + let effectCount = 0 + + const scope = alienEngine.createScope() + scope.run(() => { + alienEngine.effect(() => { + s.get() + effectCount++ + }) + }) + expect(effectCount).toBe(1) + + scope.stop() + s.set(10) + expect(effectCount).toBe(1) // should not have run again + }) + }) + + describe('batch', () => { + it('coalesces multiple signal updates into one propagation', () => { + const s1 = alienEngine.signal(0) + const s2 = alienEngine.signal(0) + const c = alienEngine.computed(() => s1.get() + s2.get()) + + let effectCount = 0 + const scope = alienEngine.createScope() + scope.run(() => { + alienEngine.effect(() => { + c.get() + effectCount++ + }) + }) + effectCount = 0 + + alienEngine.batch(() => { + s1.set(1) + s2.set(1) + }) + + expect(effectCount).toBe(1) // single propagation, not 2 + expect(c.get()).toBe(2) + + scope.stop() + }) + + it('handles nested batch correctly', () => { + const s = alienEngine.signal(0) + let effectCount = 0 + + const scope = alienEngine.createScope() + scope.run(() => { + alienEngine.effect(() => { + s.get() + effectCount++ + }) + }) + effectCount = 0 + + alienEngine.batch(() => { + s.set(1) + alienEngine.batch(() => { + s.set(2) + }) + s.set(3) + }) + + // Only the final value should be seen after batch + expect(s.get()).toBe(3) + // Effect should have run once (or a small number), not 3 times + expect(effectCount).toBeLessThanOrEqual(1) + + scope.stop() + }) + }) + + describe('createScope', () => { + it('returns value from run()', () => { + const scope = alienEngine.createScope() + const result = scope.run(() => 42) + expect(result).toBe(42) + scope.stop() + }) + + it('disposes all effects when stopped', () => { + const s1 = alienEngine.signal(0) + const s2 = alienEngine.signal(0) + let effect1Count = 0 + let effect2Count = 0 + + const scope = alienEngine.createScope() + scope.run(() => { + alienEngine.effect(() => { + s1.get() + effect1Count++ + }) + alienEngine.effect(() => { + s2.get() + effect2Count++ + }) + }) + expect(effect1Count).toBe(1) + expect(effect2Count).toBe(1) + + scope.stop() + s1.set(1) + s2.set(1) + expect(effect1Count).toBe(1) // not re-run + expect(effect2Count).toBe(1) // not re-run + }) + }) +}) diff --git a/test/signals/pathSignalRegistry.spec.ts b/test/signals/pathSignalRegistry.spec.ts new file mode 100644 index 000000000..0505aae1c --- /dev/null +++ b/test/signals/pathSignalRegistry.spec.ts @@ -0,0 +1,213 @@ +import { describe, it, expect } from 'vitest' +import { alienEngine } from '../../src/signals/engine' +import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' + +describe('PathSignalRegistry', () => { + function createRegistry() { + return createPathSignalRegistry(alienEngine) + } + + describe('getOrCreate', () => { + it('creates a signal for a primitive value', () => { + const registry = createRegistry() + const sig = registry.getOrCreate('filter', 'all') + expect(sig.get()).toBe('all') + expect(registry.size()).toBe(1) + }) + + it('returns existing signal on second access', () => { + const registry = createRegistry() + const sig1 = registry.getOrCreate('filter', 'all') + const sig2 = registry.getOrCreate('filter', 'all') + expect(sig1).toBe(sig2) + expect(registry.size()).toBe(1) + }) + + it('creates version counter (0) for object values', () => { + const registry = createRegistry() + const obj = Object.freeze({ id: 1, text: 'hello' }) + const sig = registry.getOrCreate('todos.0', obj) + expect(sig.get()).toBe(0) // version counter, not the object + }) + + it('creates version counter (0) for array values', () => { + const registry = createRegistry() + const arr = Object.freeze([1, 2, 3]) + const sig = registry.getOrCreate('todos', arr) + expect(sig.get()).toBe(0) // version counter + }) + + it('stores null as a primitive (not version counter)', () => { + const registry = createRegistry() + const sig = registry.getOrCreate('value', null) + expect(sig.get()).toBe(null) + }) + + it('creates independent signals for different paths', () => { + const registry = createRegistry() + registry.getOrCreate('a', 1) + registry.getOrCreate('b', 2) + registry.getOrCreate('c.d', 3) + expect(registry.size()).toBe(3) + }) + }) + + describe('update', () => { + it('updates primitive signal to new value', () => { + const registry = createRegistry() + const sig = registry.getOrCreate('filter', 'all') + registry.update('filter', 'active') + expect(sig.get()).toBe('active') + }) + + it('bumps version counter for object values', () => { + const registry = createRegistry() + const sig = registry.getOrCreate('todos.0', { id: 1 }) + expect(sig.get()).toBe(0) + + registry.update('todos.0', { id: 1, text: 'changed' }) + expect(sig.get()).toBe(1) + + registry.update('todos.0', { id: 1, text: 'again' }) + expect(sig.get()).toBe(2) + }) + + it('bumps version counter for array values', () => { + const registry = createRegistry() + const sig = registry.getOrCreate('todos', []) + expect(sig.get()).toBe(0) + + registry.update('todos', [{ id: 1 }]) + expect(sig.get()).toBe(1) + }) + + it('does nothing for untracked paths', () => { + const registry = createRegistry() + // No signal created for 'unknown' — should not throw + registry.update('unknown', 'value') + expect(registry.size()).toBe(0) + }) + + it('triggers reactive updates through signal engine', () => { + const registry = createRegistry() + const sig = registry.getOrCreate('count', 0) + + let effectSeen = -1 + const scope = alienEngine.createScope() + scope.run(() => { + alienEngine.effect(() => { + effectSeen = sig.get() as number + }) + }) + expect(effectSeen).toBe(0) + + registry.update('count', 5) + expect(effectSeen).toBe(5) + + scope.stop() + }) + + it('batched updates trigger single propagation', () => { + const registry = createRegistry() + const sig1 = registry.getOrCreate('a', 0) + const sig2 = registry.getOrCreate('b', 0) + + const c = alienEngine.computed(() => (sig1.get() as number) + (sig2.get() as number)) + let effectCount = 0 + const scope = alienEngine.createScope() + scope.run(() => { + alienEngine.effect(() => { + c.get() + effectCount++ + }) + }) + effectCount = 0 + + alienEngine.batch(() => { + registry.update('a', 1) + registry.update('b', 1) + }) + + expect(effectCount).toBe(1) + expect(c.get()).toBe(2) + + scope.stop() + }) + }) + + describe('prune', () => { + it('removes the exact path', () => { + const registry = createRegistry() + registry.getOrCreate('todos.0', { id: 1 }) + expect(registry.size()).toBe(1) + + registry.prune('todos.0') + expect(registry.size()).toBe(0) + }) + + it('removes all child paths', () => { + const registry = createRegistry() + registry.getOrCreate('todos', []) + registry.getOrCreate('todos.0', { id: 1 }) + registry.getOrCreate('todos.0.text', 'hello') + registry.getOrCreate('todos.0.completed', false) + registry.getOrCreate('todos.1', { id: 2 }) + registry.getOrCreate('todos.1.text', 'world') + expect(registry.size()).toBe(6) + + registry.prune('todos.0') + expect(registry.size()).toBe(3) // todos, todos.1, todos.1.text remain + expect(registry.has('todos')).toBe(true) + expect(registry.has('todos.0')).toBe(false) + expect(registry.has('todos.0.text')).toBe(false) + expect(registry.has('todos.0.completed')).toBe(false) + expect(registry.has('todos.1')).toBe(true) + }) + + it('does not remove sibling paths with similar prefix', () => { + const registry = createRegistry() + registry.getOrCreate('todo', 'a') + registry.getOrCreate('todos', []) + registry.getOrCreate('todos.0', { id: 1 }) + + registry.prune('todo') + expect(registry.size()).toBe(2) // todos and todos.0 remain + expect(registry.has('todo')).toBe(false) + expect(registry.has('todos')).toBe(true) + expect(registry.has('todos.0')).toBe(true) + }) + + it('prunes entire subtree', () => { + const registry = createRegistry() + registry.getOrCreate('a', 1) + registry.getOrCreate('b', 2) + registry.getOrCreate('b.c', 3) + registry.getOrCreate('b.c.d', 4) + registry.getOrCreate('b.e', 5) + + registry.prune('b') + expect(registry.size()).toBe(1) // only 'a' remains + expect(registry.has('a')).toBe(true) + }) + }) + + describe('has', () => { + it('returns false for non-existent path', () => { + const registry = createRegistry() + expect(registry.has('nonexistent')).toBe(false) + }) + + it('returns true after getOrCreate', () => { + const registry = createRegistry() + registry.getOrCreate('path', 42) + expect(registry.has('path')).toBe(true) + }) + + it('returns false after prune', () => { + const registry = createRegistry() + registry.getOrCreate('path', 42) + registry.prune('path') + expect(registry.has('path')).toBe(false) + }) + }) +}) From 72683ebdac86f41d90c799f9961696cd87f7e6f1 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 4 Jun 2026 20:08:46 -0700 Subject: [PATCH 03/95] Implement tracking proxy --- src/signals/trackingProxy.ts | 116 +++++ test/signals/trackingProxy.spec.ts | 657 +++++++++++++++++++++++++++++ 2 files changed, 773 insertions(+) create mode 100644 src/signals/trackingProxy.ts create mode 100644 test/signals/trackingProxy.spec.ts diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts new file mode 100644 index 000000000..7c98017df --- /dev/null +++ b/src/signals/trackingProxy.ts @@ -0,0 +1,116 @@ +import type { PathSignalRegistry } from './pathSignalRegistry' + +function isObjectOrArray(v: unknown): v is object { + return v !== null && typeof v === 'object' +} + +/** + * Creates a read-only tracking proxy that wraps frozen Redux state. + * + * On property access, the proxy: + * 1. Reads the corresponding path signal (establishing a reactive dependency) + * 2. Returns the actual frozen value (primitives) or a nested tracking proxy (objects/arrays) + * + * For frozen state compatibility: the proxy target is a thin unfrozen shell + * that mirrors the real state's structure. This avoids the ES Proxy invariant + * that forbids returning different values for non-configurable properties. + * The proxy reads actual values from the real frozen state object. + * + * Child proxies are cached within a single evaluation to avoid duplicates. + */ +export function createTrackingProxy( + target: T, + pathSegments: string[], + registry: PathSignalRegistry, +): T { + // Cache child proxies within this evaluation to avoid duplicate creation + const childCache = new Map() + + // Use an unfrozen shell as the proxy target to avoid ES Proxy invariant + // violations with frozen objects. The shell copies the target's prototype + // so that Array.isArray, instanceof, etc. work correctly. + const shell = Array.isArray(target) ? [] : Object.create(Object.getPrototypeOf(target)) + + return new Proxy(shell, { + get(_obj, prop, _receiver) { + // Symbols: read from actual target (iterator protocol, toStringTag, etc.) + if (typeof prop === 'symbol') return Reflect.get(target, prop) + + const value = (target as Record)[prop] + + // Functions (like Array.prototype.map) — bind to proxy so `this` works + if (typeof value === 'function') { + // For inherited methods (e.g. Array.prototype.map), return them + // bound to the proxy so iteration uses tracked access + return value + } + + const childPath = [...pathSegments, prop] + const pathKey = childPath.join('.') + + if (isObjectOrArray(value)) { + // Read signal to establish dependency (version counter for objects) + registry.getOrCreate(pathKey, value).get() + + // Return cached child proxy + if (!childCache.has(prop as string)) { + childCache.set( + prop as string, + createTrackingProxy(value as object, childPath, registry), + ) + } + return childCache.get(prop as string) + } + + // Primitive leaf: read signal to establish dependency + registry.getOrCreate(pathKey, value).get() + + // Return the actual value + return value + }, + + // Track when selectors iterate keys (Object.keys, for...in, .map, .filter, etc.) + ownKeys(_obj) { + const keysPath = [...pathSegments, '@@keys'].join('.') + registry.getOrCreate(keysPath, Reflect.ownKeys(target)).get() + return Reflect.ownKeys(target) + }, + + // Track has() checks for conditional property access (e.g., 'key' in obj) + has(_obj, prop) { + if (typeof prop === 'symbol') return Reflect.has(target, prop) + const pathKey = [...pathSegments, prop].join('.') + registry.getOrCreate(pathKey, (target as Record)[prop]).get() + return Reflect.has(target, prop) + }, + + // Delegate to real target for property descriptors + getOwnPropertyDescriptor(_obj, prop) { + const desc = Object.getOwnPropertyDescriptor(target, prop) + if (desc) { + // Mark as configurable so the proxy can return different values + // (our proxy may return child proxies for object properties) + return { ...desc, configurable: true } + } + return desc + }, + + // Report the real target's prototype + getPrototypeOf(_obj) { + return Object.getPrototypeOf(target) + }, + + // Report the real target's extensibility + isExtensible(_obj) { + return Object.isExtensible(target) + }, + + // Prevent mutation + set() { + return false + }, + deleteProperty() { + return false + }, + }) as T +} diff --git a/test/signals/trackingProxy.spec.ts b/test/signals/trackingProxy.spec.ts new file mode 100644 index 000000000..7d0337d44 --- /dev/null +++ b/test/signals/trackingProxy.spec.ts @@ -0,0 +1,657 @@ +import { describe, it, expect, vi } from 'vitest' +import { createTrackingProxy } from '../../src/signals/trackingProxy' +import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import { alienEngine } from '../../src/signals/engine' +import type { PathSignalRegistry } from '../../src/signals/pathSignalRegistry' + +function makeRegistry(): PathSignalRegistry { + return createPathSignalRegistry(alienEngine) +} + +describe('createTrackingProxy', () => { + // ─── Basic property access ─── + + it('returns primitive values from the underlying object', () => { + const state = { name: 'Alice', age: 30 } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + expect(proxy.name).toBe('Alice') + expect(proxy.age).toBe(30) + }) + + it('creates signals for accessed primitive paths', () => { + const state = { name: 'Alice', age: 30 } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + expect(registry.has('name')).toBe(false) + proxy.name + expect(registry.has('name')).toBe(true) + + expect(registry.has('age')).toBe(false) + proxy.age + expect(registry.has('age')).toBe(true) + }) + + it('does NOT create signals for unaccessed paths', () => { + const state = { name: 'Alice', age: 30, email: 'alice@example.com' } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + proxy.name // access only name + expect(registry.has('name')).toBe(true) + expect(registry.has('age')).toBe(false) + expect(registry.has('email')).toBe(false) + }) + + // ─── Nested objects ─── + + it('returns a tracking proxy for nested objects (not raw value)', () => { + const state = { user: { name: 'Alice' } } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + const userProxy = proxy.user + // Should be a proxy, not the raw object + expect(userProxy).not.toBe(state.user) + // But should have the same values + expect(userProxy.name).toBe('Alice') + }) + + it('creates signals for nested property paths', () => { + const state = { user: { name: 'Alice', address: { city: 'NYC' } } } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + proxy.user.address.city + + expect(registry.has('user')).toBe(true) + expect(registry.has('user.address')).toBe(true) + expect(registry.has('user.address.city')).toBe(true) + // Did not access user.name + expect(registry.has('user.name')).toBe(false) + }) + + it('caches child proxies within same evaluation', () => { + const state = { user: { name: 'Alice' } } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + const first = proxy.user + const second = proxy.user + expect(first).toBe(second) // same proxy instance + }) + + // ─── Array support ─── + + it('returns tracking proxies for array elements', () => { + const state = { todos: [{ id: 1, text: 'Test' }] } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + const todo = proxy.todos[0] + expect(todo.text).toBe('Test') + expect(registry.has('todos')).toBe(true) + expect(registry.has('todos.0')).toBe(true) + expect(registry.has('todos.0.text')).toBe(true) + }) + + it('tracks array length access', () => { + const state = { items: [1, 2, 3] } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + expect(proxy.items.length).toBe(3) + expect(registry.has('items.length')).toBe(true) + }) + + // ─── Frozen objects (ES Proxy invariant compliance) ─── + + it('works with Object.freeze() state', () => { + const state = Object.freeze({ + user: Object.freeze({ name: 'Alice', age: 30 }), + filter: 'all' as const, + }) + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + expect(proxy.filter).toBe('all') + expect(proxy.user.name).toBe('Alice') + expect(proxy.user.age).toBe(30) + expect(registry.has('filter')).toBe(true) + expect(registry.has('user.name')).toBe(true) + }) + + it('works with deeply frozen state (Immer-style)', () => { + const state = Object.freeze({ + todos: Object.freeze([ + Object.freeze({ id: 1, text: 'Test', completed: false }), + Object.freeze({ id: 2, text: 'Another', completed: true }), + ]), + counters: Object.freeze({ + counter1: Object.freeze({ value: 0 }), + }), + }) + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + expect(proxy.todos[0].text).toBe('Test') + expect(proxy.todos[1].completed).toBe(true) + expect(proxy.counters.counter1.value).toBe(0) + }) + + // ─── ownKeys / iteration tracking ─── + + it('tracks ownKeys access via @@keys signal', () => { + const state = { a: 1, b: 2, c: 3 } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + Object.keys(proxy) + expect(registry.has('@@keys')).toBe(true) + }) + + it('tracks nested ownKeys with correct path', () => { + const state = { user: { name: 'Alice', age: 30 } } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + Object.keys(proxy.user) + expect(registry.has('user.@@keys')).toBe(true) + }) + + it('supports for...in enumeration', () => { + const state = { a: 1, b: 2 } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + const keys: string[] = [] + for (const key in proxy) { + keys.push(key) + } + expect(keys).toEqual(['a', 'b']) + expect(registry.has('@@keys')).toBe(true) + }) + + it('supports Object.entries on proxy', () => { + const state = Object.freeze({ x: 10, y: 20 }) + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + const entries = Object.entries(proxy) + expect(entries).toEqual([ + ['x', 10], + ['y', 20], + ]) + }) + + it('supports array .map() through proxy', () => { + const state = Object.freeze({ + items: Object.freeze([ + Object.freeze({ id: 1, name: 'A' }), + Object.freeze({ id: 2, name: 'B' }), + ]), + }) + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + const names = proxy.items.map((item: { id: number; name: string }) => item.name) + expect(names).toEqual(['A', 'B']) + }) + + it('supports array .filter() through proxy', () => { + const state = Object.freeze({ + items: Object.freeze([ + Object.freeze({ id: 1, active: true }), + Object.freeze({ id: 2, active: false }), + Object.freeze({ id: 3, active: true }), + ]), + }) + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + const active = proxy.items.filter( + (item: { id: number; active: boolean }) => item.active, + ) + expect(active).toHaveLength(2) + }) + + // ─── Symbol properties ─── + + it('passes through symbol properties without creating signals', () => { + const sym = Symbol('test') + const state = { [sym]: 'secret', name: 'Alice' } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + expect(proxy[sym]).toBe('secret') + // Symbol access should not create any signals + expect(registry.size()).toBe(0) + }) + + it('supports Symbol.iterator for arrays', () => { + const state = Object.freeze({ items: Object.freeze([1, 2, 3]) }) + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + const spread = [...proxy.items] + expect(spread).toEqual([1, 2, 3]) + }) + + // ─── has trap ─── + + it('tracks "in" operator checks', () => { + const state = { name: 'Alice', age: 30 } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + expect('name' in proxy).toBe(true) + expect('missing' in proxy).toBe(false) + + // Both checked paths should have signals + expect(registry.has('name')).toBe(true) + expect(registry.has('missing')).toBe(true) + }) + + // ─── Null handling ─── + + it('treats null values as primitives (not objects)', () => { + const state = { value: null as null | string } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + expect(proxy.value).toBe(null) + expect(registry.has('value')).toBe(true) + }) + + // ─── Path segments (nested proxy creation) ─── + + it('builds correct path keys for deeply nested access', () => { + const state = { + a: { b: { c: { d: 42 } } }, + } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + expect(proxy.a.b.c.d).toBe(42) + expect(registry.has('a')).toBe(true) + expect(registry.has('a.b')).toBe(true) + expect(registry.has('a.b.c')).toBe(true) + expect(registry.has('a.b.c.d')).toBe(true) + }) + + // ─── Nesting path behavior ─── + + it('only creates signals along the accessed path, not siblings', () => { + const state = { + users: { + alice: { name: 'Alice', age: 30 }, + bob: { name: 'Bob', age: 25 }, + }, + settings: { theme: 'dark' }, + } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + // Access only alice's name + proxy.users.alice.name + + expect(registry.has('users')).toBe(true) + expect(registry.has('users.alice')).toBe(true) + expect(registry.has('users.alice.name')).toBe(true) + // Siblings NOT accessed + expect(registry.has('users.alice.age')).toBe(false) + expect(registry.has('users.bob')).toBe(false) + expect(registry.has('users.bob.name')).toBe(false) + expect(registry.has('settings')).toBe(false) + expect(registry.has('settings.theme')).toBe(false) + }) + + it('creates intermediate object signals as version counters', () => { + const state = { a: { b: { c: 42 } } } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + proxy.a.b.c + + // a and a.b are objects → version counter (0) + // a.b.c is a primitive → actual value (42) + const sigA = registry.getOrCreate('a', state.a) + const sigAB = registry.getOrCreate('a.b', state.a.b) + const sigABC = registry.getOrCreate('a.b.c', 42) + + expect(sigA.get()).toBe(0) // version counter + expect(sigAB.get()).toBe(0) // version counter + expect(sigABC.get()).toBe(42) // actual value + }) + + it('handles mixed nested types: objects, arrays, and primitives', () => { + const state = Object.freeze({ + entities: Object.freeze({ + users: Object.freeze([ + Object.freeze({ id: 1, tags: Object.freeze(['admin', 'active']) }), + ]), + }), + count: 5, + }) + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + // Traverse: entities → users → [0] → tags → [0] + const tag = proxy.entities.users[0].tags[0] + expect(tag).toBe('admin') + + expect(registry.has('entities')).toBe(true) + expect(registry.has('entities.users')).toBe(true) + expect(registry.has('entities.users.0')).toBe(true) + expect(registry.has('entities.users.0.tags')).toBe(true) + expect(registry.has('entities.users.0.tags.0')).toBe(true) + // Not accessed + expect(registry.has('entities.users.0.id')).toBe(false) + expect(registry.has('entities.users.0.tags.1')).toBe(false) + expect(registry.has('count')).toBe(false) + }) + + it('handles multiple access paths creating independent signal trees', () => { + const state = { + a: { x: 1, y: 2 }, + b: { x: 10, y: 20 }, + } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + proxy.a.x + proxy.b.y + + // Each branch creates its own path signals + expect(registry.has('a')).toBe(true) + expect(registry.has('a.x')).toBe(true) + expect(registry.has('a.y')).toBe(false) // not accessed + expect(registry.has('b')).toBe(true) + expect(registry.has('b.y')).toBe(true) + expect(registry.has('b.x')).toBe(false) // not accessed + }) + + it('array index paths use numeric string keys', () => { + const state = { items: ['zero', 'one', 'two'] } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + proxy.items[0] + proxy.items[2] + + expect(registry.has('items.0')).toBe(true) + expect(registry.has('items.2')).toBe(true) + expect(registry.has('items.1')).toBe(false) // skipped + }) + + it('nested iteration creates @@keys at each level', () => { + const state = { + data: { + nested: { a: 1, b: 2 }, + }, + } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, [], registry) + + // Iterate top-level keys + Object.keys(proxy) + // Iterate nested keys + Object.keys(proxy.data.nested) + + expect(registry.has('@@keys')).toBe(true) + expect(registry.has('data.nested.@@keys')).toBe(true) + // data.@@keys was NOT iterated + expect(registry.has('data.@@keys')).toBe(false) + }) + + it('accessing the same nested object via different parent proxies shares signals', () => { + const state = { user: { profile: { name: 'Alice' } } } + const registry = makeRegistry() + + // Two separate root proxies but same registry + const proxy1 = createTrackingProxy(state, [], registry) + const proxy2 = createTrackingProxy(state, [], registry) + + proxy1.user.profile.name + proxy2.user.profile.name + + // Should have created signals only once (same paths) + expect(registry.size()).toBe(3) // user, user.profile, user.profile.name + }) + + it('child proxies from different root proxies track same signal paths', () => { + const state = Object.freeze({ + counter: Object.freeze({ value: 0 }), + label: 'test', + }) + const registry = makeRegistry() + const scope = alienEngine.createScope() + + let comp1Calls = 0 + let comp2Calls = 0 + + // Two computeds that access the same path via different proxy instances + const c1 = scope.run(() => + alienEngine.computed(() => { + comp1Calls++ + const proxy = createTrackingProxy(state, [], registry) + return proxy.counter.value + }), + ) + const c2 = scope.run(() => + alienEngine.computed(() => { + comp2Calls++ + const proxy = createTrackingProxy(state, [], registry) + return proxy.counter.value + }), + ) + + c1.get() + c2.get() + expect(comp1Calls).toBe(1) + expect(comp2Calls).toBe(1) + + // Update counter.value → BOTH computeds should re-evaluate + registry.update('counter.value', 1) + c1.get() + c2.get() + expect(comp1Calls).toBe(2) + expect(comp2Calls).toBe(2) + + // Update label → NEITHER should re-evaluate (not tracked) + registry.update('label', 'changed') + c1.get() + c2.get() + expect(comp1Calls).toBe(2) + expect(comp2Calls).toBe(2) + + scope.stop() + }) + + it('version counter bumps propagate to computeds reading nested objects', () => { + const state = Object.freeze({ + nested: Object.freeze({ deep: Object.freeze({ value: 42 }) }), + }) + const registry = makeRegistry() + const scope = alienEngine.createScope() + + let calls = 0 + const c = scope.run(() => + alienEngine.computed(() => { + calls++ + const proxy = createTrackingProxy(state, [], registry) + // Access the intermediate object (version counter signal) + return proxy.nested.deep + }), + ) + + c.get() + expect(calls).toBe(1) + + // Bumping the version counter for 'nested.deep' should trigger re-eval + registry.update('nested.deep', {}) + c.get() + expect(calls).toBe(2) + + // Bumping a child signal should NOT trigger (we read 'nested.deep', not 'nested.deep.value') + registry.update('nested.deep.value', 99) + c.get() + expect(calls).toBe(2) + + scope.stop() + }) + + it('parent object signal does NOT fire when only a child leaf changes', () => { + const state = Object.freeze({ + parent: Object.freeze({ child: Object.freeze({ leaf: 'hello' }) }), + }) + const registry = makeRegistry() + const scope = alienEngine.createScope() + + let parentCalls = 0 + let leafCalls = 0 + + // One computed reads the parent object + const parentComp = scope.run(() => + alienEngine.computed(() => { + parentCalls++ + const proxy = createTrackingProxy(state, [], registry) + return proxy.parent + }), + ) + + // Another computed reads the leaf + const leafComp = scope.run(() => + alienEngine.computed(() => { + leafCalls++ + const proxy = createTrackingProxy(state, [], registry) + return proxy.parent.child.leaf + }), + ) + + parentComp.get() + leafComp.get() + expect(parentCalls).toBe(1) + expect(leafCalls).toBe(1) + + // Update only the leaf signal — parent signal untouched + registry.update('parent.child.leaf', 'world') + parentComp.get() + leafComp.get() + expect(parentCalls).toBe(1) // NOT re-evaluated + expect(leafCalls).toBe(2) // re-evaluated + + scope.stop() + }) + + it('handles pathSegments offset for pre-scoped proxies', () => { + const nested = { x: 1, y: 2 } + const registry = makeRegistry() + // Create proxy with a base path — simulates a proxy for a nested subtree + const proxy = createTrackingProxy(nested, ['root', 'sub'], registry) + + proxy.x + + expect(registry.has('root.sub.x')).toBe(true) + expect(registry.has('x')).toBe(false) // NOT at root level + }) + + // ─── Reactive integration ─── + + it('establishes signal dependencies for computed tracking', () => { + const state = Object.freeze({ + todos: Object.freeze([ + Object.freeze({ id: 1, text: 'Test' }), + ]), + filter: 'all' as const, + }) + const registry = makeRegistry() + + // Simulate what a computed selector would do + const scope = alienEngine.createScope() + let callCount = 0 + + const c = scope.run(() => { + return alienEngine.computed(() => { + callCount++ + const proxy = createTrackingProxy(state, [], registry) + return proxy.filter + }) + }) + + // Initial evaluation + expect(c.get()).toBe('all') + expect(callCount).toBe(1) + + // Update an unrelated signal → computed should NOT re-evaluate + // (only 'filter' signal was read, not 'todos') + if (registry.has('todos')) { + registry.update('todos', state.todos) + } + expect(c.get()).toBe('all') + expect(callCount).toBe(1) + + // Update the 'filter' signal → computed SHOULD re-evaluate on next read + registry.update('filter', 'completed') + c.get() // pull to trigger re-evaluation + expect(callCount).toBe(2) + + scope.stop() + }) + + it('tracks different dependencies for different computeds', () => { + const state = Object.freeze({ + todos: Object.freeze([Object.freeze({ id: 1, text: 'Test' })]), + counter: 0, + }) + const registry = makeRegistry() + const scope = alienEngine.createScope() + + let todoCalls = 0 + let counterCalls = 0 + + const todoComputed = scope.run(() => + alienEngine.computed(() => { + todoCalls++ + const proxy = createTrackingProxy(state, [], registry) + return proxy.todos + }), + ) + + const counterComputed = scope.run(() => + alienEngine.computed(() => { + counterCalls++ + const proxy = createTrackingProxy(state, [], registry) + return proxy.counter + }), + ) + + // Initial evaluation + todoComputed.get() + counterComputed.get() + expect(todoCalls).toBe(1) + expect(counterCalls).toBe(1) + + // Update counter → only counterComputed should re-evaluate + alienEngine.batch(() => { + registry.update('counter', 1) + }) + counterComputed.get() + todoComputed.get() + expect(counterCalls).toBe(2) + expect(todoCalls).toBe(1) // NOT called again + + // Update todos → only todoComputed should re-evaluate + alienEngine.batch(() => { + registry.update('todos', []) + }) + todoComputed.get() + counterComputed.get() + expect(todoCalls).toBe(2) + expect(counterCalls).toBe(2) // NOT called again + + scope.stop() + }) +}) From 02c2942b4b978621dc26b7db8c9e951fc9546aa7 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 4 Jun 2026 20:32:02 -0700 Subject: [PATCH 04/95] Add diff engine --- src/signals/diff.ts | 106 +++++ src/signals/pathSignalRegistry.ts | 6 +- test/signals/diff.spec.ts | 677 ++++++++++++++++++++++++++++++ 3 files changed, 787 insertions(+), 2 deletions(-) create mode 100644 src/signals/diff.ts create mode 100644 test/signals/diff.spec.ts diff --git a/src/signals/diff.ts b/src/signals/diff.ts new file mode 100644 index 000000000..0838de205 --- /dev/null +++ b/src/signals/diff.ts @@ -0,0 +1,106 @@ +import type { PathSignalRegistry } from './pathSignalRegistry' +import type { SignalEngine } from './types' + +function isPlainObject(v: unknown): v is Record { + if (v === null || typeof v !== 'object') return false + const proto = Object.getPrototypeOf(v) + return proto === Object.prototype || proto === null +} + +/** + * Walk prev and next state trees, updating path signals for changed values. + * Exploits Immer's structural sharing: `prev === next` skips entire subtrees. + */ +export function diffAndUpdateSignals( + prev: unknown, + next: unknown, + pathSegments: string[], + registry: PathSignalRegistry, +): void { + // Structural sharing: identical references mean nothing changed + if (prev === next) return + + // Both plain objects: recurse into properties + if (isPlainObject(prev) && isPlainObject(next)) { + const prevKeys = Object.keys(prev) + const nextKeys = Object.keys(next) + + // Check for key changes (additions/removals) + const keysChanged = + prevKeys.length !== nextKeys.length || + prevKeys.some((k) => !(k in next)) + + if (keysChanged) { + const keysPath = [...pathSegments, '@@keys'].join('.') + registry.update(keysPath, nextKeys) + } + + // Update the object signal itself (version bump) + const selfPath = pathSegments.join('.') + if (selfPath) { + registry.update(selfPath, next) + } + + // Recurse into all keys present in either object + const allKeys = new Set([...prevKeys, ...nextKeys]) + for (const key of allKeys) { + diffAndUpdateSignals(prev[key], next[key], [...pathSegments, key], registry) + } + return + } + + // Both arrays: handle length + index-based diffing + if (Array.isArray(prev) && Array.isArray(next)) { + const selfPath = pathSegments.join('.') + if (selfPath) { + registry.update(selfPath, next) + } + + // Length change → @@keys signal + if (prev.length !== next.length) { + const keysPath = [...pathSegments, '@@keys'].join('.') + registry.update(keysPath, next.length) + } + + // Recurse into each index + const maxLen = Math.max(prev.length, next.length) + for (let i = 0; i < maxLen; i++) { + diffAndUpdateSignals(prev[i], next[i], [...pathSegments, String(i)], registry) + } + + // Prune signals for removed indices + for (let i = next.length; i < prev.length; i++) { + registry.prune([...pathSegments, String(i)].join('.')) + } + return + } + + // Leaf value change (primitive, or type mismatch like object→primitive) + const pathKey = pathSegments.join('.') + if (pathKey) { + registry.update(pathKey, next) + } + + // If prev was an object/array and next is not, prune child signals + if ( + prev !== null && + typeof prev === 'object' && + (next === null || typeof next !== 'object') + ) { + registry.prune(pathKey) + } +} + +/** + * Wrapper that batches all signal updates into a single propagation pass. + */ +export function reconcileState( + prev: unknown, + next: unknown, + registry: PathSignalRegistry, + engine: SignalEngine, +): void { + engine.batch(() => { + diffAndUpdateSignals(prev, next, [], registry) + }) +} diff --git a/src/signals/pathSignalRegistry.ts b/src/signals/pathSignalRegistry.ts index 1f466f409..24f60a8e0 100644 --- a/src/signals/pathSignalRegistry.ts +++ b/src/signals/pathSignalRegistry.ts @@ -45,8 +45,10 @@ export function createPathSignalRegistry( if (!sig) return // no one is tracking this path — skip if (isObjectOrArray(newValue)) { - // Bump version counter — signals something in this subtree changed - sig.set((sig.get() as number) + 1) + // Bump version counter — signals something in this subtree changed. + // If the signal previously held a primitive (type transition), reset to 0. + const current = sig.get() + sig.set(typeof current === 'number' ? current + 1 : 0) } else { sig.set(newValue) } diff --git a/test/signals/diff.spec.ts b/test/signals/diff.spec.ts new file mode 100644 index 000000000..a151e472d --- /dev/null +++ b/test/signals/diff.spec.ts @@ -0,0 +1,677 @@ +import { describe, it, expect } from 'vitest' +import { alienEngine } from '../../src/signals/engine' +import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import { createTrackingProxy } from '../../src/signals/trackingProxy' +import { + diffAndUpdateSignals, + reconcileState, +} from '../../src/signals/diff' +import type { PathSignalRegistry } from '../../src/signals/pathSignalRegistry' + +// Helper: create a registry with signals pre-populated by running a selector through a tracking proxy +function setupRegistry(state: object, selectorFn: (s: any) => void) { + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, [], registry) + selectorFn(proxy) + return registry +} + +// Helper: freeze deeply (Immer-style) +function deepFreeze(obj: T): T { + if (obj === null || typeof obj !== 'object') return obj + Object.freeze(obj) + for (const val of Object.values(obj as Record)) { + if (val !== null && typeof val === 'object' && !Object.isFrozen(val)) { + deepFreeze(val) + } + } + return obj +} + +describe('diffAndUpdateSignals', () => { + describe('structural sharing short-circuits', () => { + it('skips entirely when prev === next', () => { + const state = deepFreeze({ todos: [{ id: 1, text: 'hi' }], filter: 'all' }) + const registry = setupRegistry(state, (s) => { + s.todos[0].text + s.filter + }) + + // Get initial signal values + const textSig = registry.getOrCreate('todos.0.text', 'hi') + const filterSig = registry.getOrCreate('filter', 'all') + const initialText = textSig.get() + const initialFilter = filterSig.get() + + // Diff with same reference — nothing should change + diffAndUpdateSignals(state, state, [], registry) + + expect(textSig.get()).toBe(initialText) + expect(filterSig.get()).toBe(initialFilter) + }) + + it('skips unchanged subtrees (Immer structural sharing)', () => { + const todos = deepFreeze([{ id: 1, text: 'hi', completed: false }]) + const prev = deepFreeze({ todos, filter: 'all', count: 0 }) + // Immer-style: share todos ref, only change count + const next = deepFreeze({ todos, filter: 'all', count: 1 }) + + const registry = setupRegistry(prev, (s) => { + s.todos[0].text + s.filter + s.count + }) + + const todoTextSig = registry.getOrCreate('todos.0.text', 'hi') + const todosSig = registry.getOrCreate('todos', todos) + const initialTodosVersion = todosSig.get() + const initialText = todoTextSig.get() + + diffAndUpdateSignals(prev, next, [], registry) + + // todos subtree is shared — signals should NOT be updated + expect(todosSig.get()).toBe(initialTodosVersion) + expect(todoTextSig.get()).toBe(initialText) + // count should be updated + expect(registry.getOrCreate('count', 1).get()).toBe(1) + }) + }) + + describe('primitive changes', () => { + it('updates primitive leaf signals', () => { + const prev = { name: 'Alice', age: 30 } + const next = { name: 'Bob', age: 30 } + + const registry = setupRegistry(prev, (s) => { + s.name + s.age + }) + + diffAndUpdateSignals(prev, next, [], registry) + + expect(registry.getOrCreate('name', 'Bob').get()).toBe('Bob') + expect(registry.getOrCreate('age', 30).get()).toBe(30) // unchanged + }) + + it('handles number changes', () => { + const prev = { value: 0 } + const next = { value: 42 } + const registry = setupRegistry(prev, (s) => s.value) + + diffAndUpdateSignals(prev, next, [], registry) + + expect(registry.getOrCreate('value', 42).get()).toBe(42) + }) + + it('handles boolean changes', () => { + const prev = { active: true } + const next = { active: false } + const registry = setupRegistry(prev, (s) => s.active) + + diffAndUpdateSignals(prev, next, [], registry) + + expect(registry.getOrCreate('active', false).get()).toBe(false) + }) + + it('handles null ↔ value changes', () => { + const prev = { data: null as string | null } + const next = { data: 'loaded' } + const registry = setupRegistry(prev, (s) => s.data) + + diffAndUpdateSignals(prev, next, [], registry) + + expect(registry.getOrCreate('data', 'loaded').get()).toBe('loaded') + }) + + it('handles undefined ↔ value changes', () => { + const prev = { data: undefined as string | undefined } + const next = { data: 'loaded' } + const registry = setupRegistry(prev, (s) => s.data) + + diffAndUpdateSignals(prev, next, [], registry) + + expect(registry.getOrCreate('data', 'loaded').get()).toBe('loaded') + }) + }) + + describe('object changes', () => { + it('bumps version counter for changed objects', () => { + const prev = { user: { name: 'Alice' } } + const next = { user: { name: 'Bob' } } + + const registry = setupRegistry(prev, (s) => s.user.name) + + const userSig = registry.getOrCreate('user', prev.user) + const initialVersion = userSig.get() as number + + diffAndUpdateSignals(prev, next, [], registry) + + expect(userSig.get()).toBe(initialVersion + 1) + expect(registry.getOrCreate('user.name', 'Bob').get()).toBe('Bob') + }) + + it('does not update untracked paths', () => { + const prev = { a: 1, b: 2 } + const next = { a: 1, b: 99 } + // Only track 'a', not 'b' + const registry = setupRegistry(prev, (s) => s.a) + + const initialSize = registry.size() + diffAndUpdateSignals(prev, next, [], registry) + + // 'b' was never tracked, so no signal should be created for it + expect(registry.has('b')).toBe(false) + // Size should not have grown (update on untracked is a no-op) + expect(registry.size()).toBe(initialSize) + }) + + it('handles deeply nested changes', () => { + const prev = deepFreeze({ + a: { b: { c: { d: 'original' } } }, + }) + const next = deepFreeze({ + a: { b: { c: { d: 'changed' } } }, + }) + + const registry = setupRegistry(prev, (s) => s.a.b.c.d) + + diffAndUpdateSignals(prev, next, [], registry) + + expect(registry.getOrCreate('a.b.c.d', 'changed').get()).toBe('changed') + // All parent objects should have version bumps + const aSig = registry.getOrCreate('a', next.a) + const bSig = registry.getOrCreate('a.b', next.a.b) + const cSig = registry.getOrCreate('a.b.c', next.a.b.c) + // They are version counters (numbers), not 0 (initial) + expect(typeof aSig.get()).toBe('number') + expect((aSig.get() as number)).toBeGreaterThan(0) + }) + }) + + describe('key changes (additions/removals)', () => { + it('updates @@keys signal when a key is added', () => { + const prev = { a: 1 } + const next = { a: 1, b: 2 } + + const registry = setupRegistry(prev, (s) => { + Object.keys(s) // tracks @@keys + s.a + }) + + const keysSig = registry.getOrCreate('@@keys', Object.keys(prev)) + const initialKeysVersion = keysSig.get() + + diffAndUpdateSignals(prev, next, [], registry) + + // @@keys signal should have been updated + expect(keysSig.get()).not.toBe(initialKeysVersion) + }) + + it('updates @@keys signal when a key is removed', () => { + const prev = { a: 1, b: 2 } + const next = { a: 1 } + + const registry = setupRegistry(prev, (s) => { + Object.keys(s) + s.a + s.b + }) + + const keysSig = registry.getOrCreate('@@keys', Object.keys(prev)) + const initialKeysVersion = keysSig.get() + + diffAndUpdateSignals(prev, next, [], registry) + + expect(keysSig.get()).not.toBe(initialKeysVersion) + }) + + it('does not update @@keys when keys are unchanged', () => { + const prev = { a: 1, b: 2 } + const next = { a: 1, b: 99 } + + const registry = setupRegistry(prev, (s) => { + Object.keys(s) + s.a + s.b + }) + + const keysSig = registry.getOrCreate('@@keys', Object.keys(prev)) + const initialKeysVersion = keysSig.get() + + diffAndUpdateSignals(prev, next, [], registry) + + // Keys didn't change, only values + expect(keysSig.get()).toBe(initialKeysVersion) + }) + + it('handles nested key changes', () => { + const prev = { entities: { user1: { name: 'Alice' } } } + const next = { entities: { user1: { name: 'Alice' }, user2: { name: 'Bob' } } } + + const registry = setupRegistry(prev, (s) => { + Object.keys(s.entities) + s.entities.user1.name + }) + + const entitiesKeysSig = registry.getOrCreate('entities.@@keys', Object.keys(prev.entities)) + const initialVersion = entitiesKeysSig.get() + + diffAndUpdateSignals(prev, next, [], registry) + + expect(entitiesKeysSig.get()).not.toBe(initialVersion) + }) + }) + + describe('array mutations', () => { + it('handles array item property change', () => { + const prev = deepFreeze({ + todos: [ + { id: 1, text: 'first', completed: false }, + { id: 2, text: 'second', completed: false }, + ], + }) + const next = deepFreeze({ + todos: [ + { id: 1, text: 'first', completed: true }, // changed + { id: 2, text: 'second', completed: false }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.todos[0].completed + s.todos[1].completed + }) + + diffAndUpdateSignals(prev, next, [], registry) + + expect(registry.getOrCreate('todos.0.completed', true).get()).toBe(true) + // todos[1] unchanged (structural sharing would make this ===) + // But since we deepFreeze manually, they're different refs. In real Immer, they'd be ===. + // The test verifies the signal update mechanism works. + }) + + it('handles array item addition (push)', () => { + const prev = deepFreeze({ todos: [{ id: 1, text: 'first' }] }) + const next = deepFreeze({ todos: [{ id: 1, text: 'first' }, { id: 2, text: 'second' }] }) + + const registry = setupRegistry(prev, (s) => { + s.todos.length + s.todos[0].text + }) + + // Track @@keys + registry.getOrCreate('todos.@@keys', 1).get() + + const keysSignal = registry.getOrCreate('todos.@@keys', 1) + const initialKeysVersion = keysSignal.get() + + diffAndUpdateSignals(prev, next, [], registry) + + // @@keys should update (length changed) + expect(keysSignal.get()).not.toBe(initialKeysVersion) + }) + + it('handles array item removal and prunes child signals', () => { + const prev = deepFreeze({ + todos: [ + { id: 1, text: 'first' }, + { id: 2, text: 'second' }, + { id: 3, text: 'third' }, + ], + }) + const next = deepFreeze({ + todos: [ + { id: 1, text: 'first' }, + { id: 2, text: 'second' }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.todos[0].text + s.todos[1].text + s.todos[2].text + }) + + expect(registry.has('todos.2.text')).toBe(true) + expect(registry.has('todos.2')).toBe(true) + + diffAndUpdateSignals(prev, next, [], registry) + + // Signals for removed index should be pruned + expect(registry.has('todos.2.text')).toBe(false) + expect(registry.has('todos.2')).toBe(false) + // Remaining indices should still exist + expect(registry.has('todos.0.text')).toBe(true) + expect(registry.has('todos.1.text')).toBe(true) + }) + + it('handles array length decrease with @@keys update', () => { + const prev = [1, 2, 3] + const next = [1] + + const registry = createPathSignalRegistry(alienEngine) + // Manually track some paths + const proxy = createTrackingProxy({ items: prev }, [], registry) + proxy.items.length // triggers @@keys and items tracking + + const keysPath = 'items.@@keys' + registry.getOrCreate(keysPath, prev.length) + + diffAndUpdateSignals( + { items: prev }, + { items: next }, + [], + registry, + ) + + // @@keys updated for length change + // The items signal itself should be version-bumped + }) + + it('handles empty array to non-empty', () => { + const prev = { items: [] as number[] } + const next = { items: [1, 2, 3] } + + const registry = setupRegistry(prev, (s) => { + s.items.length + }) + + registry.getOrCreate('items.@@keys', 0) + + diffAndUpdateSignals(prev, next, [], registry) + + // @@keys should be updated + const keysSignal = registry.getOrCreate('items.@@keys', 3) + // items should be version-bumped + const itemsSig = registry.getOrCreate('items', next.items) + expect(typeof itemsSig.get()).toBe('number') + }) + }) + + describe('type mismatches', () => { + it('handles object → primitive change', () => { + const prev = { data: { nested: 'value' } as unknown } + const next = { data: 'flat' as unknown } + + const registry = setupRegistry(prev, (s) => { + ;(s.data as any).nested + }) + + expect(registry.has('data.nested')).toBe(true) + + diffAndUpdateSignals(prev, next, [], registry) + + // Child signals should be pruned when parent becomes primitive + expect(registry.has('data.nested')).toBe(false) + }) + + it('handles primitive → object change', () => { + const prev = { data: 'flat' as unknown } + const next = { data: { nested: 'value' } as unknown } + + const registry = setupRegistry(prev, (s) => s.data) + + const dataSig = registry.getOrCreate('data', 'flat') + expect(dataSig.get()).toBe('flat') + + diffAndUpdateSignals(prev, next, [], registry) + + // Signal transitions from primitive to version counter (resets to 0) + expect(dataSig.get()).toBe(0) + }) + + it('handles array → object change', () => { + const prev = { data: [1, 2, 3] as unknown } + const next = { data: { a: 1 } as unknown } + + const registry = setupRegistry(prev, (s) => { + ;(s.data as any)[0] + ;(s.data as any)[1] + }) + + expect(registry.has('data.0')).toBe(true) + expect(registry.has('data.1')).toBe(true) + + diffAndUpdateSignals(prev, next, [], registry) + + // Array index signals should be pruned (type mismatch) + // The path 'data' gets updated, and since prev was array and next is object, + // only the leaf update path runs — no pruning of children in this case + // because both are objects. But array→object means different key structures. + }) + }) + + describe('reactive propagation', () => { + it('triggers computed that depends on changed path signal', () => { + const prev = deepFreeze({ counter: 0, label: 'test' }) + const next = deepFreeze({ counter: 1, label: 'test' }) + + const registry = setupRegistry(prev, (s) => s.counter) + + let computedRuns = 0 + const counterSig = registry.getOrCreate('counter', 0) + const derived = alienEngine.computed(() => { + computedRuns++ + return counterSig.get() + }) + + // Initial read + expect(derived.get()).toBe(0) + const initialRuns = computedRuns + + diffAndUpdateSignals(prev, next, [], registry) + + expect(derived.get()).toBe(1) + expect(computedRuns).toBe(initialRuns + 1) + }) + + it('does NOT trigger computed that depends on unchanged path', () => { + const todos = deepFreeze([{ id: 1, text: 'hi' }]) + const prev = deepFreeze({ todos, counter: 0 }) + const next = Object.freeze({ todos, counter: 1 }) // share todos ref + + const registry = setupRegistry(prev, (s) => { + s.todos[0].text + s.counter + }) + + let todoComputedRuns = 0 + const todoTextSig = registry.getOrCreate('todos.0.text', 'hi') + const todoComputed = alienEngine.computed(() => { + todoComputedRuns++ + return todoTextSig.get() + }) + + // Initial read + todoComputed.get() + const initialRuns = todoComputedRuns + + diffAndUpdateSignals(prev, next, [], registry) + + // Re-read — should NOT have re-run because todos was shared + todoComputed.get() + expect(todoComputedRuns).toBe(initialRuns) + }) + }) + + describe('NOOP dispatches', () => { + it('does nothing when state is identical reference', () => { + const state = deepFreeze({ a: 1, b: { c: 2 } }) + const registry = setupRegistry(state, (s) => { + s.a + s.b.c + }) + + const aSig = registry.getOrCreate('a', 1) + const cSig = registry.getOrCreate('b.c', 2) + const initialA = aSig.get() + const initialC = cSig.get() + + // Same reference — should be immediate no-op + diffAndUpdateSignals(state, state, [], registry) + + expect(aSig.get()).toBe(initialA) + expect(cSig.get()).toBe(initialC) + }) + }) + + describe('reconcileState (batched)', () => { + it('wraps diff in a batch for single propagation pass', () => { + const prev = { a: 1, b: 2, c: 3 } + const next = { a: 10, b: 20, c: 30 } + + const registry = setupRegistry(prev, (s) => { + s.a + s.b + s.c + }) + + let effectRuns = 0 + const aSig = registry.getOrCreate('a', 1) + const bSig = registry.getOrCreate('b', 2) + const cSig = registry.getOrCreate('c', 3) + + const derived = alienEngine.computed(() => { + return (aSig.get() as number) + (bSig.get() as number) + (cSig.get() as number) + }) + + const scope = alienEngine.createScope() + scope.run(() => { + alienEngine.effect(() => { + derived.get() + effectRuns++ + }) + }) + + const initialRuns = effectRuns + + // reconcileState should batch all updates + reconcileState(prev, next, registry, alienEngine) + + // Effect should fire exactly once (not 3 times) + expect(effectRuns).toBe(initialRuns + 1) + expect(derived.get()).toBe(60) + + scope.stop() + }) + + it('propagates all changes atomically', () => { + const prev = deepFreeze({ x: 1, y: 2 }) + const next = deepFreeze({ x: 10, y: 20 }) + + const registry = setupRegistry(prev, (s) => { + s.x + s.y + }) + + const xSig = registry.getOrCreate('x', 1) + const ySig = registry.getOrCreate('y', 2) + + const snapshots: { x: unknown; y: unknown }[] = [] + const derived = alienEngine.computed(() => ({ + x: xSig.get(), + y: ySig.get(), + })) + + const scope = alienEngine.createScope() + scope.run(() => { + alienEngine.effect(() => { + const val = derived.get() + snapshots.push(val) + }) + }) + + reconcileState(prev, next, registry, alienEngine) + + // Should only see final state, not intermediate {x:10, y:2} + const lastSnapshot = snapshots[snapshots.length - 1] + expect(lastSnapshot).toEqual({ x: 10, y: 20 }) + + scope.stop() + }) + }) + + describe('frozen state (Immer-style)', () => { + it('handles deeply frozen state correctly', () => { + const prev = deepFreeze({ + todos: [ + { id: 1, text: 'first', completed: false }, + { id: 2, text: 'second', completed: false }, + ], + counters: { + counter1: { value: 0, label: 'A' }, + counter2: { value: 0, label: 'B' }, + }, + filter: 'all', + }) + + // Immer-style update: share unchanged subtrees + const next = deepFreeze({ + todos: prev.todos, // shared ref + counters: { + counter1: { value: 1, label: 'A' }, // changed + counter2: prev.counters.counter2, // shared ref + }, + filter: 'all', + }) + + const registry = setupRegistry(prev, (s) => { + s.todos[0].text + s.counters.counter1.value + s.counters.counter2.value + s.filter + }) + + const todoTextSig = registry.getOrCreate('todos.0.text', 'first') + const c1ValueSig = registry.getOrCreate('counters.counter1.value', 0) + const c2ValueSig = registry.getOrCreate('counters.counter2.value', 0) + const filterSig = registry.getOrCreate('filter', 'all') + + const initialTodoText = todoTextSig.get() + const initialC2Value = c2ValueSig.get() + const initialFilter = filterSig.get() + + reconcileState(prev, next, registry, alienEngine) + + // Only counter1.value should have changed + expect(c1ValueSig.get()).toBe(1) + + // Everything else should be unchanged + expect(todoTextSig.get()).toBe(initialTodoText) + expect(c2ValueSig.get()).toBe(initialC2Value) + expect(filterSig.get()).toBe(initialFilter) + }) + }) + + describe('edge cases', () => { + it('handles empty objects', () => { + const prev = {} + const next = { a: 1 } + const registry = createPathSignalRegistry(alienEngine) + + // Should not throw + diffAndUpdateSignals(prev, next, [], registry) + }) + + it('handles root-level primitive comparison', () => { + // Edge case: comparing primitives at root level + const registry = createPathSignalRegistry(alienEngine) + + // Should not throw — just returns because prev !== next but neither is object + diffAndUpdateSignals('old', 'new', ['root'], registry) + }) + + it('handles nested path offset correctly', () => { + const prev = { value: 10 } + const next = { value: 20 } + + const registry = createPathSignalRegistry(alienEngine) + registry.getOrCreate('nested.obj.value', 10) + + // Diff starting from a path offset + diffAndUpdateSignals(prev, next, ['nested', 'obj'], registry) + + expect(registry.getOrCreate('nested.obj.value', 20).get()).toBe(20) + }) + }) +}) From 4e400d89bbb48e164c84e2c494588ae6b2cfca62 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 4 Jun 2026 21:13:52 -0700 Subject: [PATCH 05/95] Implement initial useSignalSelector and SignalProvider --- src/signals/SignalProvider.tsx | 102 +++ src/signals/context.ts | 37 + src/signals/index.ts | 23 + src/signals/useSignalSelector.ts | 97 +++ test/signals/useSignalSelector.spec.tsx | 865 ++++++++++++++++++++++++ 5 files changed, 1124 insertions(+) create mode 100644 src/signals/SignalProvider.tsx create mode 100644 src/signals/context.ts create mode 100644 src/signals/index.ts create mode 100644 src/signals/useSignalSelector.ts create mode 100644 test/signals/useSignalSelector.spec.tsx diff --git a/src/signals/SignalProvider.tsx b/src/signals/SignalProvider.tsx new file mode 100644 index 000000000..d20b8967f --- /dev/null +++ b/src/signals/SignalProvider.tsx @@ -0,0 +1,102 @@ +import type { Context } from 'react' +import { React } from '../utils/react' +import type { Action, UnknownAction } from 'redux' +import type { ReactReduxContextValue } from '../components/Context' +import { ReactReduxContext } from '../components/Context' +import type { ProviderProps } from '../components/Provider' +import { createSubscription } from '../utils/Subscription' +import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' +import type { SignalContextValue } from './context' +import { createPathSignalRegistry } from './pathSignalRegistry' +import { reconcileState } from './diff' +import { alienEngine } from './engine' +import type { SignalEngine } from './types' + +export interface SignalProviderProps< + A extends Action = UnknownAction, + S = unknown, +> extends ProviderProps { + engine?: SignalEngine +} + +export function SignalProvider< + S extends object, + A extends Action = UnknownAction, +>(providerProps: SignalProviderProps) { + const { + children, + context, + serverState, + store, + engine = alienEngine, + } = providerProps + + // Create signal registry once (lazy init via ref) + const registryRef = React.useRef(createPathSignalRegistry(engine)) + const registry = registryRef.current + + // Track previous state for signal diffing + const prevStateRef = React.useRef(store.getState()) + + // Build context value: standard ReactReduxContextValue + signal fields + const contextValue = React.useMemo(() => { + const subscription = createSubscription(store) + + const baseContextValue: SignalContextValue = { + store, + subscription, + getServerState: serverState ? () => serverState : undefined, + registry, + engine, + } + + if (process.env.NODE_ENV === 'production') { + return baseContextValue + } else { + const { identityFunctionCheck = 'once', stabilityCheck = 'once' } = + providerProps + + return Object.assign(baseContextValue, { + stabilityCheck, + identityFunctionCheck, + }) + } + }, [store, serverState, registry, engine]) + + const previousState = React.useMemo(() => store.getState(), [store]) + + // Standard Provider subscription logic (from components/Provider.tsx) + // + signal diff on each dispatch + useIsomorphicLayoutEffect(() => { + const { subscription } = contextValue + subscription.onStateChange = () => { + // Run signal diff BEFORE notifying nested subs, so computed values + // are up-to-date when useSelector/useSignalSelector read them + const prev = prevStateRef.current + const next = store.getState() + prevStateRef.current = next + reconcileState(prev, next, registry, engine) + + subscription.notifyNestedSubs() + } + subscription.trySubscribe() + + if (previousState !== store.getState()) { + subscription.notifyNestedSubs() + } + return () => { + subscription.tryUnsubscribe() + subscription.onStateChange = undefined + } + }, [contextValue, previousState]) + + const Context = (context || ReactReduxContext) as Context< + ReactReduxContextValue | null + > + + return ( + }> + {children} + + ) +} diff --git a/src/signals/context.ts b/src/signals/context.ts new file mode 100644 index 000000000..05c1fc77c --- /dev/null +++ b/src/signals/context.ts @@ -0,0 +1,37 @@ +import type { Action, UnknownAction } from 'redux' +import { React } from '../utils/react' +import type { ReactReduxContextValue } from '../components/Context' +import { ReactReduxContext } from '../components/Context' +import type { PathSignalRegistry } from './pathSignalRegistry' +import type { SignalEngine } from './types' + +export interface SignalContextValue< + SS = any, + A extends Action = UnknownAction, +> extends ReactReduxContextValue { + registry: PathSignalRegistry + engine: SignalEngine +} + +export { ReactReduxContext } + +export function useSignalContext(): SignalContextValue { + const contextValue = React.useContext( + ReactReduxContext as React.Context | null>, + ) + + if (!contextValue) { + throw new Error( + 'useSignalSelector must be used within a ', + ) + } + + // Verify this is actually a signal context (has registry + engine) + if (!('registry' in contextValue) || !('engine' in contextValue)) { + throw new Error( + 'useSignalSelector must be used within a , not a regular ', + ) + } + + return contextValue +} diff --git a/src/signals/index.ts b/src/signals/index.ts new file mode 100644 index 000000000..78bfe425f --- /dev/null +++ b/src/signals/index.ts @@ -0,0 +1,23 @@ +export { SignalProvider } from './SignalProvider' +export type { SignalProviderProps } from './SignalProvider' + +export { useSignalSelector } from './useSignalSelector' + +export { useSignalContext } from './context' +export type { SignalContextValue } from './context' + +export { alienEngine } from './engine' +export type { + PathKey, + ReactiveSignal, + ReactiveComputed, + SignalEngine, + SignalScope, +} from './types' + +export { createPathSignalRegistry } from './pathSignalRegistry' +export type { PathSignalRegistry } from './pathSignalRegistry' + +export { createTrackingProxy } from './trackingProxy' + +export { diffAndUpdateSignals, reconcileState } from './diff' diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts new file mode 100644 index 000000000..da605256d --- /dev/null +++ b/src/signals/useSignalSelector.ts @@ -0,0 +1,97 @@ +import { React } from '../utils/react' +import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' +import { useSignalContext } from './context' +import { createTrackingProxy } from './trackingProxy' + +const { useRef, useMemo, useEffect, useSyncExternalStore } = React + +/** + * A React hook that selects state from a Redux store using signal-based + * dependency tracking. Only re-renders when the selected value actually + * changes, with O(k) selectivity where k = number of affected selectors. + * + * Must be used within a . + */ +export function useSignalSelector( + selector: (state: S) => R, + equalityFn: (a: R, b: R) => boolean = Object.is, +): R { + const { store, registry, engine } = useSignalContext() + + // Track latest selector/equalityFn via refs, synced in layout effect (not during render) + const selectorRef = useRef(selector) + const equalityFnRef = useRef(equalityFn) + useIsomorphicLayoutEffect(() => { + selectorRef.current = selector + equalityFnRef.current = equalityFn + }) + + // Create the signal bridge once (stable across renders) + const bridge = useMemo(() => { + let currentResult: R + let version = 0 + let notifyReact: (() => void) | null = null + let effectDispose: (() => void) | null = null + + // Create a computed that runs the selector through a tracking proxy. + // This establishes signal dependencies on the paths the selector reads. + const selectorComputed = engine.computed(() => { + const state = store.getState() as S & object + const proxy = createTrackingProxy(state, [], registry) + return selectorRef.current(proxy as S) + }) + + // Initialize with current value + currentResult = selectorComputed.get() + + return { + subscribe(onStoreChange: () => void): () => void { + notifyReact = onStoreChange + + // Create an effect that fires when the computed value changes. + // We apply the user's equalityFn here since alien-signals + // doesn't support custom equality per-computed. + const scope = engine.createScope() + effectDispose = scope.run(() => { + let isFirst = true + return engine.effect(() => { + const newValue = selectorComputed.get() + + if (isFirst) { + // First effect run — just establish tracking, don't notify + isFirst = false + return + } + + // Apply user's equality function + if (!equalityFnRef.current(currentResult, newValue)) { + currentResult = newValue + version++ + notifyReact?.() + } + }) + }) + + return () => { + notifyReact = null + effectDispose?.() + effectDispose = null + } + }, + + getSnapshot(): R { + return currentResult + }, + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [store, registry, engine]) + + // Cleanup scope on unmount + useEffect(() => { + return () => { + // bridge.subscribe's cleanup handles effect disposal + } + }, [bridge]) + + return useSyncExternalStore(bridge.subscribe, bridge.getSnapshot) +} diff --git a/test/signals/useSignalSelector.spec.tsx b/test/signals/useSignalSelector.spec.tsx new file mode 100644 index 000000000..8d954a06f --- /dev/null +++ b/test/signals/useSignalSelector.spec.tsx @@ -0,0 +1,865 @@ +/* eslint-disable react/prop-types */ + +import * as rtl from '@testing-library/react' +import React, { useLayoutEffect } from 'react' +import { configureStore, createSlice, type PayloadAction } from '@reduxjs/toolkit' +import { SignalProvider, useSignalSelector } from '../../src/signals' + +// ============================================================================ +// Test State Shape (RTK slices) +// ============================================================================ + +interface Todo { + id: number + text: string + completed: boolean +} + +interface Counter { + id: string + label: string + value: number +} + +const todosSlice = createSlice({ + name: 'todos', + initialState: [ + { id: 1, text: 'Learn Redux', completed: true }, + { id: 2, text: 'Learn Signals', completed: false }, + { id: 3, text: 'Build POC', completed: false }, + ] as Todo[], + reducers: { + toggle(state, action: PayloadAction) { + const todo = state.find((t) => t.id === action.payload) + if (todo) todo.completed = !todo.completed + }, + add(state, action: PayloadAction<{ id: number; text: string }>) { + state.push({ id: action.payload.id, text: action.payload.text, completed: false }) + }, + remove(state, action: PayloadAction) { + return state.filter((t) => t.id !== action.payload) + }, + }, +}) + +const countersSlice = createSlice({ + name: 'counters', + initialState: { + counter1: { id: 'counter1', label: 'Clicks', value: 0 } as Counter, + counter2: { id: 'counter2', label: 'Views', value: 42 } as Counter, + }, + reducers: { + increment(state, action: PayloadAction<'counter1' | 'counter2'>) { + state[action.payload].value += 1 + }, + }, +}) + +const filterSlice = createSlice({ + name: 'filter', + initialState: 'all' as 'all' | 'active' | 'completed', + reducers: { + set(_state, action: PayloadAction<'all' | 'active' | 'completed'>) { + return action.payload + }, + }, +}) + +function createTestStore() { + return configureStore({ + reducer: { + todos: todosSlice.reducer, + counters: countersSlice.reducer, + filter: filterSlice.reducer, + }, + }) +} + +type TestStore = ReturnType +type TestState = ReturnType + +// ============================================================================ +// Test Helpers +// ============================================================================ + +function createTrackedSelector( + selector: (state: S) => T, +): { selector: (state: S) => T; getCalls: () => number; reset: () => void } { + let calls = 0 + const tracked = (state: S): T => { + calls++ + return selector(state) + } + return { + selector: tracked, + getCalls: () => calls, + reset: () => { + calls = 0 + }, + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('useSignalSelector', () => { + let store: TestStore + + beforeEach(() => { + store = createTestStore() + }) + + afterEach(() => { + rtl.cleanup() + }) + + // ========================================================================== + // Core Functionality (ported from prototype #1) + // ========================================================================== + + describe('core functionality', () => { + it('selects the state on initial render', () => { + let result: string | undefined + const Comp = () => { + const filter = useSignalSelector((state: TestState) => state.filter) + + useLayoutEffect(() => { + result = filter + }, [filter]) + + return
{filter}
+ } + + rtl.render( + + + , + ) + + expect(result).toBe('all') + }) + + it('selects the state and renders when store updates', () => { + const selector = vi.fn((state: TestState) => state.filter) + let result: string | undefined + + const Comp = () => { + const filter = useSignalSelector(selector) + + useLayoutEffect(() => { + result = filter + }, [filter]) + + return
{filter}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(result).toBe('all') + + rtl.act(() => { + store.dispatch(filterSlice.actions.set('active')) + }) + + expect(getByTestId('filter').textContent).toBe('active') + expect(result).toBe('active') + }) + + it('handles nested object selection', () => { + let result: number | undefined + const Comp = () => { + const value = useSignalSelector( + (state: TestState) => state.counters.counter1.value, + ) + + useLayoutEffect(() => { + result = value + }, [value]) + + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(result).toBe(0) + + rtl.act(() => { + store.dispatch(countersSlice.actions.increment('counter1')) + }) + + expect(getByTestId('value').textContent).toBe('1') + expect(result).toBe(1) + }) + + it('handles array selection', () => { + let result: Todo | undefined + const Comp = () => { + const todo = useSignalSelector((state: TestState) => state.todos[0]) + + useLayoutEffect(() => { + result = todo + }, [todo]) + + return ( +
{todo.completed ? 'done' : 'pending'}
+ ) + } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(result?.completed).toBe(true) + + rtl.act(() => { + store.dispatch(todosSlice.actions.toggle(1)) + }) + + expect(getByTestId('todo').textContent).toBe('pending') + expect(result?.completed).toBe(false) + }) + + it('unsubscribes when component unmounts', () => { + const selector = vi.fn((state: TestState) => state.filter) + + const Comp = () => { + const filter = useSignalSelector(selector) + return
{filter}
+ } + + const { unmount } = rtl.render( + + + , + ) + + const callsBeforeUnmount = selector.mock.calls.length + + unmount() + + // Dispatch after unmount + store.dispatch(filterSlice.actions.set('completed')) + + // Selector should not have been called again + expect(selector.mock.calls.length).toBe(callsBeforeUnmount) + }) + }) + + // ========================================================================== + // Selector Identity Handling (ported from prototype #1) + // ========================================================================== + + describe('selector identity handling', () => { + it('handles inline selectors', () => { + const renders: string[] = [] + const Comp = () => { + // Inline selector - new function every render + const filter = useSignalSelector((state: TestState) => state.filter) + renders.push(filter) + return
{filter}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(renders[0]).toBe('all') + + rtl.act(() => { + store.dispatch(filterSlice.actions.set('active')) + }) + + expect(getByTestId('filter').textContent).toBe('active') + expect(renders[renders.length - 1]).toBe('active') + }) + + it('re-tracks when selector closure changes via key remount', () => { + const CounterDisplay = ({ id }: { id: 'counter1' | 'counter2' }) => { + const counter = useSignalSelector( + (state: TestState) => state.counters[id], + ) + return ( +
+ {counter?.label}: {counter?.value} +
+ ) + } + + const Parent = () => { + const [selectedId, setSelectedId] = React.useState< + 'counter1' | 'counter2' + >('counter1') + return ( + + + + + ) + } + + const { getByTestId, getByText } = rtl.render() + + expect(getByTestId('display-counter1').textContent).toBe('Clicks: 0') + + // Switch to counter2 + rtl.act(() => { + getByText('Switch').click() + }) + + expect(getByTestId('display-counter2').textContent).toBe('Views: 42') + }) + }) + + // ========================================================================== + // Selective Execution — Key POC Validation (ported from prototype #1) + // ========================================================================== + + describe('selective execution (key POC validation)', () => { + it('todo selector does not run when counter changes', () => { + let todoSelectorCalls = 0 + let counterSelectorCalls = 0 + + const TodoComp = () => { + const todo = useSignalSelector((state: TestState) => { + todoSelectorCalls++ + return state.todos[0] + }) + return
{todo.text}
+ } + + const CounterComp = () => { + const counter = useSignalSelector((state: TestState) => { + counterSelectorCalls++ + return state.counters.counter1 + }) + return
{counter.value}
+ } + + rtl.render( + + + + , + ) + + expect(todoSelectorCalls).toBeGreaterThanOrEqual(1) + expect(counterSelectorCalls).toBeGreaterThanOrEqual(1) + + const todoCallsBefore = todoSelectorCalls + const counterCallsBefore = counterSelectorCalls + + rtl.act(() => { + store.dispatch(countersSlice.actions.increment('counter1')) + }) + + // Counter selector should have run, todo selector should NOT + expect(counterSelectorCalls - counterCallsBefore).toBeGreaterThanOrEqual( + 1, + ) + expect(todoSelectorCalls - todoCallsBefore).toBe(0) + }) + + it('counter selector does not run when todo changes', () => { + const todoSelector = createTrackedSelector( + (state: TestState) => state.todos[0], + ) + const counterSelector = createTrackedSelector( + (state: TestState) => state.counters.counter1, + ) + + const TodoComp = () => { + const todo = useSignalSelector(todoSelector.selector) + return ( +
{todo.completed ? 'done' : 'pending'}
+ ) + } + + const CounterComp = () => { + const counter = useSignalSelector(counterSelector.selector) + return
{counter.value}
+ } + + const { getByTestId } = rtl.render( + + + + , + ) + + todoSelector.reset() + counterSelector.reset() + + rtl.act(() => { + store.dispatch(todosSlice.actions.toggle(1)) + }) + + expect(getByTestId('todo').textContent).toBe('pending') + expect(todoSelector.getCalls()).toBeGreaterThanOrEqual(1) + expect(counterSelector.getCalls()).toBe(0) + }) + + it('filter selector does not run when todos change', () => { + const filterSelector = createTrackedSelector( + (state: TestState) => state.filter, + ) + const todoSelector = createTrackedSelector( + (state: TestState) => state.todos, + ) + + const FilterComp = () => { + const filter = useSignalSelector(filterSelector.selector) + return
{filter}
+ } + + const TodoListComp = () => { + const todos = useSignalSelector(todoSelector.selector) + return
{todos.length}
+ } + + const { getByTestId } = rtl.render( + + + + , + ) + + filterSelector.reset() + todoSelector.reset() + + rtl.act(() => { + store.dispatch(todosSlice.actions.add({ id: 4, text: 'New todo' })) + }) + + expect(getByTestId('todos').textContent).toBe('4') + expect(todoSelector.getCalls()).toBeGreaterThanOrEqual(1) + expect(filterSelector.getCalls()).toBe(0) + }) + + it('counter1 selector does not run when counter2 changes', () => { + // Note: Both selectors read through `state.counters.counterX`, so they + // share the intermediate `"counters"` version-counter signal. When either + // counter changes, both selectors RE-RUN (signal dep on parent object). + // However, the correct component still renders the correct value — + // selectivity at the object level is coarser than leaf level. + // Leaf-returning selectors (e.g., `state.counters.counter1.value`) get + // full propagation cutoff via Object.is on the computed result. + const counter1Selector = createTrackedSelector( + (state: TestState) => state.counters.counter1.value, + ) + const counter2Selector = createTrackedSelector( + (state: TestState) => state.counters.counter2.value, + ) + + let counter1Renders = 0 + let counter2Renders = 0 + + const Counter1Comp = () => { + const value = useSignalSelector(counter1Selector.selector) + counter1Renders++ + return
{value}
+ } + + const Counter2Comp = () => { + const value = useSignalSelector(counter2Selector.selector) + counter2Renders++ + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + + , + ) + + const renders1After = counter1Renders + const renders2After = counter2Renders + + rtl.act(() => { + store.dispatch(countersSlice.actions.increment('counter1')) + }) + + // counter1 value changed → must re-render + expect(getByTestId('counter1').textContent).toBe('1') + expect(counter1Renders).toBeGreaterThan(renders1After) + + // counter2 value did NOT change → should NOT re-render + // (Object.is cutoff on the primitive computed result) + expect(getByTestId('counter2').textContent).toBe('42') + expect(counter2Renders).toBe(renders2After) + }) + }) + + // ========================================================================== + // Equality Functions (ported from prototype #1) + // ========================================================================== + + describe('equality functions', () => { + it('allows custom equality functions', () => { + let renderCount = 0 + + const Comp = () => { + const todosLength = useSignalSelector( + (state: TestState) => ({ length: state.todos.length }), + (a, b) => a.length === b.length, + ) + renderCount++ + return
{todosLength.length}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('length').textContent).toBe('3') + const rendersAfterMount = renderCount + + // Toggle a todo — array changes but length stays same + rtl.act(() => { + store.dispatch(todosSlice.actions.toggle(1)) + }) + + // Should NOT have re-rendered because length is the same + expect(renderCount).toBe(rendersAfterMount) + + // Add a todo — length changes + rtl.act(() => { + store.dispatch(todosSlice.actions.add({ id: 4, text: 'New todo' })) + }) + + expect(getByTestId('length').textContent).toBe('4') + expect(renderCount).toBe(rendersAfterMount + 1) + }) + }) + + // ========================================================================== + // Frozen State Handling (new — specific to prototype #2) + // ========================================================================== + + describe('frozen state handling', () => { + it('works with configureStore (Immer freezes state)', () => { + // configureStore uses Immer which freezes state — this is the real + // production scenario that killed prototype #1 + const frozenStore = createTestStore() + + const Comp = () => { + const filter = useSignalSelector((state: TestState) => state.filter) + return
{filter}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('filter').textContent).toBe('all') + + // Verify slice state is frozen by Immer (individual slices, not root) + expect(Object.isFrozen(frozenStore.getState().todos)).toBe(true) + expect(Object.isFrozen(frozenStore.getState().counters)).toBe(true) + + rtl.act(() => { + frozenStore.dispatch(filterSlice.actions.set('active')) + }) + + expect(getByTestId('filter').textContent).toBe('active') + }) + + it('works with deeply frozen nested objects', () => { + const frozenStore = createTestStore() + + const Comp = () => { + const value = useSignalSelector( + (state: TestState) => state.counters.counter1.value, + ) + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + // Verify nested state is frozen + expect(Object.isFrozen(frozenStore.getState().counters)).toBe(true) + expect(Object.isFrozen(frozenStore.getState().counters.counter1)).toBe(true) + + expect(getByTestId('value').textContent).toBe('0') + + rtl.act(() => { + frozenStore.dispatch(countersSlice.actions.increment('counter1')) + }) + + expect(getByTestId('value').textContent).toBe('1') + }) + }) + + // ========================================================================== + // Dynamic Dependencies (new — specific to prototype #2) + // ========================================================================== + + describe('dynamic dependencies', () => { + it('re-tracks when selector accesses different paths based on state', () => { + const selectedIdSlice = createSlice({ + name: 'selectedId', + initialState: 1, + reducers: { + set(_state, action: PayloadAction) { + return action.payload + }, + }, + }) + + const itemsSlice = createSlice({ + name: 'items', + initialState: { 1: 'A', 2: 'B', 3: 'C' } as Record, + reducers: { + update(state, action: PayloadAction<{ id: number; value: string }>) { + state[action.payload.id] = action.payload.value + }, + }, + }) + + const dynStore = configureStore({ + reducer: { + selectedId: selectedIdSlice.reducer, + items: itemsSlice.reducer, + }, + }) + type DynState = ReturnType + + let selectorCalls = 0 + + const Comp = () => { + const item = useSignalSelector((state: DynState) => { + selectorCalls++ + return state.items[state.selectedId] + }) + return
{item}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('item').textContent).toBe('A') + + // Change selectedId to 2 → now tracks items.2 + rtl.act(() => { + dynStore.dispatch(selectedIdSlice.actions.set(2)) + }) + + expect(getByTestId('item').textContent).toBe('B') + + // Change items.1 → selector may re-run because of shared `"items"` + // parent version-counter signal, but the computed returns the same + // value ("B") so React does NOT re-render. + rtl.act(() => { + dynStore.dispatch(itemsSlice.actions.update({ id: 1, value: 'A2' })) + }) + + expect(getByTestId('item').textContent).toBe('B') + }) + }) + + // ========================================================================== + // Array Mutations (new — specific to prototype #2) + // ========================================================================== + + describe('array mutations', () => { + it('handles array item addition', () => { + const Comp = () => { + const count = useSignalSelector( + (state: TestState) => state.todos.length, + ) + return
{count}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('count').textContent).toBe('3') + + rtl.act(() => { + store.dispatch(todosSlice.actions.add({ id: 4, text: 'New' })) + }) + + expect(getByTestId('count').textContent).toBe('4') + }) + + it('handles array item removal', () => { + const Comp = () => { + const count = useSignalSelector( + (state: TestState) => state.todos.length, + ) + return
{count}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('count').textContent).toBe('3') + + rtl.act(() => { + store.dispatch(todosSlice.actions.remove(1)) + }) + + expect(getByTestId('count').textContent).toBe('2') + }) + + it('handles array item property change', () => { + const Comp = () => { + const text = useSignalSelector( + (state: TestState) => state.todos[0].text, + ) + return
{text}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('text').textContent).toBe('Learn Redux') + + rtl.act(() => { + store.dispatch(todosSlice.actions.toggle(1)) + }) + + // text didn't change, only completed did + expect(getByTestId('text').textContent).toBe('Learn Redux') + }) + }) + + // ========================================================================== + // NOOP Dispatches + // ========================================================================== + + describe('noop dispatches', () => { + it('does not re-render on NOOP dispatch', () => { + let renderCount = 0 + + const Comp = () => { + const filter = useSignalSelector((state: TestState) => state.filter) + renderCount++ + return
{filter}
+ } + + rtl.render( + + + , + ) + + const rendersAfterMount = renderCount + + rtl.act(() => { + store.dispatch({ type: 'NOOP' } as any) + }) + + expect(renderCount).toBe(rendersAfterMount) + }) + }) + + // ========================================================================== + // Error Handling + // ========================================================================== + + describe('error handling', () => { + it('throws when used outside SignalProvider', () => { + const Comp = () => { + const filter = useSignalSelector((state: TestState) => state.filter) + return
{filter}
+ } + + expect(() => { + rtl.render() + }).toThrow('useSignalSelector must be used within a ') + }) + }) + + // ========================================================================== + // Multiple Components + // ========================================================================== + + describe('multiple components', () => { + it('multiple components with different selectors all render correctly', () => { + const FilterComp = () => { + const filter = useSignalSelector((state: TestState) => state.filter) + return
{filter}
+ } + + const TodoComp = () => { + const count = useSignalSelector( + (state: TestState) => state.todos.length, + ) + return
{count}
+ } + + const CounterComp = () => { + const value = useSignalSelector( + (state: TestState) => state.counters.counter1.value, + ) + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + + + , + ) + + expect(getByTestId('filter').textContent).toBe('all') + expect(getByTestId('todos').textContent).toBe('3') + expect(getByTestId('counter').textContent).toBe('0') + + // Update counter — only counter component should update + rtl.act(() => { + store.dispatch(countersSlice.actions.increment('counter1')) + }) + + expect(getByTestId('filter').textContent).toBe('all') + expect(getByTestId('todos').textContent).toBe('3') + expect(getByTestId('counter').textContent).toBe('1') + + // Update filter — only filter component should update + rtl.act(() => { + store.dispatch(filterSlice.actions.set('completed')) + }) + + expect(getByTestId('filter').textContent).toBe('completed') + expect(getByTestId('todos').textContent).toBe('3') + expect(getByTestId('counter').textContent).toBe('1') + }) + }) +}) + + From 4795703e405ca50adcd0d2e3e398a1529e26e3c8 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 4 Jun 2026 22:10:11 -0700 Subject: [PATCH 06/95] Use string paths, tracked-only diffs, and shared root proxy --- src/signals/diff.ts | 43 ++++++++-------- src/signals/pathSignalRegistry.ts | 78 +++++++++++++++++++++++++++--- src/signals/trackingProxy.ts | 13 ++--- src/signals/useSignalSelector.ts | 6 ++- test/signals/diff.spec.ts | 60 +++++++++++------------ test/signals/trackingProxy.spec.ts | 76 ++++++++++++++--------------- 6 files changed, 173 insertions(+), 103 deletions(-) diff --git a/src/signals/diff.ts b/src/signals/diff.ts index 0838de205..397ee5721 100644 --- a/src/signals/diff.ts +++ b/src/signals/diff.ts @@ -9,12 +9,13 @@ function isPlainObject(v: unknown): v is Record { /** * Walk prev and next state trees, updating path signals for changed values. + * Only visits subtrees that have tracked signals (registered paths). * Exploits Immer's structural sharing: `prev === next` skips entire subtrees. */ export function diffAndUpdateSignals( prev: unknown, next: unknown, - pathSegments: string[], + parentPath: string, registry: PathSignalRegistry, ): void { // Structural sharing: identical references mean nothing changed @@ -31,54 +32,58 @@ export function diffAndUpdateSignals( prevKeys.some((k) => !(k in next)) if (keysChanged) { - const keysPath = [...pathSegments, '@@keys'].join('.') + const keysPath = parentPath ? parentPath + '.@@keys' : '@@keys' registry.update(keysPath, nextKeys) } // Update the object signal itself (version bump) - const selfPath = pathSegments.join('.') - if (selfPath) { - registry.update(selfPath, next) + if (parentPath) { + registry.update(parentPath, next) } - // Recurse into all keys present in either object + // Only recurse into keys that have tracked signals underneath const allKeys = new Set([...prevKeys, ...nextKeys]) for (const key of allKeys) { - diffAndUpdateSignals(prev[key], next[key], [...pathSegments, key], registry) + const childPath = parentPath ? parentPath + '.' + key : key + if (registry.hasPrefix(childPath)) { + diffAndUpdateSignals(prev[key], next[key], childPath, registry) + } } return } // Both arrays: handle length + index-based diffing if (Array.isArray(prev) && Array.isArray(next)) { - const selfPath = pathSegments.join('.') - if (selfPath) { - registry.update(selfPath, next) + if (parentPath) { + registry.update(parentPath, next) } // Length change → @@keys signal if (prev.length !== next.length) { - const keysPath = [...pathSegments, '@@keys'].join('.') + const keysPath = parentPath ? parentPath + '.@@keys' : '@@keys' registry.update(keysPath, next.length) } - // Recurse into each index + // Only recurse into indices that have tracked signals underneath const maxLen = Math.max(prev.length, next.length) for (let i = 0; i < maxLen; i++) { - diffAndUpdateSignals(prev[i], next[i], [...pathSegments, String(i)], registry) + const childPath = parentPath ? parentPath + '.' + i : String(i) + if (registry.hasPrefix(childPath)) { + diffAndUpdateSignals(prev?.[i], next?.[i], childPath, registry) + } } // Prune signals for removed indices for (let i = next.length; i < prev.length; i++) { - registry.prune([...pathSegments, String(i)].join('.')) + const childPath = parentPath ? parentPath + '.' + i : String(i) + registry.prune(childPath) } return } // Leaf value change (primitive, or type mismatch like object→primitive) - const pathKey = pathSegments.join('.') - if (pathKey) { - registry.update(pathKey, next) + if (parentPath) { + registry.update(parentPath, next) } // If prev was an object/array and next is not, prune child signals @@ -87,7 +92,7 @@ export function diffAndUpdateSignals( typeof prev === 'object' && (next === null || typeof next !== 'object') ) { - registry.prune(pathKey) + registry.prune(parentPath) } } @@ -101,6 +106,6 @@ export function reconcileState( engine: SignalEngine, ): void { engine.batch(() => { - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) }) } diff --git a/src/signals/pathSignalRegistry.ts b/src/signals/pathSignalRegistry.ts index 24f60a8e0..52187b7b6 100644 --- a/src/signals/pathSignalRegistry.ts +++ b/src/signals/pathSignalRegistry.ts @@ -15,38 +15,86 @@ export interface PathSignalRegistry { /** Check if a signal exists for a path (without creating one). */ has(pathKey: PathKey): boolean + + /** Check if any tracked signal exists at or below this path prefix. */ + hasPrefix(prefix: string): boolean + + /** Get or create a cached root proxy for a state snapshot. */ + getOrCreateRootProxy( + state: T, + factory: (state: T) => T, + ): T } function isObjectOrArray(v: unknown): v is object { return v !== null && typeof v === 'object' } +// Increment prefix counters for all ancestor paths of a given pathKey. +// e.g., "a.b.c" increments counters for "a.b.c", "a.b", "a" +function incrementPrefixes( + prefixCounts: Map, + pathKey: string, +): void { + prefixCounts.set(pathKey, (prefixCounts.get(pathKey) || 0) + 1) + let idx = pathKey.lastIndexOf('.') + while (idx !== -1) { + const prefix = pathKey.substring(0, idx) + prefixCounts.set(prefix, (prefixCounts.get(prefix) || 0) + 1) + idx = prefix.lastIndexOf('.') + } +} + +function decrementPrefixes( + prefixCounts: Map, + pathKey: string, +): void { + let count = prefixCounts.get(pathKey) + if (count !== undefined) { + if (count <= 1) prefixCounts.delete(pathKey) + else prefixCounts.set(pathKey, count - 1) + } + let idx = pathKey.lastIndexOf('.') + while (idx !== -1) { + const prefix = pathKey.substring(0, idx) + count = prefixCounts.get(prefix) + if (count !== undefined) { + if (count <= 1) prefixCounts.delete(prefix) + else prefixCounts.set(prefix, count - 1) + } + idx = prefix.lastIndexOf('.') + } +} + export function createPathSignalRegistry( engine: SignalEngine, ): PathSignalRegistry { const signals = new Map>() + // Prefix counter map: for each path prefix, how many tracked signals + // exist at or below that prefix. Enables O(1) hasPrefix lookups. + const prefixCounts = new Map() + // Cached root proxy: avoids creating a new proxy per selector per dispatch. + // Keyed by state reference identity (immutable snapshots). + let cachedProxyState: object | null = null + let cachedProxy: object | null = null return { getOrCreate(pathKey: PathKey, currentValue: unknown): ReactiveSignal { let sig = signals.get(pathKey) if (!sig) { - // For objects/arrays, store a version counter rather than the object itself. - // The actual value is always read from the frozen state. - // The signal's purpose is dependency tracking + change notification. const initialValue = isObjectOrArray(currentValue) ? 0 : currentValue sig = engine.signal(initialValue) signals.set(pathKey, sig) + incrementPrefixes(prefixCounts, pathKey) } return sig }, update(pathKey: PathKey, newValue: unknown): void { const sig = signals.get(pathKey) - if (!sig) return // no one is tracking this path — skip + if (!sig) return if (isObjectOrArray(newValue)) { - // Bump version counter — signals something in this subtree changed. - // If the signal previously held a primitive (type transition), reset to 0. const current = sig.get() sig.set(typeof current === 'number' ? current + 1 : 0) } else { @@ -59,6 +107,7 @@ export function createPathSignalRegistry( for (const key of signals.keys()) { if (key === pathKey || key.startsWith(prefix)) { signals.delete(key) + decrementPrefixes(prefixCounts, key) } } }, @@ -70,5 +119,22 @@ export function createPathSignalRegistry( has(pathKey: PathKey): boolean { return signals.has(pathKey) }, + + hasPrefix(prefix: string): boolean { + return (prefixCounts.get(prefix) || 0) > 0 + }, + + getOrCreateRootProxy( + state: T, + factory: (state: T) => T, + ): T { + if (cachedProxyState === state) { + return cachedProxy as T + } + const proxy = factory(state) + cachedProxyState = state + cachedProxy = proxy + return proxy + }, } } diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index 7c98017df..624dc6892 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -20,7 +20,7 @@ function isObjectOrArray(v: unknown): v is object { */ export function createTrackingProxy( target: T, - pathSegments: string[], + parentPath: string, registry: PathSignalRegistry, ): T { // Cache child proxies within this evaluation to avoid duplicate creation @@ -40,13 +40,10 @@ export function createTrackingProxy( // Functions (like Array.prototype.map) — bind to proxy so `this` works if (typeof value === 'function') { - // For inherited methods (e.g. Array.prototype.map), return them - // bound to the proxy so iteration uses tracked access return value } - const childPath = [...pathSegments, prop] - const pathKey = childPath.join('.') + const pathKey = parentPath ? parentPath + '.' + (prop as string) : (prop as string) if (isObjectOrArray(value)) { // Read signal to establish dependency (version counter for objects) @@ -56,7 +53,7 @@ export function createTrackingProxy( if (!childCache.has(prop as string)) { childCache.set( prop as string, - createTrackingProxy(value as object, childPath, registry), + createTrackingProxy(value as object, pathKey, registry), ) } return childCache.get(prop as string) @@ -71,7 +68,7 @@ export function createTrackingProxy( // Track when selectors iterate keys (Object.keys, for...in, .map, .filter, etc.) ownKeys(_obj) { - const keysPath = [...pathSegments, '@@keys'].join('.') + const keysPath = parentPath ? parentPath + '.@@keys' : '@@keys' registry.getOrCreate(keysPath, Reflect.ownKeys(target)).get() return Reflect.ownKeys(target) }, @@ -79,7 +76,7 @@ export function createTrackingProxy( // Track has() checks for conditional property access (e.g., 'key' in obj) has(_obj, prop) { if (typeof prop === 'symbol') return Reflect.has(target, prop) - const pathKey = [...pathSegments, prop].join('.') + const pathKey = parentPath ? parentPath + '.' + (prop as string) : (prop as string) registry.getOrCreate(pathKey, (target as Record)[prop]).get() return Reflect.has(target, prop) }, diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index da605256d..11a2225a5 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -31,13 +31,15 @@ export function useSignalSelector( let currentResult: R let version = 0 let notifyReact: (() => void) | null = null - let effectDispose: (() => void) | null = null + let effectDispose: (() => void) | null | void = null // Create a computed that runs the selector through a tracking proxy. // This establishes signal dependencies on the paths the selector reads. const selectorComputed = engine.computed(() => { const state = store.getState() as S & object - const proxy = createTrackingProxy(state, [], registry) + const proxy = registry.getOrCreateRootProxy(state, (s) => + createTrackingProxy(s, '', registry), + ) return selectorRef.current(proxy as S) }) diff --git a/test/signals/diff.spec.ts b/test/signals/diff.spec.ts index a151e472d..e97dd8b58 100644 --- a/test/signals/diff.spec.ts +++ b/test/signals/diff.spec.ts @@ -11,7 +11,7 @@ import type { PathSignalRegistry } from '../../src/signals/pathSignalRegistry' // Helper: create a registry with signals pre-populated by running a selector through a tracking proxy function setupRegistry(state: object, selectorFn: (s: any) => void) { const registry = createPathSignalRegistry(alienEngine) - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) selectorFn(proxy) return registry } @@ -44,7 +44,7 @@ describe('diffAndUpdateSignals', () => { const initialFilter = filterSig.get() // Diff with same reference — nothing should change - diffAndUpdateSignals(state, state, [], registry) + diffAndUpdateSignals(state, state, '', registry) expect(textSig.get()).toBe(initialText) expect(filterSig.get()).toBe(initialFilter) @@ -67,7 +67,7 @@ describe('diffAndUpdateSignals', () => { const initialTodosVersion = todosSig.get() const initialText = todoTextSig.get() - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) // todos subtree is shared — signals should NOT be updated expect(todosSig.get()).toBe(initialTodosVersion) @@ -87,7 +87,7 @@ describe('diffAndUpdateSignals', () => { s.age }) - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) expect(registry.getOrCreate('name', 'Bob').get()).toBe('Bob') expect(registry.getOrCreate('age', 30).get()).toBe(30) // unchanged @@ -98,7 +98,7 @@ describe('diffAndUpdateSignals', () => { const next = { value: 42 } const registry = setupRegistry(prev, (s) => s.value) - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) expect(registry.getOrCreate('value', 42).get()).toBe(42) }) @@ -108,7 +108,7 @@ describe('diffAndUpdateSignals', () => { const next = { active: false } const registry = setupRegistry(prev, (s) => s.active) - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) expect(registry.getOrCreate('active', false).get()).toBe(false) }) @@ -118,7 +118,7 @@ describe('diffAndUpdateSignals', () => { const next = { data: 'loaded' } const registry = setupRegistry(prev, (s) => s.data) - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) expect(registry.getOrCreate('data', 'loaded').get()).toBe('loaded') }) @@ -128,7 +128,7 @@ describe('diffAndUpdateSignals', () => { const next = { data: 'loaded' } const registry = setupRegistry(prev, (s) => s.data) - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) expect(registry.getOrCreate('data', 'loaded').get()).toBe('loaded') }) @@ -144,7 +144,7 @@ describe('diffAndUpdateSignals', () => { const userSig = registry.getOrCreate('user', prev.user) const initialVersion = userSig.get() as number - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) expect(userSig.get()).toBe(initialVersion + 1) expect(registry.getOrCreate('user.name', 'Bob').get()).toBe('Bob') @@ -157,7 +157,7 @@ describe('diffAndUpdateSignals', () => { const registry = setupRegistry(prev, (s) => s.a) const initialSize = registry.size() - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) // 'b' was never tracked, so no signal should be created for it expect(registry.has('b')).toBe(false) @@ -175,7 +175,7 @@ describe('diffAndUpdateSignals', () => { const registry = setupRegistry(prev, (s) => s.a.b.c.d) - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) expect(registry.getOrCreate('a.b.c.d', 'changed').get()).toBe('changed') // All parent objects should have version bumps @@ -201,7 +201,7 @@ describe('diffAndUpdateSignals', () => { const keysSig = registry.getOrCreate('@@keys', Object.keys(prev)) const initialKeysVersion = keysSig.get() - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) // @@keys signal should have been updated expect(keysSig.get()).not.toBe(initialKeysVersion) @@ -220,7 +220,7 @@ describe('diffAndUpdateSignals', () => { const keysSig = registry.getOrCreate('@@keys', Object.keys(prev)) const initialKeysVersion = keysSig.get() - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) expect(keysSig.get()).not.toBe(initialKeysVersion) }) @@ -238,7 +238,7 @@ describe('diffAndUpdateSignals', () => { const keysSig = registry.getOrCreate('@@keys', Object.keys(prev)) const initialKeysVersion = keysSig.get() - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) // Keys didn't change, only values expect(keysSig.get()).toBe(initialKeysVersion) @@ -256,7 +256,7 @@ describe('diffAndUpdateSignals', () => { const entitiesKeysSig = registry.getOrCreate('entities.@@keys', Object.keys(prev.entities)) const initialVersion = entitiesKeysSig.get() - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) expect(entitiesKeysSig.get()).not.toBe(initialVersion) }) @@ -282,7 +282,7 @@ describe('diffAndUpdateSignals', () => { s.todos[1].completed }) - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) expect(registry.getOrCreate('todos.0.completed', true).get()).toBe(true) // todos[1] unchanged (structural sharing would make this ===) @@ -305,7 +305,7 @@ describe('diffAndUpdateSignals', () => { const keysSignal = registry.getOrCreate('todos.@@keys', 1) const initialKeysVersion = keysSignal.get() - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) // @@keys should update (length changed) expect(keysSignal.get()).not.toBe(initialKeysVersion) @@ -335,7 +335,7 @@ describe('diffAndUpdateSignals', () => { expect(registry.has('todos.2.text')).toBe(true) expect(registry.has('todos.2')).toBe(true) - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) // Signals for removed index should be pruned expect(registry.has('todos.2.text')).toBe(false) @@ -351,7 +351,7 @@ describe('diffAndUpdateSignals', () => { const registry = createPathSignalRegistry(alienEngine) // Manually track some paths - const proxy = createTrackingProxy({ items: prev }, [], registry) + const proxy = createTrackingProxy({ items: prev }, '', registry) proxy.items.length // triggers @@keys and items tracking const keysPath = 'items.@@keys' @@ -360,7 +360,7 @@ describe('diffAndUpdateSignals', () => { diffAndUpdateSignals( { items: prev }, { items: next }, - [], + '', registry, ) @@ -378,7 +378,7 @@ describe('diffAndUpdateSignals', () => { registry.getOrCreate('items.@@keys', 0) - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) // @@keys should be updated const keysSignal = registry.getOrCreate('items.@@keys', 3) @@ -399,7 +399,7 @@ describe('diffAndUpdateSignals', () => { expect(registry.has('data.nested')).toBe(true) - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) // Child signals should be pruned when parent becomes primitive expect(registry.has('data.nested')).toBe(false) @@ -414,7 +414,7 @@ describe('diffAndUpdateSignals', () => { const dataSig = registry.getOrCreate('data', 'flat') expect(dataSig.get()).toBe('flat') - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) // Signal transitions from primitive to version counter (resets to 0) expect(dataSig.get()).toBe(0) @@ -432,7 +432,7 @@ describe('diffAndUpdateSignals', () => { expect(registry.has('data.0')).toBe(true) expect(registry.has('data.1')).toBe(true) - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) // Array index signals should be pruned (type mismatch) // The path 'data' gets updated, and since prev was array and next is object, @@ -459,7 +459,7 @@ describe('diffAndUpdateSignals', () => { expect(derived.get()).toBe(0) const initialRuns = computedRuns - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) expect(derived.get()).toBe(1) expect(computedRuns).toBe(initialRuns + 1) @@ -486,7 +486,7 @@ describe('diffAndUpdateSignals', () => { todoComputed.get() const initialRuns = todoComputedRuns - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) // Re-read — should NOT have re-run because todos was shared todoComputed.get() @@ -508,7 +508,7 @@ describe('diffAndUpdateSignals', () => { const initialC = cSig.get() // Same reference — should be immediate no-op - diffAndUpdateSignals(state, state, [], registry) + diffAndUpdateSignals(state, state, '', registry) expect(aSig.get()).toBe(initialA) expect(cSig.get()).toBe(initialC) @@ -650,7 +650,7 @@ describe('diffAndUpdateSignals', () => { const registry = createPathSignalRegistry(alienEngine) // Should not throw - diffAndUpdateSignals(prev, next, [], registry) + diffAndUpdateSignals(prev, next, '', registry) }) it('handles root-level primitive comparison', () => { @@ -658,7 +658,7 @@ describe('diffAndUpdateSignals', () => { const registry = createPathSignalRegistry(alienEngine) // Should not throw — just returns because prev !== next but neither is object - diffAndUpdateSignals('old', 'new', ['root'], registry) + diffAndUpdateSignals('old', 'new', 'root', registry) }) it('handles nested path offset correctly', () => { @@ -669,7 +669,7 @@ describe('diffAndUpdateSignals', () => { registry.getOrCreate('nested.obj.value', 10) // Diff starting from a path offset - diffAndUpdateSignals(prev, next, ['nested', 'obj'], registry) + diffAndUpdateSignals(prev, next, 'nested.obj', registry) expect(registry.getOrCreate('nested.obj.value', 20).get()).toBe(20) }) diff --git a/test/signals/trackingProxy.spec.ts b/test/signals/trackingProxy.spec.ts index 7d0337d44..51f94ca28 100644 --- a/test/signals/trackingProxy.spec.ts +++ b/test/signals/trackingProxy.spec.ts @@ -14,7 +14,7 @@ describe('createTrackingProxy', () => { it('returns primitive values from the underlying object', () => { const state = { name: 'Alice', age: 30 } const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) expect(proxy.name).toBe('Alice') expect(proxy.age).toBe(30) @@ -23,7 +23,7 @@ describe('createTrackingProxy', () => { it('creates signals for accessed primitive paths', () => { const state = { name: 'Alice', age: 30 } const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) expect(registry.has('name')).toBe(false) proxy.name @@ -37,7 +37,7 @@ describe('createTrackingProxy', () => { it('does NOT create signals for unaccessed paths', () => { const state = { name: 'Alice', age: 30, email: 'alice@example.com' } const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) proxy.name // access only name expect(registry.has('name')).toBe(true) @@ -50,7 +50,7 @@ describe('createTrackingProxy', () => { it('returns a tracking proxy for nested objects (not raw value)', () => { const state = { user: { name: 'Alice' } } const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) const userProxy = proxy.user // Should be a proxy, not the raw object @@ -62,7 +62,7 @@ describe('createTrackingProxy', () => { it('creates signals for nested property paths', () => { const state = { user: { name: 'Alice', address: { city: 'NYC' } } } const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) proxy.user.address.city @@ -76,7 +76,7 @@ describe('createTrackingProxy', () => { it('caches child proxies within same evaluation', () => { const state = { user: { name: 'Alice' } } const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) const first = proxy.user const second = proxy.user @@ -88,7 +88,7 @@ describe('createTrackingProxy', () => { it('returns tracking proxies for array elements', () => { const state = { todos: [{ id: 1, text: 'Test' }] } const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) const todo = proxy.todos[0] expect(todo.text).toBe('Test') @@ -100,7 +100,7 @@ describe('createTrackingProxy', () => { it('tracks array length access', () => { const state = { items: [1, 2, 3] } const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) expect(proxy.items.length).toBe(3) expect(registry.has('items.length')).toBe(true) @@ -114,7 +114,7 @@ describe('createTrackingProxy', () => { filter: 'all' as const, }) const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) expect(proxy.filter).toBe('all') expect(proxy.user.name).toBe('Alice') @@ -134,7 +134,7 @@ describe('createTrackingProxy', () => { }), }) const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) expect(proxy.todos[0].text).toBe('Test') expect(proxy.todos[1].completed).toBe(true) @@ -146,7 +146,7 @@ describe('createTrackingProxy', () => { it('tracks ownKeys access via @@keys signal', () => { const state = { a: 1, b: 2, c: 3 } const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) Object.keys(proxy) expect(registry.has('@@keys')).toBe(true) @@ -155,7 +155,7 @@ describe('createTrackingProxy', () => { it('tracks nested ownKeys with correct path', () => { const state = { user: { name: 'Alice', age: 30 } } const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) Object.keys(proxy.user) expect(registry.has('user.@@keys')).toBe(true) @@ -164,7 +164,7 @@ describe('createTrackingProxy', () => { it('supports for...in enumeration', () => { const state = { a: 1, b: 2 } const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) const keys: string[] = [] for (const key in proxy) { @@ -177,7 +177,7 @@ describe('createTrackingProxy', () => { it('supports Object.entries on proxy', () => { const state = Object.freeze({ x: 10, y: 20 }) const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) const entries = Object.entries(proxy) expect(entries).toEqual([ @@ -194,7 +194,7 @@ describe('createTrackingProxy', () => { ]), }) const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) const names = proxy.items.map((item: { id: number; name: string }) => item.name) expect(names).toEqual(['A', 'B']) @@ -209,7 +209,7 @@ describe('createTrackingProxy', () => { ]), }) const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) const active = proxy.items.filter( (item: { id: number; active: boolean }) => item.active, @@ -223,7 +223,7 @@ describe('createTrackingProxy', () => { const sym = Symbol('test') const state = { [sym]: 'secret', name: 'Alice' } const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) expect(proxy[sym]).toBe('secret') // Symbol access should not create any signals @@ -233,7 +233,7 @@ describe('createTrackingProxy', () => { it('supports Symbol.iterator for arrays', () => { const state = Object.freeze({ items: Object.freeze([1, 2, 3]) }) const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) const spread = [...proxy.items] expect(spread).toEqual([1, 2, 3]) @@ -244,7 +244,7 @@ describe('createTrackingProxy', () => { it('tracks "in" operator checks', () => { const state = { name: 'Alice', age: 30 } const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) expect('name' in proxy).toBe(true) expect('missing' in proxy).toBe(false) @@ -259,7 +259,7 @@ describe('createTrackingProxy', () => { it('treats null values as primitives (not objects)', () => { const state = { value: null as null | string } const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) expect(proxy.value).toBe(null) expect(registry.has('value')).toBe(true) @@ -272,7 +272,7 @@ describe('createTrackingProxy', () => { a: { b: { c: { d: 42 } } }, } const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) expect(proxy.a.b.c.d).toBe(42) expect(registry.has('a')).toBe(true) @@ -292,7 +292,7 @@ describe('createTrackingProxy', () => { settings: { theme: 'dark' }, } const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) // Access only alice's name proxy.users.alice.name @@ -311,7 +311,7 @@ describe('createTrackingProxy', () => { it('creates intermediate object signals as version counters', () => { const state = { a: { b: { c: 42 } } } const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) proxy.a.b.c @@ -336,7 +336,7 @@ describe('createTrackingProxy', () => { count: 5, }) const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) // Traverse: entities → users → [0] → tags → [0] const tag = proxy.entities.users[0].tags[0] @@ -359,7 +359,7 @@ describe('createTrackingProxy', () => { b: { x: 10, y: 20 }, } const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) proxy.a.x proxy.b.y @@ -376,7 +376,7 @@ describe('createTrackingProxy', () => { it('array index paths use numeric string keys', () => { const state = { items: ['zero', 'one', 'two'] } const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) proxy.items[0] proxy.items[2] @@ -393,7 +393,7 @@ describe('createTrackingProxy', () => { }, } const registry = makeRegistry() - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) // Iterate top-level keys Object.keys(proxy) @@ -411,8 +411,8 @@ describe('createTrackingProxy', () => { const registry = makeRegistry() // Two separate root proxies but same registry - const proxy1 = createTrackingProxy(state, [], registry) - const proxy2 = createTrackingProxy(state, [], registry) + const proxy1 = createTrackingProxy(state, '', registry) + const proxy2 = createTrackingProxy(state, '', registry) proxy1.user.profile.name proxy2.user.profile.name @@ -436,14 +436,14 @@ describe('createTrackingProxy', () => { const c1 = scope.run(() => alienEngine.computed(() => { comp1Calls++ - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) return proxy.counter.value }), ) const c2 = scope.run(() => alienEngine.computed(() => { comp2Calls++ - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) return proxy.counter.value }), ) @@ -481,7 +481,7 @@ describe('createTrackingProxy', () => { const c = scope.run(() => alienEngine.computed(() => { calls++ - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) // Access the intermediate object (version counter signal) return proxy.nested.deep }), @@ -517,7 +517,7 @@ describe('createTrackingProxy', () => { const parentComp = scope.run(() => alienEngine.computed(() => { parentCalls++ - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) return proxy.parent }), ) @@ -526,7 +526,7 @@ describe('createTrackingProxy', () => { const leafComp = scope.run(() => alienEngine.computed(() => { leafCalls++ - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) return proxy.parent.child.leaf }), ) @@ -550,7 +550,7 @@ describe('createTrackingProxy', () => { const nested = { x: 1, y: 2 } const registry = makeRegistry() // Create proxy with a base path — simulates a proxy for a nested subtree - const proxy = createTrackingProxy(nested, ['root', 'sub'], registry) + const proxy = createTrackingProxy(nested, 'root.sub', registry) proxy.x @@ -576,7 +576,7 @@ describe('createTrackingProxy', () => { const c = scope.run(() => { return alienEngine.computed(() => { callCount++ - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) return proxy.filter }) }) @@ -615,7 +615,7 @@ describe('createTrackingProxy', () => { const todoComputed = scope.run(() => alienEngine.computed(() => { todoCalls++ - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) return proxy.todos }), ) @@ -623,7 +623,7 @@ describe('createTrackingProxy', () => { const counterComputed = scope.run(() => alienEngine.computed(() => { counterCalls++ - const proxy = createTrackingProxy(state, [], registry) + const proxy = createTrackingProxy(state, '', registry) return proxy.counter }), ) From 0556979a721e033938584e6a61151a7912db4933 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 6 Jun 2026 11:36:03 -0700 Subject: [PATCH 07/95] Improve intermediate object handling --- src/signals/diff.ts | 5 ++- src/signals/trackingProxy.ts | 31 ++++++++++++++--- src/signals/useSignalSelector.ts | 18 ++++++++-- test/signals/trackingProxy.spec.ts | 55 +++++++++++++++++++++++++++--- 4 files changed, 96 insertions(+), 13 deletions(-) diff --git a/src/signals/diff.ts b/src/signals/diff.ts index 397ee5721..083a29b4b 100644 --- a/src/signals/diff.ts +++ b/src/signals/diff.ts @@ -58,10 +58,13 @@ export function diffAndUpdateSignals( registry.update(parentPath, next) } - // Length change → @@keys signal + // Length change → @@keys signal + length signal if (prev.length !== next.length) { const keysPath = parentPath ? parentPath + '.@@keys' : '@@keys' registry.update(keysPath, next.length) + // Also update the explicit 'length' signal for selectors that read .length + const lengthPath = parentPath ? parentPath + '.length' : 'length' + registry.update(lengthPath, next.length) } // Only recurse into indices that have tracked signals underneath diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index 624dc6892..e5bdb7ee6 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -4,6 +4,21 @@ function isObjectOrArray(v: unknown): v is object { return v !== null && typeof v === 'object' } +/** + * Maps proxy objects to their path keys. + * Used by useSignalSelector to detect when a selector returns a proxy (object) + * and explicitly establish a dependency on that object's signal. + */ +const proxyPathMap = new WeakMap() + +/** Get the path key associated with a tracking proxy, or undefined if not a proxy. */ +export function getProxyPath(value: unknown): string | undefined { + if (value !== null && typeof value === 'object') { + return proxyPathMap.get(value) + } + return undefined +} + /** * Creates a read-only tracking proxy that wraps frozen Redux state. * @@ -46,15 +61,21 @@ export function createTrackingProxy( const pathKey = parentPath ? parentPath + '.' + (prop as string) : (prop as string) if (isObjectOrArray(value)) { - // Read signal to establish dependency (version counter for objects) - registry.getOrCreate(pathKey, value).get() + // Register the signal (for hasPrefix/diff tracking) but DON'T read it. + // This avoids "false sharing" — intermediate object traversals won't + // create dependencies. Only leaf reads and explicit terminal reads + // (detected by useSignalSelector) establish reactive dependencies. + registry.getOrCreate(pathKey, value) // Return cached child proxy if (!childCache.has(prop as string)) { - childCache.set( - prop as string, - createTrackingProxy(value as object, pathKey, registry), + const childProxy = createTrackingProxy( + value as object, + pathKey, + registry, ) + proxyPathMap.set(childProxy as object, pathKey) + childCache.set(prop as string, childProxy) } return childCache.get(prop as string) } diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index 11a2225a5..ae549f6e4 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -1,7 +1,7 @@ import { React } from '../utils/react' import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' import { useSignalContext } from './context' -import { createTrackingProxy } from './trackingProxy' +import { createTrackingProxy, getProxyPath } from './trackingProxy' const { useRef, useMemo, useEffect, useSyncExternalStore } = React @@ -35,12 +35,26 @@ export function useSignalSelector( // Create a computed that runs the selector through a tracking proxy. // This establishes signal dependencies on the paths the selector reads. + // + // Intermediate object traversals don't create signal dependencies (to avoid + // "false sharing" where siblings cause re-runs). Only leaf primitive reads + // create deps automatically. If the selector returns a proxy (object result), + // we explicitly read that object's signal to establish the terminal dependency. const selectorComputed = engine.computed(() => { const state = store.getState() as S & object const proxy = registry.getOrCreateRootProxy(state, (s) => createTrackingProxy(s, '', registry), ) - return selectorRef.current(proxy as S) + const result = selectorRef.current(proxy as S) + + // If the selector returned a tracking proxy (object), explicitly + // read its signal to establish a reactive dependency on that path. + const proxyPath = getProxyPath(result) + if (proxyPath !== undefined) { + registry.getOrCreate(proxyPath, result).get() + } + + return result }) // Initialize with current value diff --git a/test/signals/trackingProxy.spec.ts b/test/signals/trackingProxy.spec.ts index 51f94ca28..10dc58549 100644 --- a/test/signals/trackingProxy.spec.ts +++ b/test/signals/trackingProxy.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest' -import { createTrackingProxy } from '../../src/signals/trackingProxy' +import { createTrackingProxy, getProxyPath } from '../../src/signals/trackingProxy' import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' import { alienEngine } from '../../src/signals/engine' import type { PathSignalRegistry } from '../../src/signals/pathSignalRegistry' @@ -470,7 +470,7 @@ describe('createTrackingProxy', () => { scope.stop() }) - it('version counter bumps propagate to computeds reading nested objects', () => { + it('version counter bumps propagate to computeds returning objects (with explicit terminal dep)', () => { const state = Object.freeze({ nested: Object.freeze({ deep: Object.freeze({ value: 42 }) }), }) @@ -482,8 +482,14 @@ describe('createTrackingProxy', () => { alienEngine.computed(() => { calls++ const proxy = createTrackingProxy(state, '', registry) - // Access the intermediate object (version counter signal) - return proxy.nested.deep + const result = proxy.nested.deep + // Simulate what useSignalSelector does: detect proxy result + // and explicitly read the object's signal for terminal dependency + const proxyPath = getProxyPath(result) + if (proxyPath !== undefined) { + registry.getOrCreate(proxyPath, result).get() + } + return result }), ) @@ -503,6 +509,39 @@ describe('createTrackingProxy', () => { scope.stop() }) + it('intermediate object traversal does NOT create signal dependency', () => { + const state = Object.freeze({ + nested: Object.freeze({ deep: Object.freeze({ value: 42 }) }), + }) + const registry = makeRegistry() + const scope = alienEngine.createScope() + + let calls = 0 + const c = scope.run(() => + alienEngine.computed(() => { + calls++ + const proxy = createTrackingProxy(state, '', registry) + // Read through intermediate objects to a leaf + return proxy.nested.deep.value + }), + ) + + c.get() + expect(calls).toBe(1) + + // Bumping intermediate 'nested' should NOT trigger (no .get() was called on it) + registry.update('nested', {}) + c.get() + expect(calls).toBe(1) + + // Bumping the leaf signal SHOULD trigger + registry.update('nested.deep.value', 99) + c.get() + expect(calls).toBe(2) + + scope.stop() + }) + it('parent object signal does NOT fire when only a child leaf changes', () => { const state = Object.freeze({ parent: Object.freeze({ child: Object.freeze({ leaf: 'hello' }) }), @@ -616,7 +655,13 @@ describe('createTrackingProxy', () => { alienEngine.computed(() => { todoCalls++ const proxy = createTrackingProxy(state, '', registry) - return proxy.todos + const result = proxy.todos + // Simulate terminal dependency for object result + const proxyPath = getProxyPath(result) + if (proxyPath !== undefined) { + registry.getOrCreate(proxyPath, result).get() + } + return result }), ) From af0ecc2855626684f2b428f8f54199bb1473dcb4 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 6 Jun 2026 12:44:40 -0700 Subject: [PATCH 08/95] Optimize key checks and proxy creation --- src/signals/diff.ts | 67 +++++++++---- src/signals/pathSignalRegistry.ts | 58 ++++++----- src/signals/trackingProxy.ts | 52 ++++++---- src/signals/useSignalSelector.ts | 4 +- test/signals/diff.spec.ts | 27 ++--- test/signals/trackingProxy.spec.ts | 152 +++++++++++++++-------------- 6 files changed, 209 insertions(+), 151 deletions(-) diff --git a/src/signals/diff.ts b/src/signals/diff.ts index 083a29b4b..6ce4b823c 100644 --- a/src/signals/diff.ts +++ b/src/signals/diff.ts @@ -23,32 +23,52 @@ export function diffAndUpdateSignals( // Both plain objects: recurse into properties if (isPlainObject(prev) && isPlainObject(next)) { - const prevKeys = Object.keys(prev) const nextKeys = Object.keys(next) - // Check for key changes (additions/removals) - const keysChanged = - prevKeys.length !== nextKeys.length || - prevKeys.some((k) => !(k in next)) - - if (keysChanged) { - const keysPath = parentPath ? parentPath + '.@@keys' : '@@keys' - registry.update(keysPath, nextKeys) - } + // Detect key additions/removals by comparing length + checking removals + // (cheaper than building a Set union of both key arrays) + const prevKeyCount = Object.keys(prev).length + let keysChanged = prevKeyCount !== nextKeys.length // Update the object signal itself (version bump) if (parentPath) { registry.update(parentPath, next) } - // Only recurse into keys that have tracked signals underneath - const allKeys = new Set([...prevKeys, ...nextKeys]) - for (const key of allKeys) { + // Iterate next's keys — covers additions + same keys. + // Removed keys: prev[key] was defined, next[key] is undefined, + // so prev[key] !== next[key] and the recursive call handles pruning. + for (let i = 0; i < nextKeys.length; i++) { + const key = nextKeys[i] const childPath = parentPath ? parentPath + '.' + key : key + + // Check if this key was added (not in prev) — also detects keysChanged + if (!keysChanged && !(key in prev)) { + keysChanged = true + } + if (registry.hasPrefix(childPath)) { diffAndUpdateSignals(prev[key], next[key], childPath, registry) } } + + // Handle removed keys: iterate prev keys that aren't in next + // Only needed if keys actually changed and there are tracked signals + if (keysChanged) { + const keysPath = parentPath ? parentPath + '.@@keys' : '@@keys' + registry.update(keysPath, nextKeys) + + // Prune signals for removed keys (any key in prev but not in next) + const prevKeys = Object.keys(prev) + for (let i = 0; i < prevKeys.length; i++) { + if (!(prevKeys[i] in next)) { + const childPath = parentPath + ? parentPath + '.' + prevKeys[i] + : prevKeys[i] + registry.prune(childPath) + } + } + } return } @@ -67,16 +87,25 @@ export function diffAndUpdateSignals( registry.update(lengthPath, next.length) } - // Only recurse into indices that have tracked signals underneath - const maxLen = Math.max(prev.length, next.length) - for (let i = 0; i < maxLen; i++) { + // Recurse into tracked indices, prune removed ones + const minLen = Math.min(prev.length, next.length) + // Shared indices: recurse if tracked + for (let i = 0; i < minLen; i++) { + if (prev[i] !== next[i]) { + const childPath = parentPath ? parentPath + '.' + i : String(i) + if (registry.hasPrefix(childPath)) { + diffAndUpdateSignals(prev[i], next[i], childPath, registry) + } + } + } + // Added indices: recurse if tracked + for (let i = minLen; i < next.length; i++) { const childPath = parentPath ? parentPath + '.' + i : String(i) if (registry.hasPrefix(childPath)) { - diffAndUpdateSignals(prev?.[i], next?.[i], childPath, registry) + diffAndUpdateSignals(undefined, next[i], childPath, registry) } } - - // Prune signals for removed indices + // Removed indices: prune for (let i = next.length; i < prev.length; i++) { const childPath = parentPath ? parentPath + '.' + i : String(i) registry.prune(childPath) diff --git a/src/signals/pathSignalRegistry.ts b/src/signals/pathSignalRegistry.ts index 52187b7b6..b8d6bc1cd 100644 --- a/src/signals/pathSignalRegistry.ts +++ b/src/signals/pathSignalRegistry.ts @@ -1,9 +1,15 @@ +import type { ProxyCache } from './trackingProxy' import type { PathKey, ReactiveSignal, SignalEngine } from './types' export interface PathSignalRegistry { /** Get or create a signal for a path. Object/array values use version counters. */ getOrCreate(pathKey: PathKey, currentValue: unknown): ReactiveSignal + /** Register a path in the prefix index without creating a signal. + * Used for intermediate object traversals where we need hasPrefix() + * to work for diff, but don't need a signal (no reactive dependency). */ + ensurePrefix(pathKey: PathKey): void + /** Update a signal's value (called during diff). */ update(pathKey: PathKey, newValue: unknown): void @@ -19,11 +25,8 @@ export interface PathSignalRegistry { /** Check if any tracked signal exists at or below this path prefix. */ hasPrefix(prefix: string): boolean - /** Get or create a cached root proxy for a state snapshot. */ - getOrCreateRootProxy( - state: T, - factory: (state: T) => T, - ): T + /** Proxy cache for reusing proxies across evaluations (keyed by object identity). */ + proxyCache: ProxyCache } function isObjectOrArray(v: unknown): v is object { @@ -73,10 +76,13 @@ export function createPathSignalRegistry( // Prefix counter map: for each path prefix, how many tracked signals // exist at or below that prefix. Enables O(1) hasPrefix lookups. const prefixCounts = new Map() - // Cached root proxy: avoids creating a new proxy per selector per dispatch. - // Keyed by state reference identity (immutable snapshots). - let cachedProxyState: object | null = null - let cachedProxy: object | null = null + // Paths that are prefix-only registered (no signal created yet). + // Tracked so ensurePrefix is idempotent and getOrCreate can skip + // re-incrementing prefixes if ensurePrefix was called first. + const prefixOnlyPaths = new Set() + // Proxy cache: keyed by target object identity. + // Reuses proxies for unchanged Immer subtrees across state snapshots. + const proxyWeakMap: ProxyCache = new WeakMap() return { getOrCreate(pathKey: PathKey, currentValue: unknown): ReactiveSignal { @@ -85,11 +91,23 @@ export function createPathSignalRegistry( const initialValue = isObjectOrArray(currentValue) ? 0 : currentValue sig = engine.signal(initialValue) signals.set(pathKey, sig) - incrementPrefixes(prefixCounts, pathKey) + // If ensurePrefix was called first, prefixes are already counted + if (prefixOnlyPaths.has(pathKey)) { + prefixOnlyPaths.delete(pathKey) + } else { + incrementPrefixes(prefixCounts, pathKey) + } } return sig }, + ensurePrefix(pathKey: PathKey): void { + // Already has a signal or already prefix-registered — nothing to do + if (signals.has(pathKey) || prefixOnlyPaths.has(pathKey)) return + prefixOnlyPaths.add(pathKey) + incrementPrefixes(prefixCounts, pathKey) + }, + update(pathKey: PathKey, newValue: unknown): void { const sig = signals.get(pathKey) if (!sig) return @@ -110,6 +128,13 @@ export function createPathSignalRegistry( decrementPrefixes(prefixCounts, key) } } + // Also clean up prefix-only registrations + for (const key of prefixOnlyPaths) { + if (key === pathKey || key.startsWith(prefix)) { + prefixOnlyPaths.delete(key) + decrementPrefixes(prefixCounts, key) + } + } }, size(): number { @@ -124,17 +149,6 @@ export function createPathSignalRegistry( return (prefixCounts.get(prefix) || 0) > 0 }, - getOrCreateRootProxy( - state: T, - factory: (state: T) => T, - ): T { - if (cachedProxyState === state) { - return cachedProxy as T - } - const proxy = factory(state) - cachedProxyState = state - cachedProxy = proxy - return proxy - }, + proxyCache: proxyWeakMap, } } diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index e5bdb7ee6..763713b2f 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -11,6 +11,14 @@ function isObjectOrArray(v: unknown): v is object { */ const proxyPathMap = new WeakMap() +/** + * Type for the proxy cache. Exported so the registry can own one. + * Caches proxies by their target object identity. + * Since Immer uses structural sharing, unchanged subtrees keep the same + * object reference across state snapshots — so we can reuse their proxies. + */ +export type ProxyCache = WeakMap + /** Get the path key associated with a tracking proxy, or undefined if not a proxy. */ export function getProxyPath(value: unknown): string | undefined { if (value !== null && typeof value === 'object') { @@ -37,16 +45,18 @@ export function createTrackingProxy( target: T, parentPath: string, registry: PathSignalRegistry, + cache: ProxyCache, ): T { - // Cache child proxies within this evaluation to avoid duplicate creation - const childCache = new Map() + // Check proxy cache — reuse proxy if we've already wrapped this exact object + const cached = cache.get(target) + if (cached) return cached as T // Use an unfrozen shell as the proxy target to avoid ES Proxy invariant // violations with frozen objects. The shell copies the target's prototype // so that Array.isArray, instanceof, etc. work correctly. const shell = Array.isArray(target) ? [] : Object.create(Object.getPrototypeOf(target)) - return new Proxy(shell, { + const proxy = new Proxy(shell, { get(_obj, prop, _receiver) { // Symbols: read from actual target (iterator protocol, toStringTag, etc.) if (typeof prop === 'symbol') return Reflect.get(target, prop) @@ -61,23 +71,19 @@ export function createTrackingProxy( const pathKey = parentPath ? parentPath + '.' + (prop as string) : (prop as string) if (isObjectOrArray(value)) { - // Register the signal (for hasPrefix/diff tracking) but DON'T read it. - // This avoids "false sharing" — intermediate object traversals won't - // create dependencies. Only leaf reads and explicit terminal reads - // (detected by useSignalSelector) establish reactive dependencies. - registry.getOrCreate(pathKey, value) - - // Return cached child proxy - if (!childCache.has(prop as string)) { - const childProxy = createTrackingProxy( - value as object, - pathKey, - registry, - ) - proxyPathMap.set(childProxy as object, pathKey) - childCache.set(prop as string, childProxy) - } - return childCache.get(prop as string) + // Register in prefix index (for hasPrefix/diff tracking) but DON'T + // create a signal. This avoids allocating signals for intermediate + // objects that are only traversed, not read as terminal values. + registry.ensurePrefix(pathKey) + + // Return cached child proxy (createTrackingProxy checks cache internally) + const childProxy = createTrackingProxy( + value as object, + pathKey, + registry, + cache, + ) + return childProxy } // Primitive leaf: read signal to establish dependency @@ -131,4 +137,10 @@ export function createTrackingProxy( return false }, }) as T + + // Cache by target identity — unchanged Immer subtrees reuse same proxy + cache.set(target, proxy) + proxyPathMap.set(proxy as object, parentPath) + + return proxy } diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index ae549f6e4..e88409cbf 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -42,9 +42,7 @@ export function useSignalSelector( // we explicitly read that object's signal to establish the terminal dependency. const selectorComputed = engine.computed(() => { const state = store.getState() as S & object - const proxy = registry.getOrCreateRootProxy(state, (s) => - createTrackingProxy(s, '', registry), - ) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) const result = selectorRef.current(proxy as S) // If the selector returned a tracking proxy (object), explicitly diff --git a/test/signals/diff.spec.ts b/test/signals/diff.spec.ts index e97dd8b58..82c8b2ce3 100644 --- a/test/signals/diff.spec.ts +++ b/test/signals/diff.spec.ts @@ -11,7 +11,7 @@ import type { PathSignalRegistry } from '../../src/signals/pathSignalRegistry' // Helper: create a registry with signals pre-populated by running a selector through a tracking proxy function setupRegistry(state: object, selectorFn: (s: any) => void) { const registry = createPathSignalRegistry(alienEngine) - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) selectorFn(proxy) return registry } @@ -177,14 +177,17 @@ describe('diffAndUpdateSignals', () => { diffAndUpdateSignals(prev, next, '', registry) + // Leaf signal should be updated expect(registry.getOrCreate('a.b.c.d', 'changed').get()).toBe('changed') - // All parent objects should have version bumps - const aSig = registry.getOrCreate('a', next.a) - const bSig = registry.getOrCreate('a.b', next.a.b) - const cSig = registry.getOrCreate('a.b.c', next.a.b.c) - // They are version counters (numbers), not 0 (initial) - expect(typeof aSig.get()).toBe('number') - expect((aSig.get() as number)).toBeGreaterThan(0) + // Intermediate objects have no signals (only prefix registrations) + // They don't get version bumps — that's the optimization + expect(registry.has('a')).toBe(false) + expect(registry.has('a.b')).toBe(false) + expect(registry.has('a.b.c')).toBe(false) + // But prefix index knows about them + expect(registry.hasPrefix('a')).toBe(true) + expect(registry.hasPrefix('a.b')).toBe(true) + expect(registry.hasPrefix('a.b.c')).toBe(true) }) }) @@ -332,14 +335,14 @@ describe('diffAndUpdateSignals', () => { s.todos[2].text }) - expect(registry.has('todos.2.text')).toBe(true) - expect(registry.has('todos.2')).toBe(true) + expect(registry.has('todos.2.text')).toBe(true) // leaf signal + expect(registry.hasPrefix('todos.2')).toBe(true) // prefix registered diffAndUpdateSignals(prev, next, '', registry) // Signals for removed index should be pruned expect(registry.has('todos.2.text')).toBe(false) - expect(registry.has('todos.2')).toBe(false) + expect(registry.hasPrefix('todos.2')).toBe(false) // Remaining indices should still exist expect(registry.has('todos.0.text')).toBe(true) expect(registry.has('todos.1.text')).toBe(true) @@ -351,7 +354,7 @@ describe('diffAndUpdateSignals', () => { const registry = createPathSignalRegistry(alienEngine) // Manually track some paths - const proxy = createTrackingProxy({ items: prev }, '', registry) + const proxy = createTrackingProxy({ items: prev }, '', registry, registry.proxyCache) proxy.items.length // triggers @@keys and items tracking const keysPath = 'items.@@keys' diff --git a/test/signals/trackingProxy.spec.ts b/test/signals/trackingProxy.spec.ts index 10dc58549..5391f6169 100644 --- a/test/signals/trackingProxy.spec.ts +++ b/test/signals/trackingProxy.spec.ts @@ -14,7 +14,7 @@ describe('createTrackingProxy', () => { it('returns primitive values from the underlying object', () => { const state = { name: 'Alice', age: 30 } const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) expect(proxy.name).toBe('Alice') expect(proxy.age).toBe(30) @@ -23,7 +23,7 @@ describe('createTrackingProxy', () => { it('creates signals for accessed primitive paths', () => { const state = { name: 'Alice', age: 30 } const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) expect(registry.has('name')).toBe(false) proxy.name @@ -37,7 +37,7 @@ describe('createTrackingProxy', () => { it('does NOT create signals for unaccessed paths', () => { const state = { name: 'Alice', age: 30, email: 'alice@example.com' } const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) proxy.name // access only name expect(registry.has('name')).toBe(true) @@ -50,7 +50,7 @@ describe('createTrackingProxy', () => { it('returns a tracking proxy for nested objects (not raw value)', () => { const state = { user: { name: 'Alice' } } const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) const userProxy = proxy.user // Should be a proxy, not the raw object @@ -62,13 +62,14 @@ describe('createTrackingProxy', () => { it('creates signals for nested property paths', () => { const state = { user: { name: 'Alice', address: { city: 'NYC' } } } const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) proxy.user.address.city - expect(registry.has('user')).toBe(true) - expect(registry.has('user.address')).toBe(true) - expect(registry.has('user.address.city')).toBe(true) + // Intermediate objects use ensurePrefix (no signal), leaves use getOrCreate + expect(registry.hasPrefix('user')).toBe(true) + expect(registry.hasPrefix('user.address')).toBe(true) + expect(registry.has('user.address.city')).toBe(true) // leaf — has signal // Did not access user.name expect(registry.has('user.name')).toBe(false) }) @@ -76,7 +77,7 @@ describe('createTrackingProxy', () => { it('caches child proxies within same evaluation', () => { const state = { user: { name: 'Alice' } } const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) const first = proxy.user const second = proxy.user @@ -88,19 +89,19 @@ describe('createTrackingProxy', () => { it('returns tracking proxies for array elements', () => { const state = { todos: [{ id: 1, text: 'Test' }] } const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) const todo = proxy.todos[0] expect(todo.text).toBe('Test') - expect(registry.has('todos')).toBe(true) - expect(registry.has('todos.0')).toBe(true) - expect(registry.has('todos.0.text')).toBe(true) + expect(registry.hasPrefix('todos')).toBe(true) // intermediate + expect(registry.hasPrefix('todos.0')).toBe(true) // intermediate + expect(registry.has('todos.0.text')).toBe(true) // leaf }) it('tracks array length access', () => { const state = { items: [1, 2, 3] } const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) expect(proxy.items.length).toBe(3) expect(registry.has('items.length')).toBe(true) @@ -114,7 +115,7 @@ describe('createTrackingProxy', () => { filter: 'all' as const, }) const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) expect(proxy.filter).toBe('all') expect(proxy.user.name).toBe('Alice') @@ -134,7 +135,7 @@ describe('createTrackingProxy', () => { }), }) const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) expect(proxy.todos[0].text).toBe('Test') expect(proxy.todos[1].completed).toBe(true) @@ -146,7 +147,7 @@ describe('createTrackingProxy', () => { it('tracks ownKeys access via @@keys signal', () => { const state = { a: 1, b: 2, c: 3 } const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) Object.keys(proxy) expect(registry.has('@@keys')).toBe(true) @@ -155,7 +156,7 @@ describe('createTrackingProxy', () => { it('tracks nested ownKeys with correct path', () => { const state = { user: { name: 'Alice', age: 30 } } const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) Object.keys(proxy.user) expect(registry.has('user.@@keys')).toBe(true) @@ -164,7 +165,7 @@ describe('createTrackingProxy', () => { it('supports for...in enumeration', () => { const state = { a: 1, b: 2 } const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) const keys: string[] = [] for (const key in proxy) { @@ -177,7 +178,7 @@ describe('createTrackingProxy', () => { it('supports Object.entries on proxy', () => { const state = Object.freeze({ x: 10, y: 20 }) const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) const entries = Object.entries(proxy) expect(entries).toEqual([ @@ -194,7 +195,7 @@ describe('createTrackingProxy', () => { ]), }) const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) const names = proxy.items.map((item: { id: number; name: string }) => item.name) expect(names).toEqual(['A', 'B']) @@ -209,7 +210,7 @@ describe('createTrackingProxy', () => { ]), }) const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) const active = proxy.items.filter( (item: { id: number; active: boolean }) => item.active, @@ -223,7 +224,7 @@ describe('createTrackingProxy', () => { const sym = Symbol('test') const state = { [sym]: 'secret', name: 'Alice' } const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) expect(proxy[sym]).toBe('secret') // Symbol access should not create any signals @@ -233,7 +234,7 @@ describe('createTrackingProxy', () => { it('supports Symbol.iterator for arrays', () => { const state = Object.freeze({ items: Object.freeze([1, 2, 3]) }) const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) const spread = [...proxy.items] expect(spread).toEqual([1, 2, 3]) @@ -244,7 +245,7 @@ describe('createTrackingProxy', () => { it('tracks "in" operator checks', () => { const state = { name: 'Alice', age: 30 } const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) expect('name' in proxy).toBe(true) expect('missing' in proxy).toBe(false) @@ -259,7 +260,7 @@ describe('createTrackingProxy', () => { it('treats null values as primitives (not objects)', () => { const state = { value: null as null | string } const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) expect(proxy.value).toBe(null) expect(registry.has('value')).toBe(true) @@ -272,13 +273,13 @@ describe('createTrackingProxy', () => { a: { b: { c: { d: 42 } } }, } const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) expect(proxy.a.b.c.d).toBe(42) - expect(registry.has('a')).toBe(true) - expect(registry.has('a.b')).toBe(true) - expect(registry.has('a.b.c')).toBe(true) - expect(registry.has('a.b.c.d')).toBe(true) + expect(registry.hasPrefix('a')).toBe(true) // intermediate + expect(registry.hasPrefix('a.b')).toBe(true) // intermediate + expect(registry.hasPrefix('a.b.c')).toBe(true) // intermediate + expect(registry.has('a.b.c.d')).toBe(true) // leaf }) // ─── Nesting path behavior ─── @@ -292,38 +293,39 @@ describe('createTrackingProxy', () => { settings: { theme: 'dark' }, } const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) // Access only alice's name proxy.users.alice.name - expect(registry.has('users')).toBe(true) - expect(registry.has('users.alice')).toBe(true) - expect(registry.has('users.alice.name')).toBe(true) + expect(registry.hasPrefix('users')).toBe(true) // intermediate + expect(registry.hasPrefix('users.alice')).toBe(true) // intermediate + expect(registry.has('users.alice.name')).toBe(true) // leaf // Siblings NOT accessed expect(registry.has('users.alice.age')).toBe(false) - expect(registry.has('users.bob')).toBe(false) + expect(registry.hasPrefix('users.bob')).toBe(false) expect(registry.has('users.bob.name')).toBe(false) - expect(registry.has('settings')).toBe(false) + expect(registry.hasPrefix('settings')).toBe(false) expect(registry.has('settings.theme')).toBe(false) }) - it('creates intermediate object signals as version counters', () => { + it('creates leaf signals with actual values, intermediate objects are prefix-only', () => { const state = { a: { b: { c: 42 } } } const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) proxy.a.b.c - // a and a.b are objects → version counter (0) - // a.b.c is a primitive → actual value (42) - const sigA = registry.getOrCreate('a', state.a) - const sigAB = registry.getOrCreate('a.b', state.a.b) - const sigABC = registry.getOrCreate('a.b.c', 42) + // Intermediate objects (a, a.b) only have prefix registrations, no signals + expect(registry.has('a')).toBe(false) + expect(registry.has('a.b')).toBe(false) + expect(registry.hasPrefix('a')).toBe(true) + expect(registry.hasPrefix('a.b')).toBe(true) - expect(sigA.get()).toBe(0) // version counter - expect(sigAB.get()).toBe(0) // version counter - expect(sigABC.get()).toBe(42) // actual value + // Leaf has a signal with the actual value + expect(registry.has('a.b.c')).toBe(true) + const sigABC = registry.getOrCreate('a.b.c', 42) + expect(sigABC.get()).toBe(42) }) it('handles mixed nested types: objects, arrays, and primitives', () => { @@ -336,21 +338,21 @@ describe('createTrackingProxy', () => { count: 5, }) const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) // Traverse: entities → users → [0] → tags → [0] const tag = proxy.entities.users[0].tags[0] expect(tag).toBe('admin') - expect(registry.has('entities')).toBe(true) - expect(registry.has('entities.users')).toBe(true) - expect(registry.has('entities.users.0')).toBe(true) - expect(registry.has('entities.users.0.tags')).toBe(true) - expect(registry.has('entities.users.0.tags.0')).toBe(true) + expect(registry.hasPrefix('entities')).toBe(true) // intermediate + expect(registry.hasPrefix('entities.users')).toBe(true) // intermediate + expect(registry.hasPrefix('entities.users.0')).toBe(true) // intermediate + expect(registry.hasPrefix('entities.users.0.tags')).toBe(true) // intermediate + expect(registry.has('entities.users.0.tags.0')).toBe(true) // leaf // Not accessed expect(registry.has('entities.users.0.id')).toBe(false) expect(registry.has('entities.users.0.tags.1')).toBe(false) - expect(registry.has('count')).toBe(false) + expect(registry.hasPrefix('count')).toBe(false) }) it('handles multiple access paths creating independent signal trees', () => { @@ -359,24 +361,24 @@ describe('createTrackingProxy', () => { b: { x: 10, y: 20 }, } const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) proxy.a.x proxy.b.y // Each branch creates its own path signals - expect(registry.has('a')).toBe(true) - expect(registry.has('a.x')).toBe(true) + expect(registry.hasPrefix('a')).toBe(true) // intermediate + expect(registry.has('a.x')).toBe(true) // leaf expect(registry.has('a.y')).toBe(false) // not accessed - expect(registry.has('b')).toBe(true) - expect(registry.has('b.y')).toBe(true) + expect(registry.hasPrefix('b')).toBe(true) // intermediate + expect(registry.has('b.y')).toBe(true) // leaf expect(registry.has('b.x')).toBe(false) // not accessed }) it('array index paths use numeric string keys', () => { const state = { items: ['zero', 'one', 'two'] } const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) proxy.items[0] proxy.items[2] @@ -393,7 +395,7 @@ describe('createTrackingProxy', () => { }, } const registry = makeRegistry() - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) // Iterate top-level keys Object.keys(proxy) @@ -411,14 +413,14 @@ describe('createTrackingProxy', () => { const registry = makeRegistry() // Two separate root proxies but same registry - const proxy1 = createTrackingProxy(state, '', registry) - const proxy2 = createTrackingProxy(state, '', registry) + const proxy1 = createTrackingProxy(state, '', registry, registry.proxyCache) + const proxy2 = createTrackingProxy(state, '', registry, registry.proxyCache) proxy1.user.profile.name proxy2.user.profile.name - // Should have created signals only once (same paths) - expect(registry.size()).toBe(3) // user, user.profile, user.profile.name + // Only the leaf creates a signal; user and user.profile are prefix-only + expect(registry.size()).toBe(1) // user.profile.name (leaf) }) it('child proxies from different root proxies track same signal paths', () => { @@ -436,14 +438,14 @@ describe('createTrackingProxy', () => { const c1 = scope.run(() => alienEngine.computed(() => { comp1Calls++ - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) return proxy.counter.value }), ) const c2 = scope.run(() => alienEngine.computed(() => { comp2Calls++ - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) return proxy.counter.value }), ) @@ -481,7 +483,7 @@ describe('createTrackingProxy', () => { const c = scope.run(() => alienEngine.computed(() => { calls++ - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) const result = proxy.nested.deep // Simulate what useSignalSelector does: detect proxy result // and explicitly read the object's signal for terminal dependency @@ -520,7 +522,7 @@ describe('createTrackingProxy', () => { const c = scope.run(() => alienEngine.computed(() => { calls++ - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) // Read through intermediate objects to a leaf return proxy.nested.deep.value }), @@ -556,7 +558,7 @@ describe('createTrackingProxy', () => { const parentComp = scope.run(() => alienEngine.computed(() => { parentCalls++ - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) return proxy.parent }), ) @@ -565,7 +567,7 @@ describe('createTrackingProxy', () => { const leafComp = scope.run(() => alienEngine.computed(() => { leafCalls++ - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) return proxy.parent.child.leaf }), ) @@ -589,7 +591,7 @@ describe('createTrackingProxy', () => { const nested = { x: 1, y: 2 } const registry = makeRegistry() // Create proxy with a base path — simulates a proxy for a nested subtree - const proxy = createTrackingProxy(nested, 'root.sub', registry) + const proxy = createTrackingProxy(nested, 'root.sub', registry, registry.proxyCache) proxy.x @@ -615,7 +617,7 @@ describe('createTrackingProxy', () => { const c = scope.run(() => { return alienEngine.computed(() => { callCount++ - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) return proxy.filter }) }) @@ -654,7 +656,7 @@ describe('createTrackingProxy', () => { const todoComputed = scope.run(() => alienEngine.computed(() => { todoCalls++ - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) const result = proxy.todos // Simulate terminal dependency for object result const proxyPath = getProxyPath(result) @@ -668,7 +670,7 @@ describe('createTrackingProxy', () => { const counterComputed = scope.run(() => alienEngine.computed(() => { counterCalls++ - const proxy = createTrackingProxy(state, '', registry) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) return proxy.counter }), ) From 7c52af08453b6963a3f5386bae3ba178169eb8fe Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 6 Jun 2026 23:55:02 -0400 Subject: [PATCH 09/95] Use parent-child index for path registry --- src/signals/diff.ts | 4 +-- src/signals/pathSignalRegistry.ts | 60 ++++++++++++++++++++++++++----- test/signals/diff.spec.ts | 50 ++++++++++++++++---------- 3 files changed, 85 insertions(+), 29 deletions(-) diff --git a/src/signals/diff.ts b/src/signals/diff.ts index 6ce4b823c..695fc59fa 100644 --- a/src/signals/diff.ts +++ b/src/signals/diff.ts @@ -58,7 +58,7 @@ export function diffAndUpdateSignals( const keysPath = parentPath ? parentPath + '.@@keys' : '@@keys' registry.update(keysPath, nextKeys) - // Prune signals for removed keys (any key in prev but not in next) + // Prune removed keys and their descendants const prevKeys = Object.keys(prev) for (let i = 0; i < prevKeys.length; i++) { if (!(prevKeys[i] in next)) { @@ -105,7 +105,7 @@ export function diffAndUpdateSignals( diffAndUpdateSignals(undefined, next[i], childPath, registry) } } - // Removed indices: prune + // Removed indices: prune descendants for (let i = next.length; i < prev.length; i++) { const childPath = parentPath ? parentPath + '.' + i : String(i) registry.prune(childPath) diff --git a/src/signals/pathSignalRegistry.ts b/src/signals/pathSignalRegistry.ts index b8d6bc1cd..c623c53ea 100644 --- a/src/signals/pathSignalRegistry.ts +++ b/src/signals/pathSignalRegistry.ts @@ -13,7 +13,8 @@ export interface PathSignalRegistry { /** Update a signal's value (called during diff). */ update(pathKey: PathKey, newValue: unknown): void - /** Remove signal for a path and all child paths. */ + /** Remove signal for a path and all child paths. + * Uses a parent→children index for O(subtree) instead of O(total signals). */ prune(pathKey: PathKey): void /** Number of active signals. */ @@ -80,10 +81,29 @@ export function createPathSignalRegistry( // Tracked so ensurePrefix is idempotent and getOrCreate can skip // re-incrementing prefixes if ensurePrefix was called first. const prefixOnlyPaths = new Set() + // Parent→children index: maps each path to its direct children. + // Enables O(subtree) prune instead of O(total signals) linear scan. + const childIndex = new Map>() // Proxy cache: keyed by target object identity. // Reuses proxies for unchanged Immer subtrees across state snapshots. const proxyWeakMap: ProxyCache = new WeakMap() + // Register a path in the parent→children index. + // Only links to immediate parent: "a.b.c" → childIndex["a.b"].add("a.b.c") + // Ancestors are already linked by their own registrations: + // when "a.b" was registered, childIndex["a"].add("a.b") was called. + function addToChildIndex(pathKey: string): void { + const idx = pathKey.lastIndexOf('.') + if (idx === -1) return // root-level path, no parent + const parent = pathKey.substring(0, idx) + let children = childIndex.get(parent) + if (!children) { + children = new Set() + childIndex.set(parent, children) + } + children.add(pathKey) + } + return { getOrCreate(pathKey: PathKey, currentValue: unknown): ReactiveSignal { let sig = signals.get(pathKey) @@ -91,6 +111,7 @@ export function createPathSignalRegistry( const initialValue = isObjectOrArray(currentValue) ? 0 : currentValue sig = engine.signal(initialValue) signals.set(pathKey, sig) + addToChildIndex(pathKey) // If ensurePrefix was called first, prefixes are already counted if (prefixOnlyPaths.has(pathKey)) { prefixOnlyPaths.delete(pathKey) @@ -105,6 +126,7 @@ export function createPathSignalRegistry( // Already has a signal or already prefix-registered — nothing to do if (signals.has(pathKey) || prefixOnlyPaths.has(pathKey)) return prefixOnlyPaths.add(pathKey) + addToChildIndex(pathKey) incrementPrefixes(prefixCounts, pathKey) }, @@ -121,20 +143,40 @@ export function createPathSignalRegistry( }, prune(pathKey: PathKey): void { - const prefix = pathKey + '.' - for (const key of signals.keys()) { - if (key === pathKey || key.startsWith(prefix)) { + // Recursively remove this path and all descendants using the child index. + // O(subtree size) instead of O(total signals). + const stack: string[] = [pathKey] + while (stack.length > 0) { + const key = stack.pop()! + // Push children onto stack before deleting + const children = childIndex.get(key) + if (children) { + for (const child of children) { + stack.push(child) + } + childIndex.delete(key) + } + // Remove from signals or prefix-only, and decrement prefix counts + if (signals.has(key)) { signals.delete(key) decrementPrefixes(prefixCounts, key) - } - } - // Also clean up prefix-only registrations - for (const key of prefixOnlyPaths) { - if (key === pathKey || key.startsWith(prefix)) { + } else if (prefixOnlyPaths.has(key)) { prefixOnlyPaths.delete(key) decrementPrefixes(prefixCounts, key) } } + // Remove this path from its parent's child set + const dotIdx = pathKey.lastIndexOf('.') + if (dotIdx !== -1) { + const parent = pathKey.substring(0, dotIdx) + const parentChildren = childIndex.get(parent) + if (parentChildren) { + parentChildren.delete(pathKey) + if (parentChildren.size === 0) { + childIndex.delete(parent) + } + } + } }, size(): number { diff --git a/test/signals/diff.spec.ts b/test/signals/diff.spec.ts index 82c8b2ce3..3839e1869 100644 --- a/test/signals/diff.spec.ts +++ b/test/signals/diff.spec.ts @@ -2,10 +2,7 @@ import { describe, it, expect } from 'vitest' import { alienEngine } from '../../src/signals/engine' import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' import { createTrackingProxy } from '../../src/signals/trackingProxy' -import { - diffAndUpdateSignals, - reconcileState, -} from '../../src/signals/diff' +import { diffAndUpdateSignals, reconcileState } from '../../src/signals/diff' import type { PathSignalRegistry } from '../../src/signals/pathSignalRegistry' // Helper: create a registry with signals pre-populated by running a selector through a tracking proxy @@ -31,7 +28,10 @@ function deepFreeze(obj: T): T { describe('diffAndUpdateSignals', () => { describe('structural sharing short-circuits', () => { it('skips entirely when prev === next', () => { - const state = deepFreeze({ todos: [{ id: 1, text: 'hi' }], filter: 'all' }) + const state = deepFreeze({ + todos: [{ id: 1, text: 'hi' }], + filter: 'all', + }) const registry = setupRegistry(state, (s) => { s.todos[0].text s.filter @@ -249,14 +249,19 @@ describe('diffAndUpdateSignals', () => { it('handles nested key changes', () => { const prev = { entities: { user1: { name: 'Alice' } } } - const next = { entities: { user1: { name: 'Alice' }, user2: { name: 'Bob' } } } + const next = { + entities: { user1: { name: 'Alice' }, user2: { name: 'Bob' } }, + } const registry = setupRegistry(prev, (s) => { Object.keys(s.entities) s.entities.user1.name }) - const entitiesKeysSig = registry.getOrCreate('entities.@@keys', Object.keys(prev.entities)) + const entitiesKeysSig = registry.getOrCreate( + 'entities.@@keys', + Object.keys(prev.entities), + ) const initialVersion = entitiesKeysSig.get() diffAndUpdateSignals(prev, next, '', registry) @@ -295,7 +300,12 @@ describe('diffAndUpdateSignals', () => { it('handles array item addition (push)', () => { const prev = deepFreeze({ todos: [{ id: 1, text: 'first' }] }) - const next = deepFreeze({ todos: [{ id: 1, text: 'first' }, { id: 2, text: 'second' }] }) + const next = deepFreeze({ + todos: [ + { id: 1, text: 'first' }, + { id: 2, text: 'second' }, + ], + }) const registry = setupRegistry(prev, (s) => { s.todos.length @@ -354,18 +364,18 @@ describe('diffAndUpdateSignals', () => { const registry = createPathSignalRegistry(alienEngine) // Manually track some paths - const proxy = createTrackingProxy({ items: prev }, '', registry, registry.proxyCache) + const proxy = createTrackingProxy( + { items: prev }, + '', + registry, + registry.proxyCache, + ) proxy.items.length // triggers @@keys and items tracking const keysPath = 'items.@@keys' registry.getOrCreate(keysPath, prev.length) - diffAndUpdateSignals( - { items: prev }, - { items: next }, - '', - registry, - ) + diffAndUpdateSignals({ items: prev }, { items: next }, '', registry) // @@keys updated for length change // The items signal itself should be version-bumped @@ -535,7 +545,11 @@ describe('diffAndUpdateSignals', () => { const cSig = registry.getOrCreate('c', 3) const derived = alienEngine.computed(() => { - return (aSig.get() as number) + (bSig.get() as number) + (cSig.get() as number) + return ( + (aSig.get() as number) + + (bSig.get() as number) + + (cSig.get() as number) + ) }) const scope = alienEngine.createScope() @@ -661,7 +675,7 @@ describe('diffAndUpdateSignals', () => { const registry = createPathSignalRegistry(alienEngine) // Should not throw — just returns because prev !== next but neither is object - diffAndUpdateSignals('old', 'new', 'root', registry) + diffAndUpdateSignals('old', 'new', 'root', registry, []) }) it('handles nested path offset correctly', () => { @@ -672,7 +686,7 @@ describe('diffAndUpdateSignals', () => { registry.getOrCreate('nested.obj.value', 10) // Diff starting from a path offset - diffAndUpdateSignals(prev, next, 'nested.obj', registry) + diffAndUpdateSignals(prev, next, 'nested.obj', registry, []) expect(registry.getOrCreate('nested.obj.value', 20).get()).toBe(20) }) From 504c18a3291923224331c056fa28d767dd7ad0b9 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 7 Jun 2026 17:36:29 -0400 Subject: [PATCH 10/95] Add benchmarks for reconciliation logic --- test/benchmarks/reconcile/benchEngine.ts | 51 ++++++ test/benchmarks/reconcile/registryHelper.ts | 140 +++++++++++++++ test/benchmarks/reconcile/run-single.ts | 82 +++++++++ test/benchmarks/reconcile/runner.ts | 160 +++++++++++++++++ test/benchmarks/reconcile/scenarios.ts | 113 ++++++++++++ test/benchmarks/reconcile/stateBuilders.ts | 180 ++++++++++++++++++++ 6 files changed, 726 insertions(+) create mode 100644 test/benchmarks/reconcile/benchEngine.ts create mode 100644 test/benchmarks/reconcile/registryHelper.ts create mode 100644 test/benchmarks/reconcile/run-single.ts create mode 100644 test/benchmarks/reconcile/runner.ts create mode 100644 test/benchmarks/reconcile/scenarios.ts create mode 100644 test/benchmarks/reconcile/stateBuilders.ts diff --git a/test/benchmarks/reconcile/benchEngine.ts b/test/benchmarks/reconcile/benchEngine.ts new file mode 100644 index 000000000..aa0ede77e --- /dev/null +++ b/test/benchmarks/reconcile/benchEngine.ts @@ -0,0 +1,51 @@ +/** + * Minimal SignalEngine stub for benchmarking. + * Signals store values but don't propagate — we measure diff cost, not signal propagation. + */ +import type { SignalEngine, ReactiveSignal, SignalScope } from '../../../src/signals/types' + +let updateCount = 0 + +export function resetUpdateCount(): void { + updateCount = 0 +} + +export function getUpdateCount(): number { + return updateCount +} + +function createBenchSignal(value: T): ReactiveSignal { + let current = value + return { + get(): T { + return current + }, + set(v: T): void { + current = v + updateCount++ + }, + } +} + +export const benchEngine: SignalEngine = { + signal(value: T): ReactiveSignal { + return createBenchSignal(value) + }, + computed(fn: () => T) { + return { get: fn } + }, + effect(_fn: () => void): void { + // no-op + }, + batch(fn: () => void): void { + fn() + }, + createScope(): SignalScope { + return { + run(fn: () => T): T { + return fn() + }, + stop(): void {}, + } + }, +} diff --git a/test/benchmarks/reconcile/registryHelper.ts b/test/benchmarks/reconcile/registryHelper.ts new file mode 100644 index 000000000..80286de5c --- /dev/null +++ b/test/benchmarks/reconcile/registryHelper.ts @@ -0,0 +1,140 @@ +/** + * Helpers to populate a PathSignalRegistry with tracked paths, + * simulating what happens when selectors access specific state paths. + */ +import { createPathSignalRegistry } from '../../../src/signals/pathSignalRegistry' +import type { PathSignalRegistry } from '../../../src/signals/pathSignalRegistry' +import type { SignalEngine } from '../../../src/signals/types' + +/** + * Walk an object and register all leaf paths as tracked signals. + * Intermediate objects get ensurePrefix (like the tracking proxy does). + */ +export function registerAllPaths( + state: unknown, + parentPath: string, + registry: PathSignalRegistry, +): void { + if (state === null || typeof state !== 'object') { + if (parentPath) registry.getOrCreate(parentPath, state) + return + } + + if (parentPath) registry.ensurePrefix(parentPath) + + if (Array.isArray(state)) { + for (let i = 0; i < state.length; i++) { + const childPath = parentPath ? `${parentPath}.${i}` : String(i) + registerAllPaths(state[i], childPath, registry) + } + } else { + const keys = Object.keys(state as Record) + for (const key of keys) { + const childPath = parentPath ? `${parentPath}.${key}` : key + registerAllPaths((state as Record)[key], childPath, registry) + } + } +} + +/** + * Register specific paths as tracked. + * Each path gets a signal created with the value from the state. + */ +export function registerPaths( + state: unknown, + paths: string[], + registry: PathSignalRegistry, +): void { + for (const path of paths) { + const value = getValueAtPath(state, path) + // Ensure parent prefixes exist + const parts = path.split('.') + for (let i = 1; i < parts.length; i++) { + registry.ensurePrefix(parts.slice(0, i).join('.')) + } + registry.getOrCreate(path, value) + } +} + +/** + * Register paths for a typical "entity list" selector pattern: + * - all leaf fields of every entity in an array + * - the array's @@keys signal + */ +export function registerEntityArrayPaths( + state: Record, + arrayPath: string, + registry: PathSignalRegistry, +): void { + const arr = getValueAtPath(state, arrayPath) as unknown[] + if (!Array.isArray(arr)) return + + // Register array path and @@keys + registry.ensurePrefix(arrayPath) + registry.getOrCreate(`${arrayPath}.@@keys`, arr.length) + registry.getOrCreate(`${arrayPath}.length`, arr.length) + + for (let i = 0; i < arr.length; i++) { + const itemPath = `${arrayPath}.${i}` + registry.ensurePrefix(itemPath) + const item = arr[i] + if (item !== null && typeof item === 'object' && !Array.isArray(item)) { + const keys = Object.keys(item as Record) + for (const key of keys) { + registry.getOrCreate( + `${itemPath}.${key}`, + (item as Record)[key], + ) + } + } + } +} + +/** + * Register paths for a wide-flat object: all leaf values of each top-level key's child object. + */ +export function registerWideFlatPaths( + state: Record, + registry: PathSignalRegistry, +): void { + const keys = Object.keys(state) + for (const key of keys) { + const childPath = key + const child = state[key] + if (child !== null && typeof child === 'object' && !Array.isArray(child)) { + registry.ensurePrefix(childPath) + const childKeys = Object.keys(child as Record) + for (const ck of childKeys) { + registry.getOrCreate( + `${childPath}.${ck}`, + (child as Record)[ck], + ) + } + } else { + registry.getOrCreate(childPath, child) + } + } +} + +/** + * Register all paths in a deep-nested state tree. + */ +export function registerDeepNestedPaths( + state: Record, + parentPath: string, + registry: PathSignalRegistry, +): void { + registerAllPaths(state, parentPath, registry) +} + +function getValueAtPath(state: unknown, path: string): unknown { + const parts = path.split('.') + let current = state + for (const part of parts) { + if (current === null || typeof current !== 'object') return undefined + current = (current as Record)[part] + } + return current +} + +export { createPathSignalRegistry } diff --git a/test/benchmarks/reconcile/run-single.ts b/test/benchmarks/reconcile/run-single.ts new file mode 100644 index 000000000..2bf2e8fcc --- /dev/null +++ b/test/benchmarks/reconcile/run-single.ts @@ -0,0 +1,82 @@ +/** + * Single-scenario runner for profiling with pprof-it or --prof. + * Runs one scenario in a tight loop with no table formatting overhead. + * + * Usage: + * npx tsx test/benchmarks/reconcile/run-single.ts entity-shift + * npx pprof-it npx tsx test/benchmarks/reconcile/run-single.ts entity-shift + * npx tsx test/benchmarks/reconcile/run-single.ts entity-shift --iterations 100000 + */ +import { diffAndUpdateSignals } from '../../../src/signals/diff' +import { resetUpdateCount, getUpdateCount } from './benchEngine' +import { createScenarios } from './scenarios' + +const WARMUP = 2000 +const DEFAULT_ITERATIONS = 50000 + +function main() { + const args = process.argv.slice(2) + let scenarioName: string | undefined + let iterations = DEFAULT_ITERATIONS + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--iterations' || args[i] === '-n') { + iterations = parseInt(args[i + 1], 10) + i++ + } else if (!args[i].startsWith('-')) { + scenarioName = args[i] + } + } + + if (!scenarioName) { + const all = createScenarios() + console.error('Usage: run-single.ts ') + console.error(`Available: ${all.map((s) => s.name).join(', ')}`) + process.exit(1) + } + + const scenarios = createScenarios() + const scenario = scenarios.find((s) => s.name === scenarioName) + if (!scenario) { + console.error(`Unknown scenario: ${scenarioName}`) + console.error(`Available: ${scenarios.map((s) => s.name).join(', ')}`) + process.exit(1) + } + + const { forward, reverse, registry } = scenario + + console.log(`Scenario: ${scenario.name}`) + console.log(`Tracked paths: ${scenario.trackedPaths}`) + console.log(`Warmup: ${WARMUP} | Iterations: ${iterations}`) + + // Warmup + for (let i = 0; i < WARMUP; i++) { + resetUpdateCount() + diffAndUpdateSignals(forward.prev, forward.next, '', registry) + diffAndUpdateSignals(reverse.prev, reverse.next, '', registry) + } + + // Count signal updates + resetUpdateCount() + diffAndUpdateSignals(forward.prev, forward.next, '', registry) + const fwdUpdates = getUpdateCount() + resetUpdateCount() + diffAndUpdateSignals(reverse.prev, reverse.next, '', registry) + const revUpdates = getUpdateCount() + console.log(`Signal updates: ${fwdUpdates} fwd, ${revUpdates} rev`) + + // Timed run + console.log(`\nStarting ${iterations} iterations...`) + const start = performance.now() + for (let i = 0; i < iterations; i++) { + diffAndUpdateSignals(forward.prev, forward.next, '', registry) + diffAndUpdateSignals(reverse.prev, reverse.next, '', registry) + } + const elapsed = performance.now() - start + + console.log(`Total: ${elapsed.toFixed(1)} ms`) + console.log(`Per iteration: ${(elapsed / iterations).toFixed(4)} ms`) + console.log(`Iterations/sec: ${Math.round(1000 / (elapsed / iterations)).toLocaleString()}`) +} + +main() diff --git a/test/benchmarks/reconcile/runner.ts b/test/benchmarks/reconcile/runner.ts new file mode 100644 index 000000000..0c999485e --- /dev/null +++ b/test/benchmarks/reconcile/runner.ts @@ -0,0 +1,160 @@ +/** + * Reconcile diff micro-benchmark runner. + * Runs all scenarios (or a specific one) and outputs a results table. + * + * Usage: + * npx tsx test/benchmarks/reconcile/runner.ts + * npx tsx test/benchmarks/reconcile/runner.ts entity-shift + * npx tsx test/benchmarks/reconcile/runner.ts --iterations 50000 + */ +import { diffAndUpdateSignals } from '../../../src/signals/diff' +import { resetUpdateCount, getUpdateCount } from './benchEngine' +import { createScenarios, type BenchmarkScenario } from './scenarios' + +const WARMUP = 1000 +const DEFAULT_ITERATIONS = 10000 + +interface BenchResult { + name: string + description: string + trackedPaths: number + signalUpdatesForward: number + signalUpdatesReverse: number + warmupMs: number + totalMs: number + msPerIter: number + itersPerSec: number +} + +function runScenario(scenario: BenchmarkScenario, iterations: number): BenchResult { + const { forward, reverse, registry } = scenario + + // Warmup + const warmupStart = performance.now() + for (let i = 0; i < WARMUP; i++) { + resetUpdateCount() + diffAndUpdateSignals(forward.prev, forward.next, '', registry) + diffAndUpdateSignals(reverse.prev, reverse.next, '', registry) + } + const warmupMs = performance.now() - warmupStart + + // Measure signal updates for one forward+reverse cycle + resetUpdateCount() + diffAndUpdateSignals(forward.prev, forward.next, '', registry) + const signalUpdatesForward = getUpdateCount() + resetUpdateCount() + diffAndUpdateSignals(reverse.prev, reverse.next, '', registry) + const signalUpdatesReverse = getUpdateCount() + + // Timed run + const start = performance.now() + for (let i = 0; i < iterations; i++) { + diffAndUpdateSignals(forward.prev, forward.next, '', registry) + diffAndUpdateSignals(reverse.prev, reverse.next, '', registry) + } + const totalMs = performance.now() - start + const msPerIter = totalMs / iterations + + return { + name: scenario.name, + description: scenario.description, + trackedPaths: scenario.trackedPaths, + signalUpdatesForward, + signalUpdatesReverse, + warmupMs, + totalMs, + msPerIter, + itersPerSec: 1000 / msPerIter, + } +} + +function formatTable(results: BenchResult[]): string { + const cols = [ + { key: 'name' as const, header: 'Scenario', width: 24 }, + { key: 'trackedPaths' as const, header: 'Tracked', width: 8, align: 'right' as const }, + { key: 'signalUpdatesForward' as const, header: 'Fwd Upd', width: 9, align: 'right' as const }, + { key: 'signalUpdatesReverse' as const, header: 'Rev Upd', width: 9, align: 'right' as const }, + { key: 'msPerIter' as const, header: 'ms/iter', width: 10, align: 'right' as const }, + { key: 'itersPerSec' as const, header: 'iter/s', width: 10, align: 'right' as const }, + { key: 'totalMs' as const, header: 'Total ms', width: 10, align: 'right' as const }, + ] + + function pad(s: string, w: number, align: 'left' | 'right' = 'left'): string { + if (s.length >= w) return s.slice(0, w) + return align === 'right' ? s.padStart(w) : s.padEnd(w) + } + + function formatVal(key: string, val: unknown): string { + if (typeof val === 'number') { + if (key === 'msPerIter') return val.toFixed(4) + if (key === 'itersPerSec') return Math.round(val).toLocaleString() + if (key === 'totalMs') return val.toFixed(1) + return String(val) + } + return String(val) + } + + const lines: string[] = [] + // Header + const header = cols.map((c) => pad(c.header, c.width, c.align)).join(' | ') + lines.push(header) + lines.push(cols.map((c) => '-'.repeat(c.width)).join('-+-')) + + // Rows + for (const r of results) { + const row = cols + .map((c) => pad(formatVal(c.key, r[c.key]), c.width, c.align)) + .join(' | ') + lines.push(row) + } + + return lines.join('\n') +} + +// ---- Main ---- +function main() { + const args = process.argv.slice(2) + let filterName: string | undefined + let iterations = DEFAULT_ITERATIONS + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--iterations' || args[i] === '-n') { + iterations = parseInt(args[i + 1], 10) + i++ + } else if (!args[i].startsWith('-')) { + filterName = args[i] + } + } + + console.log(`\nReconcile Diff Benchmark`) + console.log(`Warmup: ${WARMUP} | Iterations: ${iterations} (forward+reverse per iter)\n`) + + let scenarios = createScenarios() + if (filterName) { + scenarios = scenarios.filter((s) => s.name === filterName) + if (scenarios.length === 0) { + console.error(`Unknown scenario: ${filterName}`) + console.error(`Available: ${createScenarios().map((s) => s.name).join(', ')}`) + process.exit(1) + } + } + + const results: BenchResult[] = [] + for (const scenario of scenarios) { + process.stdout.write(` Running ${scenario.name}...`) + const result = runScenario(scenario, iterations) + results.push(result) + console.log(` ${result.msPerIter.toFixed(4)} ms/iter`) + } + + console.log(`\n${formatTable(results)}\n`) + + // Descriptions + console.log('Scenario descriptions:') + for (const r of results) { + console.log(` ${r.name}: ${r.description}`) + } + console.log() +} + +main() diff --git a/test/benchmarks/reconcile/scenarios.ts b/test/benchmarks/reconcile/scenarios.ts new file mode 100644 index 000000000..c05d3cc61 --- /dev/null +++ b/test/benchmarks/reconcile/scenarios.ts @@ -0,0 +1,113 @@ +/** + * Benchmark scenario definitions. + * Each scenario constructs frozen state with structural sharing, + * sets up a registry with appropriate tracked paths, and provides + * forward/reverse state pairs for diffing. + */ +import type { PathSignalRegistry } from '../../../src/signals/pathSignalRegistry' +import { benchEngine } from './benchEngine' +import { + createPathSignalRegistry, + registerEntityArrayPaths, + registerAllPaths, + registerWideFlatPaths, +} from './registryHelper' +import { + buildEntityShift, + buildEntitySingleUpdate, + buildEntityBulkUpdate, + buildDeepNested, + buildWideFlat, + buildArrayReorder, + buildArrayAppend, +} from './stateBuilders' + +export interface BenchmarkScenario { + name: string + description: string + forward: { prev: unknown; next: unknown } + reverse: { prev: unknown; next: unknown } + registry: PathSignalRegistry + trackedPaths: number +} + +function makeScenario( + name: string, + description: string, + forward: { prev: unknown; next: unknown }, + reverse: { prev: unknown; next: unknown }, + setupRegistry: (registry: PathSignalRegistry, state: unknown) => void, +): BenchmarkScenario { + const registry = createPathSignalRegistry(benchEngine) + setupRegistry(registry, forward.prev) + return { + name, + description, + forward, + reverse, + registry, + trackedPaths: registry.size(), + } +} + +export function createScenarios(): BenchmarkScenario[] { + const entityShift = buildEntityShift(1000) + const entitySingle = buildEntitySingleUpdate(1000) + const entityBulk = buildEntityBulkUpdate(1000) + const deepNested = buildDeepNested(5, 5) + const wideFlat = buildWideFlat(500, 5) + const arrayReorder = buildArrayReorder(100) + const arrayAppend = buildArrayAppend(1000, 10) + + return [ + makeScenario( + 'entity-shift', + 'Remove first of 1000 entities (flat array), all shift by 1', + entityShift.forward, + entityShift.reverse, + (reg, state) => registerEntityArrayPaths(state as Record, 'entities', reg), + ), + makeScenario( + 'entity-single-update', + 'Update 1 entity name in 1000 (structural sharing)', + entitySingle.forward, + entitySingle.reverse, + (reg, state) => registerEntityArrayPaths(state as Record, 'entities', reg), + ), + makeScenario( + 'entity-bulk-update', + 'Update 10% of 1000 entities (every 10th, structural sharing)', + entityBulk.forward, + entityBulk.reverse, + (reg, state) => registerEntityArrayPaths(state as Record, 'entities', reg), + ), + makeScenario( + 'deep-nested', + '5-level deep (breadth=5), change one leaf', + deepNested.forward, + deepNested.reverse, + (reg, state) => registerAllPaths(state, '', reg), + ), + makeScenario( + 'wide-flat', + '500 top-level keys (2 fields each), change 5', + wideFlat.forward, + wideFlat.reverse, + (reg, state) => registerWideFlatPaths(state as Record, reg), + ), + makeScenario( + 'array-reorder', + 'Reverse 100-element entity array', + arrayReorder.forward, + arrayReorder.reverse, + (reg, state) => registerEntityArrayPaths(state as Record, 'items', reg), + ), + makeScenario( + 'array-append', + 'Append 10 items to 1000-element array', + arrayAppend.forward, + arrayAppend.reverse, + (reg, state) => registerEntityArrayPaths(state as Record, 'items', reg), + ), + ] +} diff --git a/test/benchmarks/reconcile/stateBuilders.ts b/test/benchmarks/reconcile/stateBuilders.ts new file mode 100644 index 000000000..3f31e12c3 --- /dev/null +++ b/test/benchmarks/reconcile/stateBuilders.ts @@ -0,0 +1,180 @@ +/** + * State construction helpers for benchmark scenarios. + * All state is deeply frozen to simulate Immer-produced Redux state. + * Forward/reverse pairs share structure to simulate Immer's structural sharing. + */ + +export function deepFreeze(obj: T): T { + if (obj === null || typeof obj !== 'object') return obj + Object.freeze(obj) + if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i++) deepFreeze(obj[i]) + } else { + const keys = Object.keys(obj) + for (let i = 0; i < keys.length; i++) { + deepFreeze((obj as Record)[keys[i]]) + } + } + return obj +} + +interface Entity { + id: string + name: string + email: string + status: string + score: number +} + +function makeEntity(i: number): Entity { + return { + id: `entity-${i}`, + name: `User ${i}`, + email: `user${i}@example.com`, + status: i % 3 === 0 ? 'active' : i % 3 === 1 ? 'inactive' : 'pending', + score: i * 10, + } +} + +// --------------- Scenario: entity-shift --------------- +// Remove first entity from a flat array of 1000, shifting all others +export function buildEntityShift(count = 1000) { + const entities: Entity[] = [] + for (let i = 0; i < count; i++) entities.push(makeEntity(i)) + const base = deepFreeze({ entities }) + + // Remove first entity — everything shifts + const shifted = entities.slice(1) + const next = deepFreeze({ entities: shifted }) + + // Reverse: put it back (go from shifted → base) + return { forward: { prev: base, next }, reverse: { prev: next, next: base } } +} + +// --------------- Scenario: entity-single-update --------------- +// Update one entity's name in a flat array of 1000 +export function buildEntitySingleUpdate(count = 1000) { + const entities: Entity[] = [] + for (let i = 0; i < count; i++) entities.push(makeEntity(i)) + const base = deepFreeze({ entities }) + + // Structural sharing: new array with only index 500 changed + const midIdx = Math.floor(count / 2) + const updatedEntities = [...entities] + updatedEntities[midIdx] = { ...entities[midIdx], name: 'UPDATED' } + // Freeze only the new objects, reuse frozen originals at other indices + Object.freeze(updatedEntities[midIdx]) + Object.freeze(updatedEntities) + const next = deepFreeze({ entities: updatedEntities }) + + return { forward: { prev: base, next }, reverse: { prev: next, next: base } } +} + +// --------------- Scenario: entity-bulk-update --------------- +// Update 10% of entities (every 10th) +export function buildEntityBulkUpdate(count = 1000) { + const entities: Entity[] = [] + for (let i = 0; i < count; i++) entities.push(makeEntity(i)) + const base = deepFreeze({ entities }) + + const updatedEntities = [...entities] + for (let i = 0; i < count; i += 10) { + updatedEntities[i] = { ...entities[i], score: entities[i].score + 1 } + Object.freeze(updatedEntities[i]) + } + Object.freeze(updatedEntities) + const next = deepFreeze({ entities: updatedEntities }) + + return { forward: { prev: base, next }, reverse: { prev: next, next: base } } +} + +// --------------- Scenario: deep-nested --------------- +// 5-level deep object, change one leaf +export function buildDeepNested(depth = 5, breadth = 5) { + function buildLevel(d: number): Record { + const obj: Record = {} + if (d === 0) { + for (let i = 0; i < breadth; i++) { + obj[`leaf${i}`] = `value-${i}` + } + } else { + for (let i = 0; i < breadth; i++) { + obj[`level${i}`] = buildLevel(d - 1) + } + } + return obj + } + + const base = deepFreeze(buildLevel(depth)) + + // Change one deep leaf with structural sharing along the path + // Path: level0.level0.level0.level0.level0.leaf0 + function changeDeepLeaf(state: Record, d: number): Record { + if (d === 0) { + return { ...state, leaf0: 'CHANGED' } + } + return { ...state, level0: changeDeepLeaf(state.level0 as Record, d - 1) } + } + + const next = deepFreeze(changeDeepLeaf(base, depth)) + + return { forward: { prev: base, next }, reverse: { prev: next, next: base } } +} + +// --------------- Scenario: wide-flat --------------- +// Single object with many top-level keys, change a few +export function buildWideFlat(width = 500, changes = 5) { + const obj: Record = {} + for (let i = 0; i < width; i++) { + obj[`key${i}`] = { value: i, label: `item-${i}` } + } + const base = deepFreeze(obj) + + const next: Record = { ...obj } + const changeIndices: number[] = [] + for (let c = 0; c < changes; c++) { + const idx = Math.floor((width / changes) * c) + changeIndices.push(idx) + next[`key${idx}`] = { value: idx + 1000, label: `item-${idx}-updated` } + Object.freeze(next[`key${idx}`]) + } + Object.freeze(next) + + return { + forward: { prev: base, next: next as Record }, + reverse: { prev: next as Record, next: base }, + meta: { changeIndices }, + } +} + +// --------------- Scenario: array-reorder --------------- +// Reverse the order of a 100-element array +export function buildArrayReorder(count = 100) { + const items: Entity[] = [] + for (let i = 0; i < count; i++) items.push(makeEntity(i)) + const base = deepFreeze({ items }) + + const reversed = [...items].reverse() + Object.freeze(reversed) + const next = deepFreeze({ items: reversed }) + + return { forward: { prev: base, next }, reverse: { prev: next, next: base } } +} + +// --------------- Scenario: array-append --------------- +// Append 10 items to a 1000-element array (with structural sharing for unchanged items) +export function buildArrayAppend(count = 1000, appendCount = 10) { + const items: Entity[] = [] + for (let i = 0; i < count; i++) items.push(makeEntity(i)) + const base = deepFreeze({ items }) + + const newItems = [...items] + for (let i = 0; i < appendCount; i++) { + newItems.push(makeEntity(count + i)) + Object.freeze(newItems[count + i]) + } + Object.freeze(newItems) + const next = deepFreeze({ items: newItems }) + + return { forward: { prev: base, next }, reverse: { prev: next, next: base } } +} From d7cc9ac2718664e8e9c50c11fdd5b82c76632048 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 7 Jun 2026 18:00:08 -0400 Subject: [PATCH 11/95] Implement array identity tracking --- src/signals/arrayKeys.ts | 75 ++ src/signals/diff.ts | 187 +++- src/signals/pathSignalRegistry.ts | 18 + src/signals/trackingProxy.ts | 23 +- test/benchmarks/reconcile/registryHelper.ts | 22 +- test/signals/diff.spec.ts | 44 +- test/signals/identityArrayTracking.spec.ts | 1067 +++++++++++++++++++ test/signals/trackingProxy.spec.ts | 17 +- 8 files changed, 1401 insertions(+), 52 deletions(-) create mode 100644 src/signals/arrayKeys.ts create mode 100644 test/signals/identityArrayTracking.spec.ts diff --git a/src/signals/arrayKeys.ts b/src/signals/arrayKeys.ts new file mode 100644 index 000000000..a0daf2b52 --- /dev/null +++ b/src/signals/arrayKeys.ts @@ -0,0 +1,75 @@ +/** + * Identity-based array tracking utilities. + * + * When array elements have ID fields (e.g., {id: 42, name: "Alice"}), + * signals use identity-based paths (items.{id:42}.name) instead of + * index-based paths (items.0.name). This makes signals stable across + * reorders, insertions, and removals. + */ + +/** Candidate key field names, checked in priority order. */ +const KEY_CANDIDATES = ['id', 'key', '_id', '__id'] as const + +/** + * Detect an identity key field on an object. + * Returns the property name if found, undefined otherwise. + * Checks 'id' > 'key' > '_id' > '__id' — same priority order as Legend State. + */ +export function findKeyField(obj: unknown): string | undefined { + if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return undefined + for (let i = 0; i < KEY_CANDIDATES.length; i++) { + const candidate = KEY_CANDIDATES[i] + if (candidate in (obj as Record)) { + const value = (obj as Record)[candidate] + // Key must be a primitive that can be used in a path string + if (typeof value === 'string' || typeof value === 'number') { + return candidate + } + } + } + return undefined +} + +/** + * Per-array metadata for identity-based tracking. + * Stored in the registry, keyed by the array's parent path. + */ +export interface ArrayMeta { + /** The property name used as the identity key (e.g., 'id') */ + keyField: string + /** + * Map from key value → previous array element reference. + * Used by diff to match elements across prev/next arrays by identity. + */ + entityMap: Map +} + +/** + * Build the identity path segment for an array element. + * e.g., arrayPath="items", keyField="id", element={id: 42} → "items.{id:42}" + */ +export function buildIdentityPath( + arrayPath: string, + keyField: string, + keyValue: string | number, +): string { + return arrayPath ? `${arrayPath}.{${keyField}:${keyValue}}` : `{${keyField}:${keyValue}}` +} + +/** + * Extract a key value from an array element. + * Returns undefined if element is not an object or key field is missing. + */ +export function getKeyValue( + element: unknown, + keyField: string, +): string | number | undefined { + if (element === null || typeof element !== 'object' || Array.isArray(element)) { + return undefined + } + const value = (element as Record)[keyField] + if (typeof value === 'string' || typeof value === 'number') { + return value + } + return undefined +} diff --git a/src/signals/diff.ts b/src/signals/diff.ts index 695fc59fa..232e869cc 100644 --- a/src/signals/diff.ts +++ b/src/signals/diff.ts @@ -1,3 +1,8 @@ +import { + buildIdentityPath, + findKeyField, + getKeyValue, +} from './arrayKeys' import type { PathSignalRegistry } from './pathSignalRegistry' import type { SignalEngine } from './types' @@ -72,7 +77,7 @@ export function diffAndUpdateSignals( return } - // Both arrays: handle length + index-based diffing + // Both arrays: handle length + identity-based or index-based diffing if (Array.isArray(prev) && Array.isArray(next)) { if (parentPath) { registry.update(parentPath, next) @@ -82,33 +87,40 @@ export function diffAndUpdateSignals( if (prev.length !== next.length) { const keysPath = parentPath ? parentPath + '.@@keys' : '@@keys' registry.update(keysPath, next.length) - // Also update the explicit 'length' signal for selectors that read .length const lengthPath = parentPath ? parentPath + '.length' : 'length' registry.update(lengthPath, next.length) } - // Recurse into tracked indices, prune removed ones - const minLen = Math.min(prev.length, next.length) - // Shared indices: recurse if tracked - for (let i = 0; i < minLen; i++) { - if (prev[i] !== next[i]) { - const childPath = parentPath ? parentPath + '.' + i : String(i) - if (registry.hasPrefix(childPath)) { - diffAndUpdateSignals(prev[i], next[i], childPath, registry) + // Try identity-based diffing: detect key field on first element + let meta = registry.getArrayMeta(parentPath) + if (meta) { + // Meta may exist (created by proxy) but entityMap is empty on first diff. + // Populate it from prev array so we can match entities. + if (meta.entityMap.size === 0 && prev.length > 0) { + for (let i = 0; i < prev.length; i++) { + const kv = getKeyValue(prev[i], meta.keyField) + if (kv !== undefined) meta.entityMap.set(kv, prev[i]) } } - } - // Added indices: recurse if tracked - for (let i = minLen; i < next.length; i++) { - const childPath = parentPath ? parentPath + '.' + i : String(i) - if (registry.hasPrefix(childPath)) { - diffAndUpdateSignals(undefined, next[i], childPath, registry) + } else if (next.length > 0) { + const keyField = findKeyField(next[0]) + if (keyField) { + const entityMap = new Map() + for (let i = 0; i < prev.length; i++) { + const kv = getKeyValue(prev[i], keyField) + if (kv !== undefined) entityMap.set(kv, prev[i]) + } + meta = { keyField, entityMap } + registry.setArrayMeta(parentPath, meta) } } - // Removed indices: prune descendants - for (let i = next.length; i < prev.length; i++) { - const childPath = parentPath ? parentPath + '.' + i : String(i) - registry.prune(childPath) + + if (meta) { + // Identity-based diffing: match elements by key, not by index + diffArrayByKey(prev, next, parentPath, registry, meta) + } else { + // Fallback: index-based diffing for primitive arrays or no key field + diffArrayByIndex(prev, next, parentPath, registry) } return } @@ -128,6 +140,141 @@ export function diffAndUpdateSignals( } } +/** + * Identity-based array diffing. Matches elements by their key field value. + * When an entity moves indices but content is unchanged (prev === next via + * structural sharing), the identity-based signal path stays the same and + * no signal updates are needed. + */ +function diffArrayByKey( + prev: unknown[], + next: unknown[], + parentPath: string, + registry: PathSignalRegistry, + meta: import('./arrayKeys').ArrayMeta, +): void { + const { keyField, entityMap: prevEntityMap } = meta + + // Fast path: same-length arrays with no key shifts. + // Only touch changed elements. Update entityMap incrementally instead of rebuilding. + if (prev.length === next.length) { + let usedFastPath = true + for (let i = 0; i < next.length; i++) { + if (prev[i] === next[i]) continue // structural sharing: skip (most elements) + + // Different ref — verify same key at same index + const prevKv = getKeyValue(prev[i], keyField) + const nextKv = getKeyValue(next[i], keyField) + if (prevKv === undefined || nextKv === undefined || prevKv !== nextKv) { + usedFastPath = false + break + } + + // Same key, different content — diff and update entityMap entry + const identityPath = buildIdentityPath(parentPath, keyField, nextKv) + if (registry.hasPrefix(identityPath)) { + diffAndUpdateSignals(prev[i], next[i], identityPath, registry) + } + prevEntityMap.set(nextKv, next[i]) + } + if (usedFastPath) { + return + } + // Fast path failed partway — fall through to full identity diff below. + // Restore any partially-updated entityMap entries from prev array. + for (let i = 0; i < prev.length; i++) { + const kv = getKeyValue(prev[i], keyField) + if (kv !== undefined) prevEntityMap.set(kv, prev[i]) + } + } + + // Full identity-based diff: lengths differ or keys shifted positions + const mayHaveRemovals = next.length < prev.length + + const nextEntityMap = new Map() + const seenPrevKeys = mayHaveRemovals ? new Set() : null + + for (let i = 0; i < next.length; i++) { + const nextItem = next[i] + const kv = getKeyValue(nextItem, keyField) + + if (kv === undefined) { + const childPath = parentPath ? parentPath + '.' + i : String(i) + if (registry.hasPrefix(childPath)) { + const prevItem = i < prev.length ? prev[i] : undefined + if (prevItem !== nextItem) { + diffAndUpdateSignals(prevItem, nextItem, childPath, registry) + } + } + continue + } + + nextEntityMap.set(kv, nextItem) + const prevItem = prevEntityMap.get(kv) + + if (prevItem === nextItem) { + if (seenPrevKeys) seenPrevKeys.add(kv) + continue + } + + if (seenPrevKeys) seenPrevKeys.add(kv) + + const identityPath = buildIdentityPath(parentPath, keyField, kv) + + if (prevItem !== undefined) { + if (registry.hasPrefix(identityPath)) { + diffAndUpdateSignals(prevItem, nextItem, identityPath, registry) + } + } else { + if (registry.hasPrefix(identityPath)) { + diffAndUpdateSignals(undefined, nextItem, identityPath, registry) + } + } + } + + if (seenPrevKeys) { + for (const [kv] of prevEntityMap) { + if (!seenPrevKeys.has(kv)) { + const identityPath = buildIdentityPath(parentPath, keyField, kv) + registry.prune(identityPath) + } + } + } + + meta.entityMap = nextEntityMap +} + +/** + * Index-based array diffing (original algorithm). + * Used for primitive arrays or arrays without a detectable key field. + */ +function diffArrayByIndex( + prev: unknown[], + next: unknown[], + parentPath: string, + registry: PathSignalRegistry, +): void { + const minLen = Math.min(prev.length, next.length) + for (let i = 0; i < minLen; i++) { + if (prev[i] !== next[i]) { + const childPath = parentPath ? parentPath + '.' + i : String(i) + if (registry.hasPrefix(childPath)) { + diffAndUpdateSignals(prev[i], next[i], childPath, registry) + } + } + } + for (let i = minLen; i < next.length; i++) { + const childPath = parentPath ? parentPath + '.' + i : String(i) + if (registry.hasPrefix(childPath)) { + diffAndUpdateSignals(undefined, next[i], childPath, registry) + } + } + for (let i = next.length; i < prev.length; i++) { + const childPath = parentPath ? parentPath + '.' + i : String(i) + registry.prune(childPath) + } +} + /** * Wrapper that batches all signal updates into a single propagation pass. */ diff --git a/src/signals/pathSignalRegistry.ts b/src/signals/pathSignalRegistry.ts index c623c53ea..e0bb64d6a 100644 --- a/src/signals/pathSignalRegistry.ts +++ b/src/signals/pathSignalRegistry.ts @@ -1,3 +1,4 @@ +import type { ArrayMeta } from './arrayKeys' import type { ProxyCache } from './trackingProxy' import type { PathKey, ReactiveSignal, SignalEngine } from './types' @@ -28,6 +29,12 @@ export interface PathSignalRegistry { /** Proxy cache for reusing proxies across evaluations (keyed by object identity). */ proxyCache: ProxyCache + + /** Get array metadata for identity-based tracking at a given path. */ + getArrayMeta(arrayPath: string): ArrayMeta | undefined + + /** Set/update array metadata for identity-based tracking. */ + setArrayMeta(arrayPath: string, meta: ArrayMeta): void } function isObjectOrArray(v: unknown): v is object { @@ -87,6 +94,9 @@ export function createPathSignalRegistry( // Proxy cache: keyed by target object identity. // Reuses proxies for unchanged Immer subtrees across state snapshots. const proxyWeakMap: ProxyCache = new WeakMap() + // Per-array metadata for identity-based tracking. + // Maps array path → ArrayMeta (keyField + entityMap). + const arrayMetas = new Map() // Register a path in the parent→children index. // Only links to immediate parent: "a.b.c" → childIndex["a.b"].add("a.b.c") @@ -192,5 +202,13 @@ export function createPathSignalRegistry( }, proxyCache: proxyWeakMap, + + getArrayMeta(arrayPath: string): ArrayMeta | undefined { + return arrayMetas.get(arrayPath) + }, + + setArrayMeta(arrayPath: string, meta: ArrayMeta): void { + arrayMetas.set(arrayPath, meta) + }, } } diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index 763713b2f..1580da669 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -1,3 +1,4 @@ +import { buildIdentityPath, findKeyField, getKeyValue } from './arrayKeys' import type { PathSignalRegistry } from './pathSignalRegistry' function isObjectOrArray(v: unknown): v is object { @@ -68,9 +69,29 @@ export function createTrackingProxy( return value } - const pathKey = parentPath ? parentPath + '.' + (prop as string) : (prop as string) + let pathKey = parentPath ? parentPath + '.' + (prop as string) : (prop as string) if (isObjectOrArray(value)) { + // For array element access: check if parent array has identity-based tracking. + // If so, use the identity path (items.{id:42}) instead of index path (items.0). + if (Array.isArray(target) && !Array.isArray(value) && !isNaN(Number(prop))) { + let meta = registry.getArrayMeta(parentPath) + if (!meta) { + // First time accessing this array's elements — try to detect key field + const keyField = findKeyField(value) + if (keyField) { + meta = { keyField, entityMap: new Map() } + registry.setArrayMeta(parentPath, meta) + } + } + if (meta) { + const kv = getKeyValue(value, meta.keyField) + if (kv !== undefined) { + pathKey = buildIdentityPath(parentPath, meta.keyField, kv) + } + } + } + // Register in prefix index (for hasPrefix/diff tracking) but DON'T // create a signal. This avoids allocating signals for intermediate // objects that are only traversed, not read as terminal values. diff --git a/test/benchmarks/reconcile/registryHelper.ts b/test/benchmarks/reconcile/registryHelper.ts index 80286de5c..bad976915 100644 --- a/test/benchmarks/reconcile/registryHelper.ts +++ b/test/benchmarks/reconcile/registryHelper.ts @@ -5,6 +5,7 @@ import { createPathSignalRegistry } from '../../../src/signals/pathSignalRegistry' import type { PathSignalRegistry } from '../../../src/signals/pathSignalRegistry' import type { SignalEngine } from '../../../src/signals/types' +import { findKeyField, buildIdentityPath, getKeyValue } from '../../../src/signals/arrayKeys' /** * Walk an object and register all leaf paths as tracked signals. @@ -74,11 +75,28 @@ export function registerEntityArrayPaths( registry.getOrCreate(`${arrayPath}.@@keys`, arr.length) registry.getOrCreate(`${arrayPath}.length`, arr.length) + // Detect key field on first element (same as proxy does) + const keyField = arr.length > 0 ? findKeyField(arr[0]) : undefined + + if (keyField) { + // Set up ArrayMeta so diff uses identity-based matching + registry.setArrayMeta(arrayPath, { keyField, entityMap: new Map() }) + } + for (let i = 0; i < arr.length; i++) { - const itemPath = `${arrayPath}.${i}` - registry.ensurePrefix(itemPath) const item = arr[i] if (item !== null && typeof item === 'object' && !Array.isArray(item)) { + // Use identity path if key field detected, else index path + let itemPath: string + if (keyField) { + const kv = getKeyValue(item, keyField) + itemPath = kv !== undefined + ? buildIdentityPath(arrayPath, keyField, kv) + : `${arrayPath}.${i}` + } else { + itemPath = `${arrayPath}.${i}` + } + registry.ensurePrefix(itemPath) const keys = Object.keys(item as Record) for (const key of keys) { registry.getOrCreate( diff --git a/test/signals/diff.spec.ts b/test/signals/diff.spec.ts index 3839e1869..88cb29f0a 100644 --- a/test/signals/diff.spec.ts +++ b/test/signals/diff.spec.ts @@ -37,8 +37,8 @@ describe('diffAndUpdateSignals', () => { s.filter }) - // Get initial signal values - const textSig = registry.getOrCreate('todos.0.text', 'hi') + // Get initial signal values (identity-based path for keyed array element) + const textSig = registry.getOrCreate('todos.{id:1}.text', 'hi') const filterSig = registry.getOrCreate('filter', 'all') const initialText = textSig.get() const initialFilter = filterSig.get() @@ -62,7 +62,8 @@ describe('diffAndUpdateSignals', () => { s.count }) - const todoTextSig = registry.getOrCreate('todos.0.text', 'hi') + // Identity-based path for keyed array element + const todoTextSig = registry.getOrCreate('todos.{id:1}.text', 'hi') const todosSig = registry.getOrCreate('todos', todos) const initialTodosVersion = todosSig.get() const initialText = todoTextSig.get() @@ -292,10 +293,8 @@ describe('diffAndUpdateSignals', () => { diffAndUpdateSignals(prev, next, '', registry) - expect(registry.getOrCreate('todos.0.completed', true).get()).toBe(true) - // todos[1] unchanged (structural sharing would make this ===) - // But since we deepFreeze manually, they're different refs. In real Immer, they'd be ===. - // The test verifies the signal update mechanism works. + // Identity-based paths: elements have 'id' field + expect(registry.getOrCreate('todos.{id:1}.completed', true).get()).toBe(true) }) it('handles array item addition (push)', () => { @@ -309,12 +308,10 @@ describe('diffAndUpdateSignals', () => { const registry = setupRegistry(prev, (s) => { s.todos.length - s.todos[0].text + s.todos[0].text // registers at todos.{id:1}.text }) // Track @@keys - registry.getOrCreate('todos.@@keys', 1).get() - const keysSignal = registry.getOrCreate('todos.@@keys', 1) const initialKeysVersion = keysSignal.get() @@ -345,17 +342,18 @@ describe('diffAndUpdateSignals', () => { s.todos[2].text }) - expect(registry.has('todos.2.text')).toBe(true) // leaf signal - expect(registry.hasPrefix('todos.2')).toBe(true) // prefix registered + // Identity-based paths: elements have 'id' field + expect(registry.has('todos.{id:3}.text')).toBe(true) // leaf signal + expect(registry.hasPrefix('todos.{id:3}')).toBe(true) // prefix registered diffAndUpdateSignals(prev, next, '', registry) - // Signals for removed index should be pruned - expect(registry.has('todos.2.text')).toBe(false) - expect(registry.hasPrefix('todos.2')).toBe(false) - // Remaining indices should still exist - expect(registry.has('todos.0.text')).toBe(true) - expect(registry.has('todos.1.text')).toBe(true) + // Signals for removed entity should be pruned (by identity, not index) + expect(registry.has('todos.{id:3}.text')).toBe(false) + expect(registry.hasPrefix('todos.{id:3}')).toBe(false) + // Remaining entities should still exist + expect(registry.has('todos.{id:1}.text')).toBe(true) + expect(registry.has('todos.{id:2}.text')).toBe(true) }) it('handles array length decrease with @@keys update', () => { @@ -489,7 +487,8 @@ describe('diffAndUpdateSignals', () => { }) let todoComputedRuns = 0 - const todoTextSig = registry.getOrCreate('todos.0.text', 'hi') + // Identity-based path for keyed array element + const todoTextSig = registry.getOrCreate('todos.{id:1}.text', 'hi') const todoComputed = alienEngine.computed(() => { todoComputedRuns++ return todoTextSig.get() @@ -639,7 +638,8 @@ describe('diffAndUpdateSignals', () => { s.filter }) - const todoTextSig = registry.getOrCreate('todos.0.text', 'first') + // Identity-based path for keyed array element + const todoTextSig = registry.getOrCreate('todos.{id:1}.text', 'first') const c1ValueSig = registry.getOrCreate('counters.counter1.value', 0) const c2ValueSig = registry.getOrCreate('counters.counter2.value', 0) const filterSig = registry.getOrCreate('filter', 'all') @@ -675,7 +675,7 @@ describe('diffAndUpdateSignals', () => { const registry = createPathSignalRegistry(alienEngine) // Should not throw — just returns because prev !== next but neither is object - diffAndUpdateSignals('old', 'new', 'root', registry, []) + diffAndUpdateSignals('old', 'new', 'root', registry) }) it('handles nested path offset correctly', () => { @@ -686,7 +686,7 @@ describe('diffAndUpdateSignals', () => { registry.getOrCreate('nested.obj.value', 10) // Diff starting from a path offset - diffAndUpdateSignals(prev, next, 'nested.obj', registry, []) + diffAndUpdateSignals(prev, next, 'nested.obj', registry) expect(registry.getOrCreate('nested.obj.value', 20).get()).toBe(20) }) diff --git a/test/signals/identityArrayTracking.spec.ts b/test/signals/identityArrayTracking.spec.ts new file mode 100644 index 000000000..956b54291 --- /dev/null +++ b/test/signals/identityArrayTracking.spec.ts @@ -0,0 +1,1067 @@ +import { describe, it, expect } from 'vitest' +import { alienEngine } from '../../src/signals/engine' +import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import { createTrackingProxy } from '../../src/signals/trackingProxy' +import { diffAndUpdateSignals, reconcileState } from '../../src/signals/diff' +import { + findKeyField, + buildIdentityPath, + getKeyValue, +} from '../../src/signals/arrayKeys' + +// Helper: create a registry with signals pre-populated by running a selector through a tracking proxy +function setupRegistry(state: object, selectorFn: (s: any) => void) { + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + selectorFn(proxy) + return registry +} + +function deepFreeze(obj: T): T { + if (obj === null || typeof obj !== 'object') return obj + Object.freeze(obj) + for (const val of Object.values(obj as Record)) { + if (val !== null && typeof val === 'object' && !Object.isFrozen(val)) { + deepFreeze(val) + } + } + return obj +} + +describe('findKeyField', () => { + it('detects "id" field', () => { + expect(findKeyField({ id: 1, name: 'Alice' })).toBe('id') + }) + + it('detects "key" field', () => { + expect(findKeyField({ key: 'abc', value: 42 })).toBe('key') + }) + + it('detects "_id" field (MongoDB-style)', () => { + expect(findKeyField({ _id: 'abc123', name: 'test' })).toBe('_id') + }) + + it('detects "__id" field', () => { + expect(findKeyField({ __id: 99, data: 'x' })).toBe('__id') + }) + + it('prefers "id" over "key"', () => { + expect(findKeyField({ id: 1, key: 'abc' })).toBe('id') + }) + + it('prefers "key" over "_id"', () => { + expect(findKeyField({ key: 'abc', _id: 'xyz' })).toBe('key') + }) + + it('accepts string key values', () => { + expect(findKeyField({ id: 'uuid-123' })).toBe('id') + }) + + it('accepts number key values', () => { + expect(findKeyField({ id: 42 })).toBe('id') + }) + + it('rejects object key values', () => { + expect(findKeyField({ id: { nested: true }, name: 'x' })).toBe(undefined) + }) + + it('rejects boolean key values', () => { + expect(findKeyField({ id: true })).toBe(undefined) + }) + + it('rejects null key values', () => { + expect(findKeyField({ id: null })).toBe(undefined) + }) + + it('rejects undefined key values', () => { + expect(findKeyField({ id: undefined })).toBe(undefined) + }) + + it('returns undefined for arrays', () => { + expect(findKeyField([1, 2, 3])).toBe(undefined) + }) + + it('returns undefined for null', () => { + expect(findKeyField(null)).toBe(undefined) + }) + + it('returns undefined for primitives', () => { + expect(findKeyField(42)).toBe(undefined) + expect(findKeyField('str')).toBe(undefined) + }) + + it('returns undefined for objects without ID fields', () => { + expect(findKeyField({ name: 'Alice', age: 30 })).toBe(undefined) + }) +}) + +describe('buildIdentityPath', () => { + it('builds path with numeric id', () => { + expect(buildIdentityPath('items', 'id', 42)).toBe('items.{id:42}') + }) + + it('builds path with string id', () => { + expect(buildIdentityPath('items', 'id', 'abc')).toBe('items.{id:abc}') + }) + + it('builds path with custom key field', () => { + expect(buildIdentityPath('data', '_id', 'mongo123')).toBe( + 'data.{_id:mongo123}', + ) + }) + + it('handles empty parent path', () => { + expect(buildIdentityPath('', 'id', 1)).toBe('{id:1}') + }) + + it('handles nested parent path', () => { + expect(buildIdentityPath('state.users.list', 'id', 5)).toBe( + 'state.users.list.{id:5}', + ) + }) +}) + +describe('getKeyValue', () => { + it('extracts string key', () => { + expect(getKeyValue({ id: 'abc' }, 'id')).toBe('abc') + }) + + it('extracts number key', () => { + expect(getKeyValue({ id: 42 }, 'id')).toBe(42) + }) + + it('returns undefined for missing field', () => { + expect(getKeyValue({ name: 'x' }, 'id')).toBe(undefined) + }) + + it('returns undefined for non-primitive key value', () => { + expect(getKeyValue({ id: { x: 1 } }, 'id')).toBe(undefined) + }) + + it('returns undefined for null input', () => { + expect(getKeyValue(null, 'id')).toBe(undefined) + }) + + it('returns undefined for array input', () => { + expect(getKeyValue([1, 2], 'id')).toBe(undefined) + }) + + it('returns undefined for primitive input', () => { + expect(getKeyValue(42, 'id')).toBe(undefined) + }) +}) + +describe('identity-based array tracking: proxy', () => { + it('uses identity path for array elements with id field', () => { + const state = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + }) + const registry = setupRegistry(state, (s) => { + s.items[0].name + s.items[1].name + }) + + expect(registry.has('items.{id:1}.name')).toBe(true) + expect(registry.has('items.{id:2}.name')).toBe(true) + // Should NOT have index-based paths + expect(registry.has('items.0.name')).toBe(false) + expect(registry.has('items.1.name')).toBe(false) + }) + + it('uses identity path for "key" field', () => { + const state = deepFreeze({ + items: [ + { key: 'a', value: 1 }, + { key: 'b', value: 2 }, + ], + }) + const registry = setupRegistry(state, (s) => { + s.items[0].value + s.items[1].value + }) + + expect(registry.has('items.{key:a}.value')).toBe(true) + expect(registry.has('items.{key:b}.value')).toBe(true) + }) + + it('uses identity path for "_id" field', () => { + const state = deepFreeze({ + docs: [{ _id: 'abc', title: 'Doc1' }], + }) + const registry = setupRegistry(state, (s) => { + s.docs[0].title + }) + + expect(registry.has('docs.{_id:abc}.title')).toBe(true) + }) + + it('falls back to index path for arrays without key fields', () => { + const state = deepFreeze({ + items: [ + { name: 'Alice', age: 30 }, + { name: 'Bob', age: 25 }, + ], + }) + const registry = setupRegistry(state, (s) => { + s.items[0].name + s.items[1].name + }) + + // No id/key field — should use index-based paths + expect(registry.has('items.0.name')).toBe(true) + expect(registry.has('items.1.name')).toBe(true) + }) + + it('falls back to index path for primitive arrays', () => { + const state = deepFreeze({ tags: ['a', 'b', 'c'] }) + const registry = setupRegistry(state, (s) => { + s.tags[0] + s.tags[1] + }) + + expect(registry.has('tags.0')).toBe(true) + expect(registry.has('tags.1')).toBe(true) + }) + + it('creates ArrayMeta on first element access', () => { + const state = deepFreeze({ + items: [{ id: 1, name: 'Alice' }], + }) + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + // Before access — no meta + expect(registry.getArrayMeta('items')).toBeUndefined() + + // Access an element + ;(proxy as any).items[0].name + + // Now meta should exist + const meta = registry.getArrayMeta('items') + expect(meta).toBeDefined() + expect(meta!.keyField).toBe('id') + }) + + it('reuses ArrayMeta on subsequent accesses', () => { + const state = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + }) + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + ;(proxy as any).items[0].name + const meta1 = registry.getArrayMeta('items') + + ;(proxy as any).items[1].name + const meta2 = registry.getArrayMeta('items') + + // Same meta object — not recreated + expect(meta1).toBe(meta2) + }) + + it('handles nested arrays with identity paths', () => { + const state = deepFreeze({ + data: { + users: [ + { id: 'u1', name: 'Alice' }, + { id: 'u2', name: 'Bob' }, + ], + }, + }) + const registry = setupRegistry(state, (s) => { + s.data.users[0].name + s.data.users[1].name + }) + + expect(registry.has('data.users.{id:u1}.name')).toBe(true) + expect(registry.has('data.users.{id:u2}.name')).toBe(true) + }) + + it('uses index path for array-of-arrays', () => { + const state = deepFreeze({ + matrix: [ + [1, 2], + [3, 4], + ], + }) + const registry = setupRegistry(state, (s) => { + s.matrix[0][0] + s.matrix[1][1] + }) + + // Inner arrays are arrays, not objects — should use index paths + expect(registry.has('matrix.0.0')).toBe(true) + expect(registry.has('matrix.1.1')).toBe(true) + }) +}) + +describe('identity-based array tracking: diff', () => { + describe('entity update (no reorder)', () => { + it('updates only changed entity fields', () => { + const prev = deepFreeze({ + items: [ + { id: 1, name: 'Alice', score: 100 }, + { id: 2, name: 'Bob', score: 200 }, + { id: 3, name: 'Carol', score: 300 }, + ], + }) + const next = deepFreeze({ + items: [ + { id: 1, name: 'Alice', score: 100 }, + { id: 2, name: 'Bob', score: 250 }, // changed + { id: 3, name: 'Carol', score: 300 }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].score + s.items[1].score + s.items[2].score + }) + + const sig1 = registry.getOrCreate('items.{id:1}.score', 100) + const sig2 = registry.getOrCreate('items.{id:2}.score', 200) + const sig3 = registry.getOrCreate('items.{id:3}.score', 300) + + diffAndUpdateSignals(prev, next, '', registry) + + expect(sig1.get()).toBe(100) // unchanged + expect(sig2.get()).toBe(250) // updated + expect(sig3.get()).toBe(300) // unchanged + }) + + it('skips entity entirely when structurally shared', () => { + const sharedEntity = deepFreeze({ id: 1, name: 'Alice', score: 100 }) + const prev = deepFreeze({ + items: [sharedEntity, { id: 2, name: 'Bob', score: 200 }], + }) + // Immer-style: share entity reference, change another + const next = deepFreeze({ + items: [sharedEntity, { id: 2, name: 'Bob', score: 250 }], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[0].score + s.items[1].score + }) + + const nameSig = registry.getOrCreate('items.{id:1}.name', 'Alice') + const score1Sig = registry.getOrCreate('items.{id:1}.score', 100) + const score2Sig = registry.getOrCreate('items.{id:2}.score', 200) + + const initialName = nameSig.get() + const initialScore1 = score1Sig.get() + + diffAndUpdateSignals(prev, next, '', registry) + + // Shared entity skipped entirely (prevItem === nextItem) + expect(nameSig.get()).toBe(initialName) + expect(score1Sig.get()).toBe(initialScore1) + // Other entity updated + expect(score2Sig.get()).toBe(250) + }) + }) + + describe('entity addition', () => { + it('handles new entity appended', () => { + const shared = deepFreeze([ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]) + const prev = deepFreeze({ items: shared }) + const next = deepFreeze({ + items: [...shared, { id: 3, name: 'Carol' }], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[1].name + }) + + const keysSignal = registry.getOrCreate('items.@@keys', 2) + const initialVersion = keysSignal.get() + + diffAndUpdateSignals(prev, next, '', registry) + + // @@keys should update for length change + expect(keysSignal.get()).not.toBe(initialVersion) + // Existing entities unchanged (structurally shared) + expect(registry.getOrCreate('items.{id:1}.name', 'Alice').get()).toBe( + 'Alice', + ) + }) + + it('handles new entity prepended', () => { + const prev = deepFreeze({ + items: [ + { id: 2, name: 'Bob' }, + { id: 3, name: 'Carol' }, + ], + }) + const next = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Carol' }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[1].name + }) + + // Existing entities are tracked by identity, not index + const bobSig = registry.getOrCreate('items.{id:2}.name', 'Bob') + const carolSig = registry.getOrCreate('items.{id:3}.name', 'Carol') + + diffAndUpdateSignals(prev, next, '', registry) + + // Bob and Carol's signals are stable — same identity path regardless of index + expect(bobSig.get()).toBe('Bob') + expect(carolSig.get()).toBe('Carol') + }) + }) + + describe('entity removal', () => { + it('prunes signals for removed entity', () => { + const prev = deepFreeze({ + items: [ + { id: 1, name: 'Alice', score: 100 }, + { id: 2, name: 'Bob', score: 200 }, + { id: 3, name: 'Carol', score: 300 }, + ], + }) + const next = deepFreeze({ + items: [ + { id: 1, name: 'Alice', score: 100 }, + { id: 3, name: 'Carol', score: 300 }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[0].score + s.items[1].name + s.items[1].score + s.items[2].name + s.items[2].score + }) + + expect(registry.has('items.{id:2}.name')).toBe(true) + expect(registry.has('items.{id:2}.score')).toBe(true) + expect(registry.hasPrefix('items.{id:2}')).toBe(true) + + diffAndUpdateSignals(prev, next, '', registry) + + // Entity 2 removed — all its signals pruned + expect(registry.has('items.{id:2}.name')).toBe(false) + expect(registry.has('items.{id:2}.score')).toBe(false) + expect(registry.hasPrefix('items.{id:2}')).toBe(false) + // Remaining entities intact + expect(registry.has('items.{id:1}.name')).toBe(true) + expect(registry.has('items.{id:3}.name')).toBe(true) + }) + + it('prunes signals when removing from beginning (shift)', () => { + const prev = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Carol' }, + ], + }) + const next = deepFreeze({ + items: [ + { id: 2, name: 'Bob' }, + { id: 3, name: 'Carol' }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[1].name + s.items[2].name + }) + + diffAndUpdateSignals(prev, next, '', registry) + + // Entity 1 removed + expect(registry.has('items.{id:1}.name')).toBe(false) + // Entities 2 and 3 still exist at their identity paths + expect(registry.has('items.{id:2}.name')).toBe(true) + expect(registry.has('items.{id:3}.name')).toBe(true) + }) + + it('handles removing all entities', () => { + const prev = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + }) + const next = deepFreeze({ items: [] as any[] }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[1].name + }) + + expect(registry.has('items.{id:1}.name')).toBe(true) + expect(registry.has('items.{id:2}.name')).toBe(true) + + diffAndUpdateSignals(prev, next, '', registry) + + expect(registry.has('items.{id:1}.name')).toBe(false) + expect(registry.has('items.{id:2}.name')).toBe(false) + }) + }) + + describe('entity reorder', () => { + it('does not update signals when entities just swap positions', () => { + const entityA = deepFreeze({ id: 1, name: 'Alice', score: 100 }) + const entityB = deepFreeze({ id: 2, name: 'Bob', score: 200 }) + const prev = deepFreeze({ items: [entityA, entityB] }) + // Swap positions but share refs (Immer structural sharing) + const next = deepFreeze({ items: [entityB, entityA] }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[0].score + s.items[1].name + s.items[1].score + }) + + const aliceNameSig = registry.getOrCreate('items.{id:1}.name', 'Alice') + const aliceScoreSig = registry.getOrCreate('items.{id:1}.score', 100) + const bobNameSig = registry.getOrCreate('items.{id:2}.name', 'Bob') + const bobScoreSig = registry.getOrCreate('items.{id:2}.score', 200) + + const initialAliceName = aliceNameSig.get() + const initialAliceScore = aliceScoreSig.get() + const initialBobName = bobNameSig.get() + const initialBobScore = bobScoreSig.get() + + diffAndUpdateSignals(prev, next, '', registry) + + // With identity-based tracking, reorder with shared refs → zero updates + expect(aliceNameSig.get()).toBe(initialAliceName) + expect(aliceScoreSig.get()).toBe(initialAliceScore) + expect(bobNameSig.get()).toBe(initialBobName) + expect(bobScoreSig.get()).toBe(initialBobScore) + }) + + it('handles reorder with content change', () => { + const prev = deepFreeze({ + items: [ + { id: 1, name: 'Alice', score: 100 }, + { id: 2, name: 'Bob', score: 200 }, + ], + }) + const next = deepFreeze({ + items: [ + { id: 2, name: 'Bob', score: 250 }, // moved AND changed + { id: 1, name: 'Alice', score: 100 }, // just moved + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].score + s.items[1].score + }) + + diffAndUpdateSignals(prev, next, '', registry) + + // Alice unchanged (even though she moved) + expect(registry.getOrCreate('items.{id:1}.score', 100).get()).toBe(100) + // Bob changed + expect(registry.getOrCreate('items.{id:2}.score', 250).get()).toBe(250) + }) + + it('handles complete reversal', () => { + const e1 = deepFreeze({ id: 1, v: 'a' }) + const e2 = deepFreeze({ id: 2, v: 'b' }) + const e3 = deepFreeze({ id: 3, v: 'c' }) + const prev = deepFreeze({ items: [e1, e2, e3] }) + const next = deepFreeze({ items: [e3, e2, e1] }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].v + s.items[1].v + s.items[2].v + }) + + const sig1 = registry.getOrCreate('items.{id:1}.v', 'a') + const sig2 = registry.getOrCreate('items.{id:2}.v', 'b') + const sig3 = registry.getOrCreate('items.{id:3}.v', 'c') + + const init1 = sig1.get() + const init2 = sig2.get() + const init3 = sig3.get() + + diffAndUpdateSignals(prev, next, '', registry) + + // All shared refs — zero updates + expect(sig1.get()).toBe(init1) + expect(sig2.get()).toBe(init2) + expect(sig3.get()).toBe(init3) + }) + }) + + describe('entityMap lifecycle', () => { + it('populates entityMap from prev on first diff', () => { + const prev = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + }) + const next = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bobby' }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[1].name + }) + + // Meta exists (created by proxy) but entityMap is empty + const meta = registry.getArrayMeta('items') + expect(meta).toBeDefined() + expect(meta!.entityMap.size).toBe(0) + + diffAndUpdateSignals(prev, next, '', registry) + + // After diff, entityMap is populated with next array's entities + expect(meta!.entityMap.size).toBe(2) + expect(meta!.entityMap.get(1)).toBe(next.items[0]) + expect(meta!.entityMap.get(2)).toBe(next.items[1]) + }) + + it('updates entityMap after each diff', () => { + const state1 = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + }) + const state2 = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bobby' }, + { id: 3, name: 'Carol' }, + ], + }) + const state3 = deepFreeze({ + items: [{ id: 1, name: 'Alice' }], + }) + + const registry = setupRegistry(state1, (s) => { + s.items[0].name + s.items[1].name + }) + + // First diff + diffAndUpdateSignals(state1, state2, '', registry) + const meta = registry.getArrayMeta('items')! + expect(meta.entityMap.size).toBe(3) + + // Second diff + diffAndUpdateSignals(state2, state3, '', registry) + expect(meta.entityMap.size).toBe(1) + expect(meta.entityMap.has(2)).toBe(false) + expect(meta.entityMap.has(3)).toBe(false) + }) + }) + + describe('edge cases', () => { + it('handles empty array → entities', () => { + const prev = deepFreeze({ items: [] as any[] }) + const next = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.items.length + }) + + diffAndUpdateSignals(prev, next, '', registry) + + // No crash, @@keys updated + const keysSignal = registry.getOrCreate('items.@@keys', 2) + expect(typeof keysSignal.get()).toBe('number') + }) + + it('handles entities → empty array', () => { + const prev = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + }) + const next = deepFreeze({ items: [] as any[] }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[1].name + }) + + diffAndUpdateSignals(prev, next, '', registry) + + // All entities pruned + expect(registry.has('items.{id:1}.name')).toBe(false) + expect(registry.has('items.{id:2}.name')).toBe(false) + }) + + it('handles duplicate IDs gracefully', () => { + const prev = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 1, name: 'Also Alice' }, // duplicate ID + ], + }) + const next = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 1, name: 'Still Alice' }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[1].name + }) + + // Should not throw — just works with last-wins semantics + expect(() => + diffAndUpdateSignals(prev, next, '', registry), + ).not.toThrow() + }) + + it('handles entity with id=0', () => { + const prev = deepFreeze({ + items: [ + { id: 0, name: 'Zero' }, + { id: 1, name: 'One' }, + ], + }) + const next = deepFreeze({ + items: [ + { id: 0, name: 'Zero Updated' }, + { id: 1, name: 'One' }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[1].name + }) + + diffAndUpdateSignals(prev, next, '', registry) + + expect(registry.getOrCreate('items.{id:0}.name', 'Zero Updated').get()).toBe( + 'Zero Updated', + ) + expect(registry.getOrCreate('items.{id:1}.name', 'One').get()).toBe('One') + }) + + it('handles entity with empty string id', () => { + const prev = deepFreeze({ + items: [{ id: '', name: 'Empty' }], + }) + const next = deepFreeze({ + items: [{ id: '', name: 'Updated' }], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + }) + + diffAndUpdateSignals(prev, next, '', registry) + + expect(registry.getOrCreate('items.{id:}.name', 'Updated').get()).toBe( + 'Updated', + ) + }) + + it('handles mixed keyed and non-keyed arrays in same state', () => { + const prev = deepFreeze({ + users: [{ id: 1, name: 'Alice' }], + tags: ['react', 'redux'], + }) + const next = deepFreeze({ + users: [{ id: 1, name: 'Alicia' }], + tags: ['react', 'signals'], + }) + + const registry = setupRegistry(prev, (s) => { + s.users[0].name + s.tags[0] + s.tags[1] + }) + + diffAndUpdateSignals(prev, next, '', registry) + + // Users: identity-based + expect(registry.getOrCreate('users.{id:1}.name', 'Alicia').get()).toBe( + 'Alicia', + ) + // Tags: index-based (primitive array) + expect(registry.getOrCreate('tags.1', 'signals').get()).toBe('signals') + }) + + it('handles deeply nested entity arrays', () => { + const prev = deepFreeze({ + data: { + nested: { + items: [ + { id: 'a', val: 1 }, + { id: 'b', val: 2 }, + ], + }, + }, + }) + const next = deepFreeze({ + data: { + nested: { + items: [ + { id: 'a', val: 1 }, + { id: 'b', val: 99 }, + ], + }, + }, + }) + + const registry = setupRegistry(prev, (s) => { + s.data.nested.items[0].val + s.data.nested.items[1].val + }) + + diffAndUpdateSignals(prev, next, '', registry) + + expect( + registry + .getOrCreate('data.nested.items.{id:a}.val', 1) + .get(), + ).toBe(1) + expect( + registry + .getOrCreate('data.nested.items.{id:b}.val', 99) + .get(), + ).toBe(99) + }) + + it('handles entities with nested objects', () => { + const prev = deepFreeze({ + items: [ + { id: 1, profile: { name: 'Alice', age: 30 } }, + { id: 2, profile: { name: 'Bob', age: 25 } }, + ], + }) + const next = deepFreeze({ + items: [ + { id: 1, profile: { name: 'Alice', age: 31 } }, // age changed + { id: 2, profile: { name: 'Bob', age: 25 } }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].profile.age + s.items[1].profile.age + }) + + diffAndUpdateSignals(prev, next, '', registry) + + expect( + registry.getOrCreate('items.{id:1}.profile.age', 31).get(), + ).toBe(31) + expect( + registry.getOrCreate('items.{id:2}.profile.age', 25).get(), + ).toBe(25) + }) + }) + + describe('reactive propagation with identity tracking', () => { + it('triggers computed only for changed entity', () => { + const prev = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + }) + const next = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bobby' }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[1].name + }) + + const sig1 = registry.getOrCreate('items.{id:1}.name', 'Alice') + const sig2 = registry.getOrCreate('items.{id:2}.name', 'Bob') + + let computed1Runs = 0 + let computed2Runs = 0 + + const c1 = alienEngine.computed(() => { + computed1Runs++ + return sig1.get() + }) + const c2 = alienEngine.computed(() => { + computed2Runs++ + return sig2.get() + }) + + // Initial read + c1.get() + c2.get() + const initialRuns1 = computed1Runs + const initialRuns2 = computed2Runs + + reconcileState(prev, next, registry, alienEngine) + + c1.get() + c2.get() + + // Only entity 2 changed — only computed2 should re-run + expect(computed1Runs).toBe(initialRuns1) // no re-run + expect(computed2Runs).toBe(initialRuns2 + 1) // re-ran + expect(c2.get()).toBe('Bobby') + }) + + it('does not trigger computed for reorder-only change', () => { + const e1 = deepFreeze({ id: 1, name: 'Alice' }) + const e2 = deepFreeze({ id: 2, name: 'Bob' }) + const prev = deepFreeze({ items: [e1, e2] }) + const next = deepFreeze({ items: [e2, e1] }) // swapped + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[1].name + }) + + const sig1 = registry.getOrCreate('items.{id:1}.name', 'Alice') + const sig2 = registry.getOrCreate('items.{id:2}.name', 'Bob') + + let runs = 0 + const derived = alienEngine.computed(() => { + runs++ + return `${sig1.get()}-${sig2.get()}` + }) + + derived.get() + const initialRuns = runs + + reconcileState(prev, next, registry, alienEngine) + + derived.get() + // No content changed, just reorder with shared refs → zero signal updates → no re-run + expect(runs).toBe(initialRuns) + }) + }) + + describe('multi-dispatch sequences', () => { + it('handles add → update → remove sequence', () => { + const state0 = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + }) + const state1 = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Carol' }, + ], + }) + const state2 = deepFreeze({ + items: [ + { id: 1, name: 'Alicia' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Carol' }, + ], + }) + const state3 = deepFreeze({ + items: [ + { id: 2, name: 'Bob' }, + { id: 3, name: 'Carol' }, + ], + }) + + const registry = setupRegistry(state0, (s) => { + s.items[0].name // tracks {id:1}.name + s.items[1].name // tracks {id:2}.name + }) + + expect(registry.has('items.{id:1}.name')).toBe(true) + expect(registry.has('items.{id:2}.name')).toBe(true) + + // Dispatch 1: add entity 3 + diffAndUpdateSignals(state0, state1, '', registry) + // @@keys updated but entity 3 not tracked (never accessed via proxy) + + // Dispatch 2: update entity 1's name + diffAndUpdateSignals(state1, state2, '', registry) + expect(registry.getOrCreate('items.{id:1}.name', 'Alicia').get()).toBe( + 'Alicia', + ) + + // Dispatch 3: remove entity 1 + diffAndUpdateSignals(state2, state3, '', registry) + expect(registry.has('items.{id:1}.name')).toBe(false) + expect(registry.has('items.{id:2}.name')).toBe(true) + }) + + it('handles repeated reorders', () => { + const e1 = deepFreeze({ id: 1, v: 'a' }) + const e2 = deepFreeze({ id: 2, v: 'b' }) + const e3 = deepFreeze({ id: 3, v: 'c' }) + + const s0 = deepFreeze({ items: [e1, e2, e3] }) + const s1 = deepFreeze({ items: [e3, e1, e2] }) + const s2 = deepFreeze({ items: [e2, e3, e1] }) + const s3 = deepFreeze({ items: [e1, e2, e3] }) // back to original + + const registry = setupRegistry(s0, (s) => { + s.items[0].v + s.items[1].v + s.items[2].v + }) + + const sig1 = registry.getOrCreate('items.{id:1}.v', 'a') + const sig2 = registry.getOrCreate('items.{id:2}.v', 'b') + const sig3 = registry.getOrCreate('items.{id:3}.v', 'c') + + const init1 = sig1.get() + const init2 = sig2.get() + const init3 = sig3.get() + + diffAndUpdateSignals(s0, s1, '', registry) + diffAndUpdateSignals(s1, s2, '', registry) + diffAndUpdateSignals(s2, s3, '', registry) + + // After multiple reorders with shared refs, signals unchanged + expect(sig1.get()).toBe(init1) + expect(sig2.get()).toBe(init2) + expect(sig3.get()).toBe(init3) + }) + }) +}) diff --git a/test/signals/trackingProxy.spec.ts b/test/signals/trackingProxy.spec.ts index 5391f6169..19f95a9bc 100644 --- a/test/signals/trackingProxy.spec.ts +++ b/test/signals/trackingProxy.spec.ts @@ -94,8 +94,9 @@ describe('createTrackingProxy', () => { const todo = proxy.todos[0] expect(todo.text).toBe('Test') expect(registry.hasPrefix('todos')).toBe(true) // intermediate - expect(registry.hasPrefix('todos.0')).toBe(true) // intermediate - expect(registry.has('todos.0.text')).toBe(true) // leaf + // Identity-based path: element has 'id' field, so uses {id:1} instead of 0 + expect(registry.hasPrefix('todos.{id:1}')).toBe(true) // intermediate (identity path) + expect(registry.has('todos.{id:1}.text')).toBe(true) // leaf (identity path) }) it('tracks array length access', () => { @@ -346,12 +347,14 @@ describe('createTrackingProxy', () => { expect(registry.hasPrefix('entities')).toBe(true) // intermediate expect(registry.hasPrefix('entities.users')).toBe(true) // intermediate - expect(registry.hasPrefix('entities.users.0')).toBe(true) // intermediate - expect(registry.hasPrefix('entities.users.0.tags')).toBe(true) // intermediate - expect(registry.has('entities.users.0.tags.0')).toBe(true) // leaf + // Identity-based: users array has objects with 'id', uses {id:1} + expect(registry.hasPrefix('entities.users.{id:1}')).toBe(true) // intermediate (identity path) + expect(registry.hasPrefix('entities.users.{id:1}.tags')).toBe(true) // intermediate + // tags is string array (no id), so uses index-based 0 + expect(registry.has('entities.users.{id:1}.tags.0')).toBe(true) // leaf // Not accessed - expect(registry.has('entities.users.0.id')).toBe(false) - expect(registry.has('entities.users.0.tags.1')).toBe(false) + expect(registry.has('entities.users.{id:1}.id')).toBe(false) + expect(registry.has('entities.users.{id:1}.tags.1')).toBe(false) expect(registry.hasPrefix('count')).toBe(false) }) From c86c2cacac088f93cef3f68af2c142c6947bc63f Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 7 Jun 2026 18:04:52 -0400 Subject: [PATCH 12/95] Improve array append/prune case perf --- src/signals/diff.ts | 58 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/src/signals/diff.ts b/src/signals/diff.ts index 232e869cc..c287f6863 100644 --- a/src/signals/diff.ts +++ b/src/signals/diff.ts @@ -188,13 +188,67 @@ function diffArrayByKey( } } - // Full identity-based diff: lengths differ or keys shifted positions + // Full identity-based diff: lengths differ or keys shifted positions. + // Forward scan: skip shared prefix where prev[i] === next[i]. + // This makes pure append/prepend-from-end nearly free. + const minLen = Math.min(prev.length, next.length) + let startIdx = 0 + while (startIdx < minLen && prev[startIdx] === next[startIdx]) { + startIdx++ + } + + // If we skipped the entire overlap and next is longer, it's a pure append. + // No Map needed — just update entityMap for new tail entries. + if (startIdx === minLen && next.length >= prev.length) { + for (let i = startIdx; i < next.length; i++) { + const nextItem = next[i] + const kv = getKeyValue(nextItem, keyField) + if (kv !== undefined) { + prevEntityMap.set(kv, nextItem) + const identityPath = buildIdentityPath(parentPath, keyField, kv) + if (registry.hasPrefix(identityPath)) { + diffAndUpdateSignals(undefined, nextItem, identityPath, registry) + } + } + } + return + } + + // If we skipped the entire overlap and prev is longer, it's a pure truncation. + // Prune the removed tail entries from entityMap. + if (startIdx === minLen && prev.length > next.length) { + for (let i = startIdx; i < prev.length; i++) { + const prevItem = prev[i] + const kv = getKeyValue(prevItem, keyField) + if (kv !== undefined) { + prevEntityMap.delete(kv) + const identityPath = buildIdentityPath(parentPath, keyField, kv) + registry.prune(identityPath) + } + } + return + } + + // General case: build Map only from startIdx onward const mayHaveRemovals = next.length < prev.length const nextEntityMap = new Map() + // Carry over skipped prefix entries from prevEntityMap + for (let i = 0; i < startIdx; i++) { + const kv = getKeyValue(next[i], keyField) + if (kv !== undefined) nextEntityMap.set(kv, next[i]) + } + const seenPrevKeys = mayHaveRemovals ? new Set() : null + // Mark skipped prefix keys as seen (they can't be removed) + if (seenPrevKeys) { + for (let i = 0; i < startIdx; i++) { + const kv = getKeyValue(prev[i], keyField) + if (kv !== undefined) seenPrevKeys.add(kv) + } + } - for (let i = 0; i < next.length; i++) { + for (let i = startIdx; i < next.length; i++) { const nextItem = next[i] const kv = getKeyValue(nextItem, keyField) From f0e8340f1ae99d0bb49f61c95c0cc939da1b26cd Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 7 Jun 2026 18:47:51 -0400 Subject: [PATCH 13/95] Implement proxy array method overrides --- src/signals/arrayMethodOverrides.ts | 152 +++++ src/signals/trackingProxy.ts | 9 +- test/signals/arrayMethodOverrides.spec.ts | 782 ++++++++++++++++++++++ 3 files changed, 942 insertions(+), 1 deletion(-) create mode 100644 src/signals/arrayMethodOverrides.ts create mode 100644 test/signals/arrayMethodOverrides.spec.ts diff --git a/src/signals/arrayMethodOverrides.ts b/src/signals/arrayMethodOverrides.ts new file mode 100644 index 000000000..819db153d --- /dev/null +++ b/src/signals/arrayMethodOverrides.ts @@ -0,0 +1,152 @@ +import type { PathSignalRegistry } from './pathSignalRegistry' +import type { ProxyCache } from './trackingProxy' + +/** + * Non-mutating array methods that we override on the tracking proxy. + * + * The key optimization: callbacks receive RAW frozen values (no proxy, no signal + * registration). Only the RESULTS get proxied — so signal dependencies are + * established only for the elements the selector actually uses. + * + * Example: `state.items.find(i => i.id === 42)` scans 1000 raw items (fast), + * then returns a proxy for the one match (registers signals for that element only). + * + * Categories: + * - Subset operations (find, findLast, filter, slice): return proxied elements + * - Primitive-returning (findIndex, indexOf, some, every, includes, etc.): return as-is + * - Transform operations (concat, flat): return raw values (new structures, not subsets) + * - Pass-through (map, forEach, reduce, flatMap): NOT overridden — callbacks need tracking + */ + +type SubsetMethod = 'find' | 'findLast' | 'filter' | 'slice' +type PrimitiveMethod = + | 'findIndex' + | 'findLastIndex' + | 'some' + | 'every' + | 'indexOf' + | 'lastIndexOf' + | 'includes' + | 'join' + | 'toString' + | 'toLocaleString' +type TransformMethod = 'concat' | 'flat' + +type OverriddenMethod = SubsetMethod | PrimitiveMethod | TransformMethod + +const FIND_METHODS = new Set(['find', 'findLast']) + +const OVERRIDDEN_METHODS = new Set([ + // Subset — return proxied results + 'find', + 'findLast', + 'filter', + 'slice', + // Primitive-returning + 'findIndex', + 'findLastIndex', + 'some', + 'every', + 'indexOf', + 'lastIndexOf', + 'includes', + 'join', + 'toString', + 'toLocaleString', + // Transform — return raw values + 'concat', + 'flat', +]) + +/** + * Check if a property name is an overridden array method. + */ +export function isOverriddenArrayMethod(prop: string): boolean { + return OVERRIDDEN_METHODS.has(prop) +} + +function normalizeSliceIndex(index: number, length: number): number { + if (index < 0) { + return Math.max(length + index, 0) + } + return Math.min(index, length) +} + +/** + * Create an interceptor for an array method that operates on the raw frozen + * array, bypassing per-element proxy creation. Only results that the caller + * will actually use get wrapped in tracking proxies. + * + * @param target - The raw frozen array + * @param proxy - The tracking proxy wrapping this array (used to return proxied elements) + * @param method - The method name being intercepted + */ +export function createArrayMethodInterceptor( + target: readonly unknown[], + proxy: object, + method: string, +): (...args: unknown[]) => unknown { + return function intercepted(...args: unknown[]): unknown { + const m = method as OverriddenMethod + + // --- Subset operations: scan raw, return proxied results --- + + if (m === 'filter') { + const predicate = args[0] as ( + value: unknown, + index: number, + array: readonly unknown[], + ) => boolean + const result: unknown[] = [] + for (let i = 0; i < target.length; i++) { + if (predicate(target[i], i, target)) { + // Access through proxy to register signals for matching elements + result.push((proxy as Record)[i]) + } + } + return result + } + + if (FIND_METHODS.has(m)) { + const predicate = args[0] as ( + value: unknown, + index: number, + array: readonly unknown[], + ) => boolean + const isForward = m === 'find' + const step = isForward ? 1 : -1 + const start = isForward ? 0 : target.length - 1 + + for (let i = start; i >= 0 && i < target.length; i += step) { + if (predicate(target[i], i, target)) { + // Return proxied element — registers signals for just this one + return (proxy as Record)[i] + } + } + return undefined + } + + if (m === 'slice') { + const rawStart = (args[0] as number) ?? 0 + const rawEnd = (args[1] as number) ?? target.length + const start = normalizeSliceIndex(rawStart, target.length) + const end = normalizeSliceIndex(rawEnd, target.length) + const result: unknown[] = [] + for (let i = start; i < end; i++) { + result.push((proxy as Record)[i]) + } + return result + } + + // --- Primitive and transform operations: call on raw array --- + + // indexOf/lastIndexOf/includes may receive a proxy as the search value. + // We need to compare against raw values, so extract the raw target if needed. + // For these methods, the comparison is by reference (===), and since our + // proxy !== raw value, we delegate to the raw array directly. + // This works correctly for primitive arrays. For object arrays, users + // should use findIndex instead. + + return (target as any)[m](...args) + } +} diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index 1580da669..0c7b6dc9f 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -1,4 +1,8 @@ import { buildIdentityPath, findKeyField, getKeyValue } from './arrayKeys' +import { + isOverriddenArrayMethod, + createArrayMethodInterceptor, +} from './arrayMethodOverrides' import type { PathSignalRegistry } from './pathSignalRegistry' function isObjectOrArray(v: unknown): v is object { @@ -64,8 +68,11 @@ export function createTrackingProxy( const value = (target as Record)[prop] - // Functions (like Array.prototype.map) — bind to proxy so `this` works + // Functions: intercept array methods to avoid per-element proxy creation if (typeof value === 'function') { + if (Array.isArray(target) && isOverriddenArrayMethod(prop as string)) { + return createArrayMethodInterceptor(target, proxy, prop as string) + } return value } diff --git a/test/signals/arrayMethodOverrides.spec.ts b/test/signals/arrayMethodOverrides.spec.ts new file mode 100644 index 000000000..33932a66b --- /dev/null +++ b/test/signals/arrayMethodOverrides.spec.ts @@ -0,0 +1,782 @@ +import { describe, test, expect, vi } from 'vitest' +import { alienEngine } from '@internal/signals/engine' +import { createPathSignalRegistry } from '@internal/signals/pathSignalRegistry' +import { createTrackingProxy, type ProxyCache } from '@internal/signals/trackingProxy' +import { getProxyPath } from '@internal/signals/trackingProxy' + +function deepFreeze(obj: T): T { + if (obj === null || typeof obj !== 'object') return obj + Object.freeze(obj) + for (const val of Object.values(obj as Record)) { + if (val !== null && typeof val === 'object') deepFreeze(val) + } + return obj +} + +function setup(state: object) { + const registry = createPathSignalRegistry(alienEngine) + const cache: ProxyCache = new WeakMap() + const proxy = createTrackingProxy(state, '', registry, cache) + return { registry, cache, proxy } +} + +describe('Array method overrides on tracking proxy', () => { + // Test data factory — mimics Immer test structure + const createTestData = () => + deepFreeze({ + items: [ + { id: 1, value: 10, nested: { count: 1 } }, + { id: 2, value: 20, nested: { count: 2 } }, + { id: 3, value: 30, nested: { count: 3 } }, + { id: 4, value: 40, nested: { count: 4 } }, + { id: 5, value: 50, nested: { count: 5 } }, + ], + other: { data: 'test' }, + }) + + describe('find()', () => { + test('returns found item as proxy', () => { + const state = createTestData() + const { proxy } = setup(state) + const found = proxy.items.find((item) => item.id === 3) + expect(found).toBeDefined() + expect(found!.value).toBe(30) + // Verify it's a proxy (has a path) + expect(getProxyPath(found)).toBeDefined() + }) + + test('returns undefined when not found', () => { + const state = createTestData() + const { proxy } = setup(state) + const found = proxy.items.find((item) => item.id === 999) + expect(found).toBeUndefined() + }) + + test('callback receives raw values, not proxies', () => { + const state = createTestData() + const { proxy } = setup(state) + const receivedValues: unknown[] = [] + proxy.items.find((item) => { + receivedValues.push(item) + return false // never match, scan all + }) + // Callbacks should receive raw frozen objects, not proxies + for (const val of receivedValues) { + expect(getProxyPath(val)).toBeUndefined() + expect(Object.isFrozen(val)).toBe(true) + } + expect(receivedValues).toHaveLength(5) + }) + + test('only registers signals for the found element, not all scanned elements', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + + // find item with id === 3 (index 2) + const found = proxy.items.find((item) => item.id === 3) + // Access a property to register a signal + const _val = found!.value + + // Should have a signal for the found item's value, NOT for all items + // With identity-based tracking, the path would be items.{id:3}.value + expect(registry.size()).toBeGreaterThan(0) + + // The key assertion: we should NOT have signals for items that were + // only scanned (not returned). Check that items 1, 2, 4, 5 don't + // have signals registered. + expect(registry.has('items.{id:1}.value')).toBe(false) + expect(registry.has('items.{id:2}.value')).toBe(false) + expect(registry.has('items.{id:4}.value')).toBe(false) + expect(registry.has('items.{id:5}.value')).toBe(false) + }) + + test('callback receives correct index and array arguments', () => { + const state = createTestData() + const { proxy } = setup(state) + const indices: number[] = [] + let receivedArray: unknown + + proxy.items.find((item, index, arr) => { + indices.push(index) + receivedArray = arr + return item.id === 2 + }) + + expect(indices).toEqual([0, 1]) // stops after finding id:2 + // Array argument should be the raw frozen array + expect(receivedArray).toBe(state.items) + }) + + test('found element properties are accessible through proxy', () => { + const state = createTestData() + const { proxy } = setup(state) + const found = proxy.items.find((item) => item.id === 2)! + expect(found.id).toBe(2) + expect(found.value).toBe(20) + expect(found.nested.count).toBe(2) + }) + + test('works with primitive arrays', () => { + const state = deepFreeze({ nums: [10, 20, 30, 40, 50] }) + const { proxy } = setup(state) + const found = proxy.nums.find((n) => n > 25) + expect(found).toBe(30) + }) + }) + + describe('findLast()', () => { + test('returns last matching item as proxy', () => { + const state = deepFreeze({ + items: [ + { id: 1, type: 'A' }, + { id: 2, type: 'B' }, + { id: 3, type: 'A' }, + ], + }) + const { proxy } = setup(state) + const found = proxy.items.findLast((item) => item.type === 'A') + expect(found).toBeDefined() + expect(found!.id).toBe(3) + expect(getProxyPath(found)).toBeDefined() + }) + + test('returns undefined when not found', () => { + const state = createTestData() + const { proxy } = setup(state) + const found = proxy.items.findLast((item) => item.id === 999) + expect(found).toBeUndefined() + }) + + test('searches from end', () => { + const state = createTestData() + const { proxy } = setup(state) + const indices: number[] = [] + proxy.items.findLast((item, index) => { + indices.push(index) + return item.id === 3 + }) + // Should search backwards: 4, 3, 2 — stops at index 2 + expect(indices).toEqual([4, 3, 2]) + }) + }) + + describe('findIndex()', () => { + test('returns index of found item', () => { + const state = createTestData() + const { proxy } = setup(state) + const index = proxy.items.findIndex((item) => item.id === 3) + expect(index).toBe(2) + }) + + test('returns -1 when not found', () => { + const state = createTestData() + const { proxy } = setup(state) + const index = proxy.items.findIndex((item) => item.id === 999) + expect(index).toBe(-1) + }) + + test('does not register any element signals', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + proxy.items.findIndex((item) => item.id === 3) + // findIndex returns a primitive — no element signals should be created + expect(registry.has('items.{id:1}.value')).toBe(false) + expect(registry.has('items.{id:3}.value')).toBe(false) + }) + + test('callback receives raw values', () => { + const state = createTestData() + const { proxy } = setup(state) + proxy.items.findIndex((item) => { + expect(getProxyPath(item)).toBeUndefined() + expect(Object.isFrozen(item)).toBe(true) + return false + }) + }) + }) + + describe('findLastIndex()', () => { + test('returns last matching index', () => { + const state = deepFreeze({ + items: [ + { id: 1, type: 'A' }, + { id: 2, type: 'B' }, + { id: 3, type: 'A' }, + ], + }) + const { proxy } = setup(state) + const index = proxy.items.findLastIndex((item) => item.type === 'A') + expect(index).toBe(2) + }) + + test('returns -1 when not found', () => { + const state = createTestData() + const { proxy } = setup(state) + const index = proxy.items.findLastIndex((item) => item.id === 999) + expect(index).toBe(-1) + }) + }) + + describe('filter()', () => { + test('returns filtered items as proxies', () => { + const state = createTestData() + const { proxy } = setup(state) + const filtered = proxy.items.filter((item) => item.value > 25) + expect(filtered).toHaveLength(3) + expect(filtered[0].id).toBe(3) + expect(filtered[1].id).toBe(4) + expect(filtered[2].id).toBe(5) + // Each result should be a proxy + for (const item of filtered) { + expect(getProxyPath(item)).toBeDefined() + } + }) + + test('callback receives raw values, not proxies', () => { + const state = createTestData() + const { proxy } = setup(state) + const receivedValues: unknown[] = [] + proxy.items.filter((item) => { + receivedValues.push(item) + return false + }) + for (const val of receivedValues) { + expect(getProxyPath(val)).toBeUndefined() + expect(Object.isFrozen(val)).toBe(true) + } + }) + + test('only registers signals for matching elements', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + const filtered = proxy.items.filter((item) => item.id === 3) + // Access a property on the result + const _val = filtered[0].value + + // Should have signal for the matching item, not others + expect(registry.has('items.{id:3}.value')).toBe(true) + expect(registry.has('items.{id:1}.value')).toBe(false) + expect(registry.has('items.{id:2}.value')).toBe(false) + }) + + test('filtered items give access to nested properties', () => { + const state = createTestData() + const { proxy } = setup(state) + const filtered = proxy.items.filter((item) => item.id > 3) + expect(filtered[0].nested.count).toBe(4) + expect(filtered[1].nested.count).toBe(5) + }) + + test('multiple filters on same array work correctly', () => { + const state = createTestData() + const { proxy } = setup(state) + const high = proxy.items.filter((item) => item.value > 30) + const low = proxy.items.filter((item) => item.value < 30) + expect(high).toHaveLength(2) + expect(low).toHaveLength(2) + }) + + test('returns empty array when nothing matches', () => { + const state = createTestData() + const { proxy } = setup(state) + const filtered = proxy.items.filter((item) => item.id > 100) + expect(filtered).toHaveLength(0) + }) + + test('callback receives correct index and array arguments', () => { + const state = createTestData() + const { proxy } = setup(state) + const seen: number[] = [] + proxy.items.filter((_item, index, arr) => { + seen.push(index) + expect(arr).toBe(state.items) + return false + }) + expect(seen).toEqual([0, 1, 2, 3, 4]) + }) + }) + + describe('slice()', () => { + test('returns sliced items as proxies', () => { + const state = createTestData() + const { proxy } = setup(state) + const sliced = proxy.items.slice(1, 3) + expect(sliced).toHaveLength(2) + expect(sliced[0].id).toBe(2) + expect(sliced[1].id).toBe(3) + for (const item of sliced) { + expect(getProxyPath(item)).toBeDefined() + } + }) + + test('slice with no arguments returns all as proxies', () => { + const state = createTestData() + const { proxy } = setup(state) + const sliced = proxy.items.slice() + expect(sliced).toHaveLength(5) + for (const item of sliced) { + expect(getProxyPath(item)).toBeDefined() + } + }) + + test('slice with negative indices', () => { + const state = createTestData() + const { proxy } = setup(state) + const sliced = proxy.items.slice(-2) + expect(sliced).toHaveLength(2) + expect(sliced[0].id).toBe(4) + expect(sliced[1].id).toBe(5) + }) + + test('slice with negative start and end', () => { + const state = createTestData() + const { proxy } = setup(state) + const sliced = proxy.items.slice(-3, -1) + expect(sliced).toHaveLength(2) + expect(sliced[0].id).toBe(3) + expect(sliced[1].id).toBe(4) + }) + + test('out-of-bounds indices are clamped', () => { + const state = createTestData() + const { proxy } = setup(state) + const sliced = proxy.items.slice(-100, 100) + expect(sliced).toHaveLength(5) + }) + + test('only registers signals for sliced elements', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + const sliced = proxy.items.slice(2, 3) // just item at index 2 (id: 3) + const _val = sliced[0].value + + expect(registry.has('items.{id:3}.value')).toBe(true) + expect(registry.has('items.{id:1}.value')).toBe(false) + }) + }) + + describe('some()', () => { + test('returns true when predicate matches', () => { + const state = createTestData() + const { proxy } = setup(state) + expect(proxy.items.some((item) => item.value > 40)).toBe(true) + }) + + test('returns false when no match', () => { + const state = createTestData() + const { proxy } = setup(state) + expect(proxy.items.some((item) => item.value > 100)).toBe(false) + }) + + test('short-circuits on first match', () => { + const state = createTestData() + const { proxy } = setup(state) + let callCount = 0 + proxy.items.some((item) => { + callCount++ + return item.id === 2 + }) + expect(callCount).toBe(2) // stops after finding id:2 at index 1 + }) + + test('callback receives raw values', () => { + const state = createTestData() + const { proxy } = setup(state) + proxy.items.some((item) => { + expect(getProxyPath(item)).toBeUndefined() + return false + }) + }) + + test('does not register element signals', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + proxy.items.some((item) => item.id === 3) + expect(registry.has('items.{id:1}.value')).toBe(false) + expect(registry.has('items.{id:3}.value')).toBe(false) + }) + }) + + describe('every()', () => { + test('returns true when all match', () => { + const state = createTestData() + const { proxy } = setup(state) + expect(proxy.items.every((item) => item.value > 0)).toBe(true) + }) + + test('returns false when some do not match', () => { + const state = createTestData() + const { proxy } = setup(state) + expect(proxy.items.every((item) => item.value > 25)).toBe(false) + }) + + test('short-circuits on first failure', () => { + const state = createTestData() + const { proxy } = setup(state) + let callCount = 0 + proxy.items.every((item) => { + callCount++ + return item.value < 25 + }) + // items: 10, 20, 30 — fails at index 2 (value 30) + expect(callCount).toBe(3) + }) + }) + + describe('includes()', () => { + test('returns true for primitive values', () => { + const state = deepFreeze({ nums: [10, 20, 30, 40, 50] }) + const { proxy } = setup(state) + expect(proxy.nums.includes(30)).toBe(true) + }) + + test('returns false for missing primitive', () => { + const state = deepFreeze({ nums: [10, 20, 30, 40, 50] }) + const { proxy } = setup(state) + expect(proxy.nums.includes(99)).toBe(false) + }) + + test('works with fromIndex', () => { + const state = deepFreeze({ nums: [10, 20, 30, 20, 50] }) + const { proxy } = setup(state) + expect(proxy.nums.includes(20, 2)).toBe(true) // finds the 20 at index 3 + expect(proxy.nums.includes(10, 1)).toBe(false) // 10 is before index 1 + }) + }) + + describe('indexOf()', () => { + test('returns index of primitive value', () => { + const state = deepFreeze({ nums: [10, 20, 30, 40, 50] }) + const { proxy } = setup(state) + expect(proxy.nums.indexOf(30)).toBe(2) + }) + + test('returns -1 when not found', () => { + const state = deepFreeze({ nums: [10, 20, 30] }) + const { proxy } = setup(state) + expect(proxy.nums.indexOf(99)).toBe(-1) + }) + + test('with fromIndex parameter', () => { + const state = deepFreeze({ nums: [1, 2, 3, 2, 5] }) + const { proxy } = setup(state) + expect(proxy.nums.indexOf(2)).toBe(1) + expect(proxy.nums.indexOf(2, 2)).toBe(3) + }) + }) + + describe('lastIndexOf()', () => { + test('returns last index of primitive value', () => { + const state = deepFreeze({ nums: [10, 20, 30, 20, 50] }) + const { proxy } = setup(state) + expect(proxy.nums.lastIndexOf(20)).toBe(3) + }) + + test('returns -1 when not found', () => { + const state = deepFreeze({ nums: [10, 20, 30] }) + const { proxy } = setup(state) + expect(proxy.nums.lastIndexOf(99)).toBe(-1) + }) + }) + + describe('join()', () => { + test('returns joined string with separator', () => { + const state = deepFreeze({ items: [1, 2, 3, 4, 5] }) + const { proxy } = setup(state) + expect(proxy.items.join(',')).toBe('1,2,3,4,5') + }) + + test('join with custom separator', () => { + const state = deepFreeze({ items: ['a', 'b', 'c'] }) + const { proxy } = setup(state) + expect(proxy.items.join(' - ')).toBe('a - b - c') + }) + + test('join with no separator uses comma', () => { + const state = deepFreeze({ items: [1, 2, 3] }) + const { proxy } = setup(state) + expect(proxy.items.join()).toBe('1,2,3') + }) + }) + + describe('toString()', () => { + test('returns string representation', () => { + const state = deepFreeze({ items: [1, 2, 3] }) + const { proxy } = setup(state) + expect(proxy.items.toString()).toBe('1,2,3') + }) + }) + + describe('toLocaleString()', () => { + test('returns locale string', () => { + const state = deepFreeze({ items: [1, 2, 3] }) + const { proxy } = setup(state) + // Just verify it returns a string without error + expect(typeof proxy.items.toLocaleString()).toBe('string') + }) + }) + + describe('concat()', () => { + test('returns new array with concatenated items (raw values)', () => { + const state = deepFreeze({ items: [1, 2, 3] }) + const { proxy } = setup(state) + const result = proxy.items.concat([4, 5]) + expect(result).toEqual([1, 2, 3, 4, 5]) + }) + + test('concat with no arguments creates shallow copy', () => { + const state = createTestData() + const { proxy } = setup(state) + const copy = proxy.items.concat() + expect(copy).toHaveLength(5) + expect(copy[0].id).toBe(1) + // Result elements are raw values, not proxies + expect(getProxyPath(copy[0])).toBeUndefined() + }) + + test('concat with multiple arrays', () => { + const state = deepFreeze({ items: [1, 2] }) + const { proxy } = setup(state) + const result = proxy.items.concat([3, 4], [5, 6]) + expect(result).toEqual([1, 2, 3, 4, 5, 6]) + }) + }) + + describe('flat()', () => { + test('flattens nested arrays', () => { + const state = deepFreeze({ items: [[1, 2], [3, 4], [5]] }) + const { proxy } = setup(state) + const result = proxy.items.flat() + expect(result).toEqual([1, 2, 3, 4, 5]) + }) + + test('flat with depth', () => { + const state = deepFreeze({ items: [[[1, 2]], [[3, 4]]] }) + const { proxy } = setup(state) + expect(proxy.items.flat(1)).toEqual([[1, 2], [3, 4]]) + expect(proxy.items.flat(2)).toEqual([1, 2, 3, 4]) + }) + }) + + describe('Non-overridden methods (pass-through)', () => { + test('map() callbacks receive proxied values', () => { + const state = createTestData() + const { proxy } = setup(state) + const mapped = proxy.items.map((item) => { + // map is NOT overridden — callback should receive proxy + return item.value * 2 + }) + expect(mapped).toEqual([20, 40, 60, 80, 100]) + }) + + test('forEach() callbacks receive proxied values', () => { + const state = createTestData() + const { proxy } = setup(state) + const values: number[] = [] + proxy.items.forEach((item) => { + values.push(item.value) + }) + expect(values).toEqual([10, 20, 30, 40, 50]) + }) + + test('reduce() callbacks receive proxied values', () => { + const state = createTestData() + const { proxy } = setup(state) + const sum = proxy.items.reduce((acc, item) => acc + item.value, 0) + expect(sum).toBe(150) + }) + + test('reduceRight() callbacks receive proxied values', () => { + const state = createTestData() + const { proxy } = setup(state) + const ids = proxy.items.reduceRight((acc: number[], item) => { + acc.push(item.id) + return acc + }, []) + expect(ids).toEqual([5, 4, 3, 2, 1]) + }) + }) + + describe('Signal tracking precision', () => { + test('find() registers signals only for the result element', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + + // Simulate what a selector does: find + read properties + const item = proxy.items.find((i) => i.id === 3)! + const _label = item.value + const _nested = item.nested.count + + // The found item (id:3) should have signals + expect(registry.has('items.{id:3}.value')).toBe(true) + expect(registry.has('items.{id:3}.nested.count')).toBe(true) + + // Other items should NOT have signals + expect(registry.has('items.{id:1}.value')).toBe(false) + expect(registry.has('items.{id:2}.nested.count')).toBe(false) + expect(registry.has('items.{id:4}.value')).toBe(false) + expect(registry.has('items.{id:5}.value')).toBe(false) + }) + + test('filter() registers signals only for matching elements', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + + const highValue = proxy.items.filter((i) => i.value > 30) + // Read properties to register + for (const item of highValue) { + const _v = item.value + } + + // Items 4 and 5 (value > 30) should have signals + expect(registry.has('items.{id:4}.value')).toBe(true) + expect(registry.has('items.{id:5}.value')).toBe(true) + + // Items 1, 2, 3 should NOT + expect(registry.has('items.{id:1}.value')).toBe(false) + expect(registry.has('items.{id:2}.value')).toBe(false) + expect(registry.has('items.{id:3}.value')).toBe(false) + }) + + test('primitive-returning methods register no element signals', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + + // Call several primitive-returning methods + proxy.items.some((i) => i.id === 3) + proxy.items.every((i) => i.value > 0) + proxy.items.findIndex((i) => i.id === 4) + + // No element-level signals should exist + for (let id = 1; id <= 5; id++) { + expect(registry.has(`items.{id:${id}}.value`)).toBe(false) + expect(registry.has(`items.{id:${id}}.id`)).toBe(false) + } + }) + + test('selectById pattern: find + read is efficient', () => { + // This is the exact entity-list-array benchmark pattern + const items = Array.from({ length: 100 }, (_, i) => + deepFreeze({ + id: i, + value: i * 10, + label: `Item ${i}`, + category: 'A', + updatedAt: Date.now(), + }), + ) + const state = deepFreeze(items) + const registry = createPathSignalRegistry(alienEngine) + const cache: ProxyCache = new WeakMap() + const proxy = createTrackingProxy(state, 'items', registry, cache) + + // selectById pattern + const found = proxy.find((i) => i.id === 42)! + const _value = found.value + const _label = found.label + + // Only 1 entity's signals should be registered + expect(registry.has('items.{id:42}.value')).toBe(true) + expect(registry.has('items.{id:42}.label')).toBe(true) + + // Spot-check other items are NOT registered + expect(registry.has('items.{id:0}.value')).toBe(false) + expect(registry.has('items.{id:41}.value')).toBe(false) + expect(registry.has('items.{id:43}.value')).toBe(false) + expect(registry.has('items.{id:99}.value')).toBe(false) + }) + }) + + describe('Edge cases', () => { + test('empty array', () => { + const state = deepFreeze({ items: [] as { id: number }[] }) + const { proxy } = setup(state) + expect(proxy.items.find(() => true)).toBeUndefined() + expect(proxy.items.filter(() => true)).toEqual([]) + expect(proxy.items.some(() => true)).toBe(false) + expect(proxy.items.every(() => true)).toBe(true) + expect(proxy.items.indexOf(1)).toBe(-1) + expect(proxy.items.join(',')).toBe('') + }) + + test('single-element array', () => { + const state = deepFreeze({ items: [{ id: 1, value: 10 }] }) + const { proxy } = setup(state) + const found = proxy.items.find((i) => i.id === 1) + expect(found).toBeDefined() + expect(found!.value).toBe(10) + }) + + test('array of primitives (no identity detection)', () => { + const state = deepFreeze({ nums: [5, 10, 15, 20, 25] }) + const { proxy } = setup(state) + expect(proxy.nums.find((n) => n > 12)).toBe(15) + expect(proxy.nums.filter((n) => n > 12)).toEqual([15, 20, 25]) + expect(proxy.nums.some((n) => n > 20)).toBe(true) + expect(proxy.nums.indexOf(15)).toBe(2) + }) + + test('nested array access', () => { + const state = deepFreeze({ + groups: [ + { id: 1, items: [{ id: 10, name: 'a' }, { id: 11, name: 'b' }] }, + { id: 2, items: [{ id: 20, name: 'c' }] }, + ], + }) + const { proxy } = setup(state) + const group = proxy.groups.find((g) => g.id === 1)! + const item = group.items.find((i) => i.id === 11)! + expect(item.name).toBe('b') + }) + + test('chained array methods', () => { + const state = createTestData() + const { proxy } = setup(state) + // filter then find — both should work with overrides + const highValue = proxy.items.filter((i) => i.value > 20) + // highValue is a plain array of proxied items (not a tracking proxy itself) + // so .find on it will use normal Array.prototype.find + const found = highValue.find((i) => i.id === 4) + expect(found).toBeDefined() + expect(found!.value).toBe(40) + }) + + test('array.length is not affected by overrides', () => { + const state = createTestData() + const { proxy } = setup(state) + expect(proxy.items.length).toBe(5) + }) + + test('for...of iteration still works (not overridden)', () => { + const state = createTestData() + const { proxy } = setup(state) + const ids: number[] = [] + for (const item of proxy.items) { + ids.push(item.id) + } + expect(ids).toEqual([1, 2, 3, 4, 5]) + }) + + test('spread still works', () => { + const state = createTestData() + const { proxy } = setup(state) + const copy = [...proxy.items] + expect(copy).toHaveLength(5) + expect(copy[0].id).toBe(1) + }) + + test('Array.isArray still works', () => { + const state = createTestData() + const { proxy } = setup(state) + expect(Array.isArray(proxy.items)).toBe(true) + }) + + test('array destructuring works', () => { + const state = createTestData() + const { proxy } = setup(state) + const [first, second] = proxy.items + expect(first.id).toBe(1) + expect(second.id).toBe(2) + }) + }) +}) From f92ea9f5da9e3fdeb11cf1f05476c07b7ec34a69 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 7 Jun 2026 18:55:54 -0400 Subject: [PATCH 14/95] Split object and array paths for v8 optimization --- src/signals/diff.ts | 188 ++++++++++++++++++++++++-------------------- 1 file changed, 101 insertions(+), 87 deletions(-) diff --git a/src/signals/diff.ts b/src/signals/diff.ts index c287f6863..4bcf00a41 100644 --- a/src/signals/diff.ts +++ b/src/signals/diff.ts @@ -13,115 +13,129 @@ function isPlainObject(v: unknown): v is Record { } /** - * Walk prev and next state trees, updating path signals for changed values. - * Only visits subtrees that have tracked signals (registered paths). - * Exploits Immer's structural sharing: `prev === next` skips entire subtrees. + * Diff a plain object: recurse into changed properties. + * Separated from the main dispatcher for V8 monomorphic optimization. */ -export function diffAndUpdateSignals( - prev: unknown, - next: unknown, +function diffObject( + prev: Record, + next: Record, parentPath: string, registry: PathSignalRegistry, ): void { - // Structural sharing: identical references mean nothing changed - if (prev === next) return + const nextKeys = Object.keys(next) + const prevKeys = Object.keys(prev) + let keysChanged = prevKeys.length !== nextKeys.length - // Both plain objects: recurse into properties - if (isPlainObject(prev) && isPlainObject(next)) { - const nextKeys = Object.keys(next) + if (parentPath) { + registry.update(parentPath, next) + } - // Detect key additions/removals by comparing length + checking removals - // (cheaper than building a Set union of both key arrays) - const prevKeyCount = Object.keys(prev).length - let keysChanged = prevKeyCount !== nextKeys.length + for (let i = 0; i < nextKeys.length; i++) { + const key = nextKeys[i] - // Update the object signal itself (version bump) - if (parentPath) { - registry.update(parentPath, next) + if (!keysChanged && !(key in prev)) { + keysChanged = true } - // Iterate next's keys — covers additions + same keys. - // Removed keys: prev[key] was defined, next[key] is undefined, - // so prev[key] !== next[key] and the recursive call handles pruning. - for (let i = 0; i < nextKeys.length; i++) { - const key = nextKeys[i] - const childPath = parentPath ? parentPath + '.' + key : key + // Hoist reference equality check before path construction. + // Structural sharing means most keys are unchanged — skip early. + if (prev[key] === next[key]) continue - // Check if this key was added (not in prev) — also detects keysChanged - if (!keysChanged && !(key in prev)) { - keysChanged = true - } - - if (registry.hasPrefix(childPath)) { - diffAndUpdateSignals(prev[key], next[key], childPath, registry) - } + const childPath = parentPath ? parentPath + '.' + key : key + if (registry.hasPrefix(childPath)) { + diffAndUpdateSignals(prev[key], next[key], childPath, registry) } + } - // Handle removed keys: iterate prev keys that aren't in next - // Only needed if keys actually changed and there are tracked signals - if (keysChanged) { - const keysPath = parentPath ? parentPath + '.@@keys' : '@@keys' - registry.update(keysPath, nextKeys) - - // Prune removed keys and their descendants - const prevKeys = Object.keys(prev) - for (let i = 0; i < prevKeys.length; i++) { - if (!(prevKeys[i] in next)) { - const childPath = parentPath - ? parentPath + '.' + prevKeys[i] - : prevKeys[i] - registry.prune(childPath) - } + if (keysChanged) { + const keysPath = parentPath ? parentPath + '.@@keys' : '@@keys' + registry.update(keysPath, nextKeys) + + for (let i = 0; i < prevKeys.length; i++) { + if (!(prevKeys[i] in next)) { + const childPath = parentPath + ? parentPath + '.' + prevKeys[i] + : prevKeys[i] + registry.prune(childPath) } } - return } +} - // Both arrays: handle length + identity-based or index-based diffing - if (Array.isArray(prev) && Array.isArray(next)) { - if (parentPath) { - registry.update(parentPath, next) - } +/** + * Diff an array: detect key field and dispatch to identity or index based. + * Separated from the main dispatcher for V8 monomorphic optimization. + */ +function diffArray( + prev: unknown[], + next: unknown[], + parentPath: string, + registry: PathSignalRegistry, +): void { + if (parentPath) { + registry.update(parentPath, next) + } - // Length change → @@keys signal + length signal - if (prev.length !== next.length) { - const keysPath = parentPath ? parentPath + '.@@keys' : '@@keys' - registry.update(keysPath, next.length) - const lengthPath = parentPath ? parentPath + '.length' : 'length' - registry.update(lengthPath, next.length) - } + if (prev.length !== next.length) { + const keysPath = parentPath ? parentPath + '.@@keys' : '@@keys' + registry.update(keysPath, next.length) + const lengthPath = parentPath ? parentPath + '.length' : 'length' + registry.update(lengthPath, next.length) + } - // Try identity-based diffing: detect key field on first element - let meta = registry.getArrayMeta(parentPath) - if (meta) { - // Meta may exist (created by proxy) but entityMap is empty on first diff. - // Populate it from prev array so we can match entities. - if (meta.entityMap.size === 0 && prev.length > 0) { - for (let i = 0; i < prev.length; i++) { - const kv = getKeyValue(prev[i], meta.keyField) - if (kv !== undefined) meta.entityMap.set(kv, prev[i]) - } + let meta = registry.getArrayMeta(parentPath) + if (meta) { + if (meta.entityMap.size === 0 && prev.length > 0) { + for (let i = 0; i < prev.length; i++) { + const kv = getKeyValue(prev[i], meta.keyField) + if (kv !== undefined) meta.entityMap.set(kv, prev[i]) } - } else if (next.length > 0) { - const keyField = findKeyField(next[0]) - if (keyField) { - const entityMap = new Map() - for (let i = 0; i < prev.length; i++) { - const kv = getKeyValue(prev[i], keyField) - if (kv !== undefined) entityMap.set(kv, prev[i]) - } - meta = { keyField, entityMap } - registry.setArrayMeta(parentPath, meta) + } + } else if (next.length > 0) { + const keyField = findKeyField(next[0]) + if (keyField) { + const entityMap = new Map() + for (let i = 0; i < prev.length; i++) { + const kv = getKeyValue(prev[i], keyField) + if (kv !== undefined) entityMap.set(kv, prev[i]) } + meta = { keyField, entityMap } + registry.setArrayMeta(parentPath, meta) } + } - if (meta) { - // Identity-based diffing: match elements by key, not by index - diffArrayByKey(prev, next, parentPath, registry, meta) - } else { - // Fallback: index-based diffing for primitive arrays or no key field - diffArrayByIndex(prev, next, parentPath, registry) - } + if (meta) { + diffArrayByKey(prev, next, parentPath, registry, meta) + } else { + diffArrayByIndex(prev, next, parentPath, registry) + } +} + +/** + * Walk prev and next state trees, updating path signals for changed values. + * Only visits subtrees that have tracked signals (registered paths). + * Exploits Immer's structural sharing: `prev === next` skips entire subtrees. + * + * Dispatches to monomorphic helpers (diffObject/diffArray) for V8 optimization. + */ +export function diffAndUpdateSignals( + prev: unknown, + next: unknown, + parentPath: string, + registry: PathSignalRegistry, +): void { + // Structural sharing: identical references mean nothing changed + if (prev === next) return + + // Both plain objects: recurse into properties + if (isPlainObject(prev) && isPlainObject(next)) { + diffObject(prev, next, parentPath, registry) + return + } + + // Both arrays + if (Array.isArray(prev) && Array.isArray(next)) { + diffArray(prev, next, parentPath, registry) return } From bde96b4eff4d850ee4c1b1342498958298698c62 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Mon, 8 Jun 2026 23:18:05 -0400 Subject: [PATCH 15/95] Export SignalProvider and useSignalSelector --- src/exports.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/exports.ts b/src/exports.ts index e3a027b08..e5ed97bc7 100644 --- a/src/exports.ts +++ b/src/exports.ts @@ -9,6 +9,7 @@ import shallowEqual from './utils/shallowEqual' import Provider from './components/Provider' import { defaultNoopBatch } from './utils/batch' +import { SignalProvider, useSignalSelector } from './signals' export { ReactReduxContext } from './components/Context' export type { ReactReduxContextValue } from './components/Context' @@ -48,4 +49,12 @@ export * from './types' */ const batch = defaultNoopBatch -export { Provider, batch, connect, legacy_connect, shallowEqual } +export { + Provider, + batch, + connect, + legacy_connect, + shallowEqual, + SignalProvider, + useSignalSelector, +} From 8e04f1b9e4047752887a83b4a2546c68cf08f4cc Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 9 Jun 2026 00:24:52 -0400 Subject: [PATCH 16/95] Fix lint issues --- src/signals/arrayKeys.ts | 9 ++++++ src/signals/arrayMethodOverrides.ts | 3 ++ src/signals/diff.ts | 34 ++++++++++++++++++++- src/signals/trackingProxy.ts | 11 ++++++- src/signals/useSignalSelector.ts | 11 +++++-- test/benchmarks/reconcile/registryHelper.ts | 19 ++++++++++++ test/signals/diff.spec.ts | 6 ++-- 7 files changed, 86 insertions(+), 7 deletions(-) diff --git a/src/signals/arrayKeys.ts b/src/signals/arrayKeys.ts index a0daf2b52..08b5de5c5 100644 --- a/src/signals/arrayKeys.ts +++ b/src/signals/arrayKeys.ts @@ -14,6 +14,8 @@ const KEY_CANDIDATES = ['id', 'key', '_id', '__id'] as const * Detect an identity key field on an object. * Returns the property name if found, undefined otherwise. * Checks 'id' > 'key' > '_id' > '__id' — same priority order as Legend State. + * @param obj - The object to inspect for a key field + * @returns The key field name, or undefined if none found */ export function findKeyField(obj: unknown): string | undefined { if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return undefined @@ -47,6 +49,10 @@ export interface ArrayMeta { /** * Build the identity path segment for an array element. * e.g., arrayPath="items", keyField="id", element={id: 42} → "items.{id:42}" + * @param arrayPath - Parent array's path + * @param keyField - The identity key field name + * @param keyValue - The identity key value + * @returns The identity-based path string */ export function buildIdentityPath( arrayPath: string, @@ -59,6 +65,9 @@ export function buildIdentityPath( /** * Extract a key value from an array element. * Returns undefined if element is not an object or key field is missing. + * @param element - The array element to extract the key from + * @param keyField - The identity key field name + * @returns The key value, or undefined if not extractable */ export function getKeyValue( element: unknown, diff --git a/src/signals/arrayMethodOverrides.ts b/src/signals/arrayMethodOverrides.ts index 819db153d..ad87139f6 100644 --- a/src/signals/arrayMethodOverrides.ts +++ b/src/signals/arrayMethodOverrides.ts @@ -60,6 +60,8 @@ const OVERRIDDEN_METHODS = new Set([ /** * Check if a property name is an overridden array method. + * @param prop - The property name to check + * @returns Whether the property is an overridden array method */ export function isOverriddenArrayMethod(prop: string): boolean { return OVERRIDDEN_METHODS.has(prop) @@ -80,6 +82,7 @@ function normalizeSliceIndex(index: number, length: number): number { * @param target - The raw frozen array * @param proxy - The tracking proxy wrapping this array (used to return proxied elements) * @param method - The method name being intercepted + * @returns An interceptor function that delegates to the raw array */ export function createArrayMethodInterceptor( target: readonly unknown[], diff --git a/src/signals/diff.ts b/src/signals/diff.ts index 4bcf00a41..54f7f0fa0 100644 --- a/src/signals/diff.ts +++ b/src/signals/diff.ts @@ -3,6 +3,7 @@ import { findKeyField, getKeyValue, } from './arrayKeys' +import type { ArrayMeta } from './arrayKeys' import type { PathSignalRegistry } from './pathSignalRegistry' import type { SignalEngine } from './types' @@ -15,6 +16,11 @@ function isPlainObject(v: unknown): v is Record { /** * Diff a plain object: recurse into changed properties. * Separated from the main dispatcher for V8 monomorphic optimization. + * @param prev - Previous object state + * @param next - Next object state + * @param parentPath - Dot-separated path to this object in the state tree + * @param registry - Signal registry to update + * @returns void */ function diffObject( prev: Record, @@ -65,6 +71,11 @@ function diffObject( /** * Diff an array: detect key field and dispatch to identity or index based. * Separated from the main dispatcher for V8 monomorphic optimization. + * @param prev - Previous array state + * @param next - Next array state + * @param parentPath - Dot-separated path to this array in the state tree + * @param registry - Signal registry to update + * @returns void */ function diffArray( prev: unknown[], @@ -117,6 +128,11 @@ function diffArray( * Exploits Immer's structural sharing: `prev === next` skips entire subtrees. * * Dispatches to monomorphic helpers (diffObject/diffArray) for V8 optimization. + * @param prev - Previous state value + * @param next - Next state value + * @param parentPath - Dot-separated path to this node in the state tree + * @param registry - Signal registry to update + * @returns void */ export function diffAndUpdateSignals( prev: unknown, @@ -159,13 +175,19 @@ export function diffAndUpdateSignals( * When an entity moves indices but content is unchanged (prev === next via * structural sharing), the identity-based signal path stays the same and * no signal updates are needed. + * @param prev - Previous array state + * @param next - Next array state + * @param parentPath - Dot-separated path to this array in the state tree + * @param registry - Signal registry to update + * @param meta - Array identity metadata (key field and entity map) + * @returns void */ function diffArrayByKey( prev: unknown[], next: unknown[], parentPath: string, registry: PathSignalRegistry, - meta: import('./arrayKeys').ArrayMeta, + meta: ArrayMeta, ): void { const { keyField, entityMap: prevEntityMap } = meta @@ -315,6 +337,11 @@ function diffArrayByKey( /** * Index-based array diffing (original algorithm). * Used for primitive arrays or arrays without a detectable key field. + * @param prev - Previous array state + * @param next - Next array state + * @param parentPath - Dot-separated path to this array in the state tree + * @param registry - Signal registry to update + * @returns void */ function diffArrayByIndex( prev: unknown[], @@ -345,6 +372,11 @@ function diffArrayByIndex( /** * Wrapper that batches all signal updates into a single propagation pass. + * @param prev - Previous state + * @param next - Next state + * @param registry - Signal registry to update + * @param engine - Signal engine for batching + * @returns void */ export function reconcileState( prev: unknown, diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index 0c7b6dc9f..bf56846c4 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -24,7 +24,11 @@ const proxyPathMap = new WeakMap() */ export type ProxyCache = WeakMap -/** Get the path key associated with a tracking proxy, or undefined if not a proxy. */ +/** + * Get the path key associated with a tracking proxy, or undefined if not a proxy. + * @param value - The value to check + * @returns The path string, or undefined if not a tracking proxy + */ export function getProxyPath(value: unknown): string | undefined { if (value !== null && typeof value === 'object') { return proxyPathMap.get(value) @@ -45,6 +49,11 @@ export function getProxyPath(value: unknown): string | undefined { * The proxy reads actual values from the real frozen state object. * * Child proxies are cached within a single evaluation to avoid duplicates. + * @param target - The frozen state object to wrap + * @param parentPath - Dot-separated path to this object in the state tree + * @param registry - Signal registry for dependency tracking + * @param cache - Proxy cache for deduplication + * @returns A tracking proxy wrapping the target */ export function createTrackingProxy( target: T, diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index e88409cbf..4071eb6ce 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -11,6 +11,9 @@ const { useRef, useMemo, useEffect, useSyncExternalStore } = React * changes, with O(k) selectivity where k = number of affected selectors. * * Must be used within a . + * @param selector - Function that extracts a value from the store state + * @param equalityFn - Custom equality function for change detection + * @returns The selected value */ export function useSignalSelector( selector: (state: S) => R, @@ -42,7 +45,12 @@ export function useSignalSelector( // we explicitly read that object's signal to establish the terminal dependency. const selectorComputed = engine.computed(() => { const state = store.getState() as S & object - const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) const result = selectorRef.current(proxy as S) // If the selector returned a tracking proxy (object), explicitly @@ -97,7 +105,6 @@ export function useSignalSelector( return currentResult }, } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [store, registry, engine]) // Cleanup scope on unmount diff --git a/test/benchmarks/reconcile/registryHelper.ts b/test/benchmarks/reconcile/registryHelper.ts index bad976915..b953c1994 100644 --- a/test/benchmarks/reconcile/registryHelper.ts +++ b/test/benchmarks/reconcile/registryHelper.ts @@ -10,6 +10,10 @@ import { findKeyField, buildIdentityPath, getKeyValue } from '../../../src/signa /** * Walk an object and register all leaf paths as tracked signals. * Intermediate objects get ensurePrefix (like the tracking proxy does). + * @param state - The state value to walk + * @param parentPath - Dot-separated path prefix + * @param registry - Signal registry to populate + * @returns void */ export function registerAllPaths( state: unknown, @@ -40,6 +44,10 @@ export function registerAllPaths( /** * Register specific paths as tracked. * Each path gets a signal created with the value from the state. + * @param state - The state to extract values from + * @param paths - Array of dot-separated paths to register + * @param registry - Signal registry to populate + * @returns void */ export function registerPaths( state: unknown, @@ -61,6 +69,10 @@ export function registerPaths( * Register paths for a typical "entity list" selector pattern: * - all leaf fields of every entity in an array * - the array's @@keys signal + * @param state - The root state object + * @param arrayPath - Dot-separated path to the entity array + * @param registry - Signal registry to populate + * @returns void */ export function registerEntityArrayPaths( state: Record, @@ -110,6 +122,9 @@ export function registerEntityArrayPaths( /** * Register paths for a wide-flat object: all leaf values of each top-level key's child object. + * @param state - The root state object + * @param registry - Signal registry to populate + * @returns void */ export function registerWideFlatPaths( state: Record, @@ -136,6 +151,10 @@ export function registerWideFlatPaths( /** * Register all paths in a deep-nested state tree. + * @param state - The root state object + * @param parentPath - Dot-separated path prefix + * @param registry - Signal registry to populate + * @returns void */ export function registerDeepNestedPaths( state: Record, diff --git a/test/signals/diff.spec.ts b/test/signals/diff.spec.ts index 88cb29f0a..2a8316103 100644 --- a/test/signals/diff.spec.ts +++ b/test/signals/diff.spec.ts @@ -405,7 +405,7 @@ describe('diffAndUpdateSignals', () => { const next = { data: 'flat' as unknown } const registry = setupRegistry(prev, (s) => { - ;(s.data as any).nested + void (s.data as any).nested }) expect(registry.has('data.nested')).toBe(true) @@ -436,8 +436,8 @@ describe('diffAndUpdateSignals', () => { const next = { data: { a: 1 } as unknown } const registry = setupRegistry(prev, (s) => { - ;(s.data as any)[0] - ;(s.data as any)[1] + void (s.data as any)[0] + void (s.data as any)[1] }) expect(registry.has('data.0')).toBe(true) From a83b7e4f801b48b2915940d6fbc4b031a6a04764 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 9 Jun 2026 00:34:18 -0400 Subject: [PATCH 17/95] Fix test and import type issues --- src/index-rsc.ts | 2 ++ test/signals/arrayMethodOverrides.spec.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/index-rsc.ts b/src/index-rsc.ts index 47722081e..4ad8acf71 100644 --- a/src/index-rsc.ts +++ b/src/index-rsc.ts @@ -30,6 +30,8 @@ export { throwNotSupportedError as useDispatch, throwNotSupportedError as useSelector, throwNotSupportedError as useStore, + throwNotSupportedError as SignalProvider, + throwNotSupportedError as useSignalSelector, } export const ReactReduxContext = {} as any export { default as shallowEqual } from './utils/shallowEqual' diff --git a/test/signals/arrayMethodOverrides.spec.ts b/test/signals/arrayMethodOverrides.spec.ts index 33932a66b..bb45a71ec 100644 --- a/test/signals/arrayMethodOverrides.spec.ts +++ b/test/signals/arrayMethodOverrides.spec.ts @@ -13,7 +13,7 @@ function deepFreeze(obj: T): T { return obj } -function setup(state: object) { +function setup(state: T) { const registry = createPathSignalRegistry(alienEngine) const cache: ProxyCache = new WeakMap() const proxy = createTrackingProxy(state, '', registry, cache) @@ -695,7 +695,7 @@ describe('Array method overrides on tracking proxy', () => { expect(proxy.items.filter(() => true)).toEqual([]) expect(proxy.items.some(() => true)).toBe(false) expect(proxy.items.every(() => true)).toBe(true) - expect(proxy.items.indexOf(1)).toBe(-1) + expect(proxy.items.indexOf({ id: 1 })).toBe(-1) expect(proxy.items.join(',')).toBe('') }) From 44144ac17b71024857d0c1374d6251b52049a910 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Wed, 10 Jun 2026 00:38:39 -0400 Subject: [PATCH 18/95] Track parents for leaf values --- src/signals/trackingProxy.ts | 43 +++++++++++++++++++++++++++++- src/signals/useSignalSelector.ts | 31 ++++++++++++++++++++- test/signals/diff.spec.ts | 8 +++--- test/signals/trackingProxy.spec.ts | 10 ++++--- 4 files changed, 82 insertions(+), 10 deletions(-) diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index bf56846c4..1feae7315 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -16,6 +16,22 @@ function isObjectOrArray(v: unknown): v is object { */ const proxyPathMap = new WeakMap() + +/** + * Tracks which object-typed proxy accesses are "leaf" accesses — + * objects that were read by the selector but never had their properties + * accessed (i.e., used only for identity comparison like `===`). + * + * After the selector runs, leaf objects need their version signals + * explicitly read so that identity changes (ref swaps) are tracked. + */ +export interface LeafObjectTracker { + /** All paths where an object/array value was read, mapped to their raw values */ + accessedObjects: Map + /** Paths that had children accessed (i.e., were traversed, not leaves) */ + traversedPaths: Set +} + /** * Type for the proxy cache. Exported so the registry can own one. * Caches proxies by their target object identity. @@ -60,6 +76,7 @@ export function createTrackingProxy( parentPath: string, registry: PathSignalRegistry, cache: ProxyCache, + leafTracker?: LeafObjectTracker, ): T { // Check proxy cache — reuse proxy if we've already wrapped this exact object const cached = cache.get(target) @@ -113,17 +130,41 @@ export function createTrackingProxy( // objects that are only traversed, not read as terminal values. registry.ensurePrefix(pathKey) + // Mark parent as traversed (it has children being accessed) + if (leafTracker) { + leafTracker.traversedPaths.add(parentPath) + } + // Return cached child proxy (createTrackingProxy checks cache internally) const childProxy = createTrackingProxy( value as object, pathKey, registry, cache, + leafTracker, ) + + // Track this object access — may be a leaf (identity-only usage) + if (leafTracker) { + leafTracker.accessedObjects.set(pathKey, value as object) + } + return childProxy } - // Primitive leaf: read signal to establish dependency + // Mark parent as traversed (it has children being accessed) + if (leafTracker) { + leafTracker.traversedPaths.add(parentPath) + } + + // Primitive leaf: read signal to establish dependency. + // Also read the immediate parent's version signal so that identity + // changes to the parent object (ref swaps) are tracked. Skip for + // root-level primitives (parentPath === '') since the root object + // changes on every dispatch and would defeat the optimization. + if (parentPath !== '') { + registry.getOrCreate(parentPath, target).get() + } registry.getOrCreate(pathKey, value).get() // Return the actual value diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index 4071eb6ce..919fc9369 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -1,10 +1,15 @@ import { React } from '../utils/react' import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' import { useSignalContext } from './context' -import { createTrackingProxy, getProxyPath } from './trackingProxy' +import { + createTrackingProxy, + getProxyPath, + type LeafObjectTracker, +} from './trackingProxy' const { useRef, useMemo, useEffect, useSyncExternalStore } = React + /** * A React hook that selects state from a Redux store using signal-based * dependency tracking. Only re-renders when the selected value actually @@ -45,11 +50,21 @@ export function useSignalSelector( // we explicitly read that object's signal to establish the terminal dependency. const selectorComputed = engine.computed(() => { const state = store.getState() as S & object + + // Track leaf object accesses for identity comparison support. + // Objects read by the selector but never traversed deeper are + // "leaf objects" — their identity matters (e.g., `a === b`). + const leafTracker: LeafObjectTracker = { + accessedObjects: new Map(), + traversedPaths: new Set(), + } + const proxy = createTrackingProxy( state, '', registry, registry.proxyCache, + leafTracker, ) const result = selectorRef.current(proxy as S) @@ -60,6 +75,20 @@ export function useSignalSelector( registry.getOrCreate(proxyPath, result).get() } + // Read version signals for leaf objects — objects that were accessed + // but never had their properties read. These are used for identity + // comparison (===) and need their ref-change signals tracked. + for (const [objPath, rawValue] of leafTracker.accessedObjects) { + if (!leafTracker.traversedPaths.has(objPath)) { + // This object was read but never traversed — it's a leaf. + // Read its version signal to track identity changes. + // Skip root path since root changes every dispatch. + if (objPath !== '') { + registry.getOrCreate(objPath, rawValue).get() + } + } + } + return result }) diff --git a/test/signals/diff.spec.ts b/test/signals/diff.spec.ts index 2a8316103..b618a82df 100644 --- a/test/signals/diff.spec.ts +++ b/test/signals/diff.spec.ts @@ -180,12 +180,12 @@ describe('diffAndUpdateSignals', () => { // Leaf signal should be updated expect(registry.getOrCreate('a.b.c.d', 'changed').get()).toBe('changed') - // Intermediate objects have no signals (only prefix registrations) - // They don't get version bumps — that's the optimization + // Non-immediate ancestors have no signals (only prefix registrations) expect(registry.has('a')).toBe(false) expect(registry.has('a.b')).toBe(false) - expect(registry.has('a.b.c')).toBe(false) - // But prefix index knows about them + // Immediate parent of leaf gets a version signal (identity tracking) + expect(registry.has('a.b.c')).toBe(true) + // Prefix index knows about all ancestors expect(registry.hasPrefix('a')).toBe(true) expect(registry.hasPrefix('a.b')).toBe(true) expect(registry.hasPrefix('a.b.c')).toBe(true) diff --git a/test/signals/trackingProxy.spec.ts b/test/signals/trackingProxy.spec.ts index 19f95a9bc..789c2df51 100644 --- a/test/signals/trackingProxy.spec.ts +++ b/test/signals/trackingProxy.spec.ts @@ -317,12 +317,14 @@ describe('createTrackingProxy', () => { proxy.a.b.c - // Intermediate objects (a, a.b) only have prefix registrations, no signals + // Non-immediate ancestors only have prefix registrations, no signals expect(registry.has('a')).toBe(false) - expect(registry.has('a.b')).toBe(false) expect(registry.hasPrefix('a')).toBe(true) expect(registry.hasPrefix('a.b')).toBe(true) + // Immediate parent of leaf gets a version signal (for identity tracking) + expect(registry.has('a.b')).toBe(true) + // Leaf has a signal with the actual value expect(registry.has('a.b.c')).toBe(true) const sigABC = registry.getOrCreate('a.b.c', 42) @@ -422,8 +424,8 @@ describe('createTrackingProxy', () => { proxy1.user.profile.name proxy2.user.profile.name - // Only the leaf creates a signal; user and user.profile are prefix-only - expect(registry.size()).toBe(1) // user.profile.name (leaf) + // Leaf + its immediate parent get signals + expect(registry.size()).toBe(2) // user.profile (parent version) + user.profile.name (leaf) }) it('child proxies from different root proxies track same signal paths', () => { From 252aee69295222c38a8ec129405052dcfe503e6f Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Wed, 10 Jun 2026 00:39:02 -0400 Subject: [PATCH 19/95] Unwrap proxy values for array reference comparisons --- src/signals/arrayMethodOverrides.ts | 22 +- src/signals/trackingProxy.ts | 20 + test/signals/identityComparison.spec.tsx | 938 +++++++++++++++++++++++ 3 files changed, 971 insertions(+), 9 deletions(-) create mode 100644 test/signals/identityComparison.spec.tsx diff --git a/src/signals/arrayMethodOverrides.ts b/src/signals/arrayMethodOverrides.ts index ad87139f6..f086efe22 100644 --- a/src/signals/arrayMethodOverrides.ts +++ b/src/signals/arrayMethodOverrides.ts @@ -1,5 +1,4 @@ -import type { PathSignalRegistry } from './pathSignalRegistry' -import type { ProxyCache } from './trackingProxy' +import { getProxyTarget } from './trackingProxy' /** * Non-mutating array methods that we override on the tracking proxy. @@ -141,14 +140,19 @@ export function createArrayMethodInterceptor( return result } - // --- Primitive and transform operations: call on raw array --- + // --- Identity-comparing methods: unwrap proxy arguments --- - // indexOf/lastIndexOf/includes may receive a proxy as the search value. - // We need to compare against raw values, so extract the raw target if needed. - // For these methods, the comparison is by reference (===), and since our - // proxy !== raw value, we delegate to the raw array directly. - // This works correctly for primitive arrays. For object arrays, users - // should use findIndex instead. + // indexOf/lastIndexOf/includes compare by === (SameValueZero for includes). + // If the search argument is a tracking proxy, unwrap it to the raw target + // so the comparison works against raw array elements. + if (m === 'includes' || m === 'indexOf' || m === 'lastIndexOf') { + const unwrappedArgs = args.map((arg, i) => + i === 0 ? getProxyTarget(arg) : arg, + ) + return (target as any)[m](...unwrappedArgs) + } + + // --- Other primitive and transform operations: call on raw array --- return (target as any)[m](...args) } diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index 1feae7315..5492ae9e6 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -16,6 +16,12 @@ function isObjectOrArray(v: unknown): v is object { */ const proxyPathMap = new WeakMap() +/** + * Maps tracking proxies back to their raw target objects. + * Used to unwrap proxy arguments in array methods like includes/indexOf + * where identity comparison needs to work against raw array elements. + */ +const proxyTargetMap = new WeakMap() /** * Tracks which object-typed proxy accesses are "leaf" accesses — @@ -52,6 +58,19 @@ export function getProxyPath(value: unknown): string | undefined { return undefined } +/** + * Get the raw target object from a tracking proxy, or the value itself if not a proxy. + * Used to unwrap proxy arguments in array methods like includes/indexOf. + * @param value - The value to unwrap + * @returns The raw target object, or the original value if not a proxy + */ +export function getProxyTarget(value: unknown): unknown { + if (value !== null && typeof value === 'object') { + return proxyTargetMap.get(value) ?? value + } + return value +} + /** * Creates a read-only tracking proxy that wraps frozen Redux state. * @@ -219,6 +238,7 @@ export function createTrackingProxy( // Cache by target identity — unchanged Immer subtrees reuse same proxy cache.set(target, proxy) proxyPathMap.set(proxy as object, parentPath) + proxyTargetMap.set(proxy as object, target) return proxy } diff --git a/test/signals/identityComparison.spec.tsx b/test/signals/identityComparison.spec.tsx new file mode 100644 index 000000000..55ff28b5f --- /dev/null +++ b/test/signals/identityComparison.spec.tsx @@ -0,0 +1,938 @@ +/* eslint-disable react/prop-types */ + +import * as rtl from '@testing-library/react' +import React, { useLayoutEffect } from 'react' +import { + configureStore, + createSlice, + type PayloadAction, +} from '@reduxjs/toolkit' +import { SignalProvider, useSignalSelector } from '../../src/signals' + +// ============================================================================ +// Test: Object Identity Comparison in Selectors +// +// Investigates whether selectors relying on object identity (===, Object.is) +// are correctly tracked by our path-signal tracking proxy architecture. +// +// ROOT CAUSE OF FAILURES: +// When the tracking proxy's `get` trap encounters an object value, it calls +// `registry.ensurePrefix(pathKey)` — which registers the path in the prefix +// index but does NOT create a signal or establish a reactive dependency. +// Only primitive leaf values call `registry.getOrCreate(pathKey, value).get()` +// which creates a signal and reads it (establishing dep via alien-signals). +// +// When a selector does `state.a === state.b` (both objects), the get trap +// fires for both reads, but since both are objects, only ensurePrefix is +// called. The selector's computed ends up with ZERO signal dependencies. +// It never re-runs, regardless of state changes. +// +// ADDITIONAL ISSUE: Array.includes() override +// The `includes` override calls `target[m](...args)` on the raw frozen array, +// but the search argument is a proxy (e.g., `state.selectedItem`). Since +// `proxy !== rawObject`, `includes` always returns false for object elements. +// +// CORRECTLY WORKING: +// - Proxy cache ensures same underlying object → same proxy instance, so +// `state.draft === state.published` returns the correct boolean initially +// - Selectors that access primitive properties of objects work fine +// - `useSignalSelector` terminal proxy detection works when selector returns +// an object (proxy), but doesn't help when selector returns a boolean +// ============================================================================ + +// ============================================================================ +// Store Setup +// ============================================================================ + +interface Item { + id: number + name: string +} + +interface IdentityTestState { + items: Item[] + selectedItem: Item | null + draft: Record | null + published: Record | null + current: Record + saved: Record + listA: Item[] + setB: number[] // IDs in set B +} + +const initialItems: Item[] = [ + { id: 1, name: 'Alpha' }, + { id: 2, name: 'Beta' }, + { id: 3, name: 'Gamma' }, +] + +const sharedObj = { title: 'shared', version: 1 } + +const identitySlice = createSlice({ + name: 'identity', + initialState: { + items: initialItems, + selectedItem: initialItems[0], // same ref as items[0] + draft: sharedObj, + published: sharedObj, // same ref as draft + current: { x: 1 } as Record, + saved: { x: 1 } as Record, // different ref, same value + listA: initialItems, + setB: [1, 3], // IDs of items in set B + } as IdentityTestState, + reducers: { + selectItem(state, action: PayloadAction) { + state.selectedItem = state.items[action.payload] ?? null + }, + clearSelection(state) { + state.selectedItem = null + }, + publishDraft(state) { + // Make draft and published point to the same new object + const newObj = { title: 'updated', version: 2 } + state.draft = newObj + state.published = newObj + }, + divergeDraftFromPublished(state) { + // Make draft differ from published + state.draft = { title: 'draft-v2', version: 3 } + // published stays at its current value + }, + syncCurrentToSaved(state) { + // Make current and saved point to the same ref + // In Immer, both get new refs but with same content + state.saved = { ...state.current } + }, + mutateCurrent(state) { + state.current = { x: 999 } + }, + addToSetB(state, action: PayloadAction) { + state.setB.push(action.payload) + }, + removeFromSetB(state, action: PayloadAction) { + state.setB = state.setB.filter((id) => id !== action.payload) + }, + updateItemName( + state, + action: PayloadAction<{ index: number; name: string }>, + ) { + state.items[action.payload.index].name = action.payload.name + }, + replaceItems(state, action: PayloadAction) { + state.items = action.payload + // selectedItem is now stale (points to old array's item) + }, + }, +}) + +function createIdentityStore() { + return configureStore({ + reducer: { + identity: identitySlice.reducer, + }, + }) +} + +type IdentityStore = ReturnType +type RootState = ReturnType + +// ============================================================================ +// Tests +// ============================================================================ + +describe('object identity comparison in selectors', () => { + let store: IdentityStore + + beforeEach(() => { + store = createIdentityStore() + }) + + afterEach(() => { + rtl.cleanup() + }) + + // ========================================================================== + // Pattern 1: selectedItem === items[i] + // + // A selector reads state.selectedItem and state.items[i] and compares them. + // The proxy's get trap fires for both reads. The question is whether the + // dependency tracking is sufficient to re-run when selectedItem changes + // to point to a different object. + // ========================================================================== + + describe('Pattern 1: selectedItem === items[i]', () => { + it('detects when selectedItem is the same ref as items[0]', () => { + const Comp = () => { + const isSelected = useSignalSelector( + (state: RootState) => + state.identity.selectedItem === state.identity.items[0], + ) + return
{String(isSelected)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + // Initially selectedItem is items[0] (same ref in initialState) + expect(getByTestId('result').textContent).toBe('true') + }) + + // KNOWN FAILURE: selector reads two objects (selectedItem, items[0]) but + // never accesses a primitive property on either. The tracking proxy only + // calls ensurePrefix for objects — no signal dependency is created. + // The computed has zero reactive sources and never re-runs. + it('updates when selectedItem changes to a different item', () => { + let selectorCalls = 0 + + const Comp = () => { + const isSelected = useSignalSelector((state: RootState) => { + selectorCalls++ + return state.identity.selectedItem === state.identity.items[0] + }) + return
{String(isSelected)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + const callsAfterMount = selectorCalls + + // Change selection to items[1] — selectedItem now differs from items[0] + rtl.act(() => { + store.dispatch(identitySlice.actions.selectItem(1)) + }) + + // The selector MUST re-run and return false + expect(getByTestId('result').textContent).toBe('false') + expect(selectorCalls).toBeGreaterThan(callsAfterMount) + }) + + // KNOWN FAILURE: same root cause — selectedItem changes from an object + // proxy to null, but no signal was tracking the selectedItem path. + it('updates when selectedItem becomes null', () => { + const Comp = () => { + const isSelected = useSignalSelector( + (state: RootState) => + state.identity.selectedItem === state.identity.items[0], + ) + return
{String(isSelected)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + + rtl.act(() => { + store.dispatch(identitySlice.actions.clearSelection()) + }) + + expect(getByTestId('result').textContent).toBe('false') + }) + + // KNOWN FAILURE: items array replaced but selectedItem still points to old + // item. No signal deps on either path since both are objects. + it.fails('updates when items array is replaced (selected item becomes stale)', () => { + const Comp = () => { + const isSelected = useSignalSelector( + (state: RootState) => + state.identity.selectedItem === state.identity.items[0], + ) + return
{String(isSelected)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + + // Replace items array entirely — selectedItem still points to old item + rtl.act(() => { + store.dispatch( + identitySlice.actions.replaceItems([ + { id: 10, name: 'New Alpha' }, + { id: 20, name: 'New Beta' }, + ]), + ) + }) + + // selectedItem is the old {id:1, name:'Alpha'}, items[0] is now {id:10, name:'New Alpha'} + // These are different refs → should be false + expect(getByTestId('result').textContent).toBe('false') + }) + }) + + // ========================================================================== + // Pattern 2: draft === published (cross-slice identity) + // + // Two state branches might point to the same object. Selector checks + // if they are identical. + // ========================================================================== + + describe('Pattern 2: draft === published', () => { + it('detects when draft and published are the same ref', () => { + const Comp = () => { + const isSynced = useSignalSelector( + (state: RootState) => + state.identity.draft === state.identity.published, + ) + return
{String(isSynced)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + // Initially draft and published are the same object ref + expect(getByTestId('result').textContent).toBe('true') + }) + + // KNOWN FAILURE: draft and published are both objects. The selector + // compares their identity but never reads any primitive property. + // Zero signal deps → computed never re-runs. + it('updates when draft diverges from published', () => { + const Comp = () => { + const isSynced = useSignalSelector( + (state: RootState) => + state.identity.draft === state.identity.published, + ) + return
{String(isSynced)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + + rtl.act(() => { + store.dispatch(identitySlice.actions.divergeDraftFromPublished()) + }) + + expect(getByTestId('result').textContent).toBe('false') + }) + + it('stays true when both draft and published are updated to same new ref', () => { + const Comp = () => { + const isSynced = useSignalSelector( + (state: RootState) => + state.identity.draft === state.identity.published, + ) + return
{String(isSynced)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + + // Both draft and published get set to the same new object + rtl.act(() => { + store.dispatch(identitySlice.actions.publishDraft()) + }) + + // After Immer, these will be different refs (Immer creates new objects) + // but the selector should still reflect the actual state + const state = store.getState() + const expectedResult = state.identity.draft === state.identity.published + expect(getByTestId('result').textContent).toBe(String(expectedResult)) + }) + }) + + // ========================================================================== + // Pattern 3: current !== saved (boolean from identity comparison) + // + // Selector returns a boolean based on whether two objects are the same ref. + // This is the "has unsaved changes" pattern. + // ========================================================================== + + describe('Pattern 3: current !== saved (unsaved changes detection)', () => { + it('returns true when current and saved are different refs', () => { + const Comp = () => { + const hasChanges = useSignalSelector( + (state: RootState) => + state.identity.current !== state.identity.saved, + ) + return
{String(hasChanges)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + // Initially current and saved are different objects (even though same values) + expect(getByTestId('result').textContent).toBe('true') + }) + + // KNOWN FAILURE: selector reads current and saved (objects), compares + // identity, returns boolean. No signal deps created for object-only reads. + // The selector never re-runs even when current is replaced. + it('updates when current is mutated', () => { + let selectorCalls = 0 + + const Comp = () => { + const hasChanges = useSignalSelector((state: RootState) => { + selectorCalls++ + return state.identity.current !== state.identity.saved + }) + return
{String(hasChanges)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + const callsAfterMount = selectorCalls + + // Mutate current — it was already !== saved, stays that way + // But selector should still re-run because current's ref changed + rtl.act(() => { + store.dispatch(identitySlice.actions.mutateCurrent()) + }) + + // Result is still true, but selector should have re-run due to + // the version counter on identity.current changing + expect(getByTestId('result').textContent).toBe('true') + // We expect the selector to have been re-evaluated + expect(selectorCalls).toBeGreaterThan(callsAfterMount) + }) + }) + + // ========================================================================== + // Pattern 4: Reading object ref without accessing properties + // + // The selector reads an object but only uses it for identity comparison, + // never accessing any of its properties. Does the proxy still track this? + // ========================================================================== + + describe('Pattern 4: object ref read without property access', () => { + it('tracks object ref changes via proxy version counter', () => { + // This selector reads state.identity.current (an object) but doesn't + // access any of its properties. It just checks if the ref exists. + const Comp = () => { + const hasCurrent = useSignalSelector( + (state: RootState) => state.identity.current != null, + ) + return
{String(hasCurrent)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + }) + + it('selector re-runs when object ref changes even without property access', () => { + let selectorCalls = 0 + + // This is the tricky case: the selector reads two object refs and + // compares them, but never accesses any property inside either object. + // The proxy's get trap fires to return the child proxy, but + // ensurePrefix is called (NOT getOrCreate). No signal dependency is + // established for intermediate objects. + // + // HOWEVER: useSignalSelector checks if the result is a proxy and + // explicitly reads its signal. But here the result is a boolean, + // not a proxy. So we rely on intermediate version counters. + const Comp = () => { + const isSame = useSignalSelector((state: RootState) => { + selectorCalls++ + // Read two object refs — get trap fires for each + const current = state.identity.current + const saved = state.identity.saved + // Compare identity — no property access on either + return current === saved + }) + return
{String(isSame)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + // Initially different refs + expect(getByTestId('result').textContent).toBe('false') + const callsAfterMount = selectorCalls + + // Mutate current to a new object — still !== saved + rtl.act(() => { + store.dispatch(identitySlice.actions.mutateCurrent()) + }) + + // The result is still false (different refs), so even if selector + // re-runs, the component may not re-render. + // The key question: does the selector re-run at all? + expect(getByTestId('result').textContent).toBe('false') + }) + + it('detects identity change when result flips from false to true', () => { + // Start with current !== saved, then sync them. + // This is the critical test: if no deps are tracked for the object + // reads, the selector won't re-run and the UI stays stale. + const Comp = () => { + const isSame = useSignalSelector((state: RootState) => { + const current = state.identity.current + const saved = state.identity.saved + return current === saved + }) + return
{String(isSame)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('false') + + // Sync saved to match current + rtl.act(() => { + store.dispatch(identitySlice.actions.syncCurrentToSaved()) + }) + + // After Immer, saved gets a new object with same content as current. + // But Immer creates a new ref for saved, so current !== saved still. + // Let's check what the store actually has: + const state = store.getState() + const expected = state.identity.current === state.identity.saved + expect(getByTestId('result').textContent).toBe(String(expected)) + }) + }) + + // ========================================================================== + // Pattern 5: Array.includes / Set.has with object identity + // + // Selectors that use methods relying on identity comparison internally. + // ========================================================================== + + describe('Pattern 5: identity-based collection methods', () => { + it('tracks when using manual identity check across arrays', () => { + // Selector: find items from listA whose id is in setB + const Comp = () => { + const matchingItems = useSignalSelector((state: RootState) => { + const setBIds = new Set(state.identity.setB) + return state.identity.listA.filter((item) => setBIds.has(item.id)) + }) + return ( +
+ {matchingItems.map((i) => i.name).join(',')} +
+ ) + } + + const { getByTestId } = rtl.render( + + + , + ) + + // setB has ids [1, 3], so Alpha and Gamma match + expect(getByTestId('result').textContent).toBe('Alpha,Gamma') + + // Add id 2 to setB + rtl.act(() => { + store.dispatch(identitySlice.actions.addToSetB(2)) + }) + + expect(getByTestId('result').textContent).toBe('Alpha,Beta,Gamma') + }) + + it('updates when setB changes to exclude an item', () => { + const Comp = () => { + const matchingItems = useSignalSelector((state: RootState) => { + const setBIds = new Set(state.identity.setB) + return state.identity.listA.filter((item) => setBIds.has(item.id)) + }) + return ( +
+ {matchingItems.map((i) => i.name).join(',')} +
+ ) + } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('Alpha,Gamma') + + // Remove id 1 from setB + rtl.act(() => { + store.dispatch(identitySlice.actions.removeFromSetB(1)) + }) + + expect(getByTestId('result').textContent).toBe('Gamma') + }) + + // KNOWN FAILURE (different root cause): Array.includes override calls + // `target[m](...args)` on the RAW frozen array, but the search argument + // `state.identity.selectedItem` is a PROXY. Since proxy !== rawObject, + // includes always returns false for object elements. + // This is a bug in arrayMethodOverrides.ts — the search arg should be + // unwrapped to its raw target before passing to the native method. + it('selector using Array.includes with object identity', () => { + // This test creates a scenario where Array.includes is used with + // object references. Array.includes uses ===. + const Comp = () => { + const isFirstItemSelected = useSignalSelector((state: RootState) => { + // This is subtle: state.identity.items is a proxy, and + // state.identity.selectedItem is also a proxy. + // Array.includes on a proxy array checks if the proxy of + // selectedItem is in the proxy array. + return state.identity.items.includes(state.identity.selectedItem as Item) + }) + return
{String(isFirstItemSelected)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + // selectedItem is items[0], and items includes it + expect(getByTestId('result').textContent).toBe('true') + + // Clear selection — null is not in items + rtl.act(() => { + store.dispatch(identitySlice.actions.clearSelection()) + }) + + expect(getByTestId('result').textContent).toBe('false') + }) + }) + + // ========================================================================== + // Pattern 6: Selective re-execution verification + // + // The key concern: does the selector re-run when it SHOULD but no + // property-level signals changed? This tests whether our version + // counters on intermediate objects are sufficient. + // ========================================================================== + + describe('Pattern 6: selective re-execution with identity selectors', () => { + it('identity selector re-runs when unrelated slice changes do NOT trigger it', () => { + let identitySelectorCalls = 0 + let unrelatedSelectorCalls = 0 + + const IdentityComp = () => { + const isSame = useSignalSelector((state: RootState) => { + identitySelectorCalls++ + return state.identity.draft === state.identity.published + }) + return
{String(isSame)}
+ } + + // A second component reading something entirely different + const UnrelatedComp = () => { + const name = useSignalSelector((state: RootState) => { + unrelatedSelectorCalls++ + return state.identity.items[0].name + }) + return
{name}
+ } + + rtl.render( + + + + , + ) + + const identityCallsAfterMount = identitySelectorCalls + const unrelatedCallsAfterMount = unrelatedSelectorCalls + + // Update items[0].name — only the unrelated selector should re-run + rtl.act(() => { + store.dispatch( + identitySlice.actions.updateItemName({ index: 0, name: 'AlphaV2' }), + ) + }) + + expect(unrelatedSelectorCalls).toBeGreaterThan(unrelatedCallsAfterMount) + // Identity selector should NOT re-run — draft/published didn't change + // (The exact behavior depends on whether identity.draft and identity.published + // share parent object version signals that fire for ANY identity.* change) + // This is informational — we record whether it ran or not + const identityRan = identitySelectorCalls > identityCallsAfterMount + + // Whether or not it re-ran, the result should be correct + expect( + document.querySelector('[data-testid="identity"]')!.textContent, + ).toBe('true') + + // Log for analysis + console.log( + `Identity selector re-ran on unrelated change: ${identityRan}`, + ) + }) + + // KNOWN FAILURE: same root cause as all identity comparison failures. + // The selector reads draft and published (objects), no signal deps. + it('identity selector DOES re-run when relevant ref changes', () => { + let selectorCalls = 0 + + const Comp = () => { + const isSame = useSignalSelector((state: RootState) => { + selectorCalls++ + return state.identity.draft === state.identity.published + }) + return
{String(isSame)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + const callsAfterMount = selectorCalls + + // Diverge draft from published — selector MUST re-run and return false + rtl.act(() => { + store.dispatch(identitySlice.actions.divergeDraftFromPublished()) + }) + + expect(selectorCalls).toBeGreaterThan(callsAfterMount) + expect(getByTestId('result').textContent).toBe('false') + }) + }) + + // ========================================================================== + // Pattern 7: Proxy identity consistency + // + // Verify that when two state paths point to the same underlying object, + // the proxy cache returns the same proxy, making === work correctly + // THROUGH the proxy layer. + // ========================================================================== + + describe('Pattern 7: proxy identity preservation', () => { + it('same underlying object produces same proxy via cache', () => { + // This is fundamental: if state.draft and state.published point to + // the same object, do their proxies compare as === ? + let proxyDraft: unknown + let proxyPublished: unknown + + const Comp = () => { + const result = useSignalSelector((state: RootState) => { + proxyDraft = state.identity.draft + proxyPublished = state.identity.published + return state.identity.draft === state.identity.published + }) + return
{String(result)}
+ } + + rtl.render( + + + , + ) + + // Verify the proxies are actually the same object + expect(proxyDraft).toBe(proxyPublished) + }) + + it('different underlying objects produce different proxies', () => { + let proxyCurrent: unknown + let proxySaved: unknown + + const Comp = () => { + const result = useSignalSelector((state: RootState) => { + proxyCurrent = state.identity.current + proxySaved = state.identity.saved + return state.identity.current === state.identity.saved + }) + return
{String(result)}
+ } + + rtl.render( + + + , + ) + + // current and saved are different objects with same values + expect(proxyCurrent).not.toBe(proxySaved) + }) + }) + + // ========================================================================== + // Pattern 8: Immer structural sharing edge case + // + // When Immer updates one field but leaves another untouched, + // the untouched field keeps its reference. Test that identity + // comparisons reflect this correctly. + // ========================================================================== + + describe('Pattern 8: Immer structural sharing with identity', () => { + it('reflects Immer structural sharing in identity comparisons', () => { + // After dispatching divergeDraftFromPublished, draft changes but + // published keeps its old ref. selectedItem should still be items[0]. + const results: boolean[] = [] + + const Comp = () => { + const selectedIsFirst = useSignalSelector((state: RootState) => { + return state.identity.selectedItem === state.identity.items[0] + }) + results.push(selectedIsFirst) + return
{String(selectedIsFirst)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + + // Dispatch something that doesn't affect items or selectedItem + rtl.act(() => { + store.dispatch(identitySlice.actions.divergeDraftFromPublished()) + }) + + // selectedItem and items[0] should still be the same ref + // (Immer structural sharing preserves them) + const state = store.getState() + expect(state.identity.selectedItem).toBe(state.identity.items[0]) + + // The UI should still show true + expect(getByTestId('result').textContent).toBe('true') + }) + }) + + // ========================================================================== + // Pattern 9: Workarounds — identity comparison WITH property access + // + // These tests show that adding a primitive property access alongside + // the identity comparison makes the selector correctly reactive. + // This is the natural workaround for real-world selectors. + // ========================================================================== + + describe('Pattern 9: workaround — identity comparison with property access', () => { + it('identity selector works when also reading a primitive property', () => { + // This is the realistic pattern: compare identity BUT ALSO read + // a property (like id or name) from the objects. + const Comp = () => { + const isSelected = useSignalSelector((state: RootState) => { + // Reading .id creates a signal dependency on selectedItem.id + // and items.{id:1}.id — these signals fire when the refs change + const selId = state.identity.selectedItem?.id + const firstId = state.identity.items[0]?.id + return selId === firstId + }) + return
{String(isSelected)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + + // Change selection to items[1] + rtl.act(() => { + store.dispatch(identitySlice.actions.selectItem(1)) + }) + + // Now selectedItem.id is 2, items[0].id is still 1 → false + expect(getByTestId('result').textContent).toBe('false') + }) + + it('identity selector works when reading any leaf from compared objects', () => { + // Comparing draft and published identity, but also reading .title + const Comp = () => { + const result = useSignalSelector((state: RootState) => { + // Read a primitive from each to establish signal deps + const draftTitle = (state.identity.draft as Record) + ?.title + const pubTitle = ( + state.identity.published as Record + )?.title + return draftTitle === pubTitle + }) + return
{String(result)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + + // Diverge draft + rtl.act(() => { + store.dispatch(identitySlice.actions.divergeDraftFromPublished()) + }) + + expect(getByTestId('result').textContent).toBe('false') + }) + }) + + // ========================================================================== + // Pattern 10: indexOf/lastIndexOf with proxy arguments + // + // Same root cause as includes: the override calls the raw array method + // with a proxy argument, which can't match raw objects. + // ========================================================================== + + describe('Pattern 10: indexOf/lastIndexOf with object args', () => { + it('indexOf returns correct index for proxy-wrapped object', () => { + const Comp = () => { + const idx = useSignalSelector((state: RootState) => { + return state.identity.items.indexOf( + state.identity.selectedItem as Item, + ) + }) + return
{String(idx)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + // selectedItem is items[0], so indexOf should return 0 + expect(getByTestId('result').textContent).toBe('0') + }) + }) +}) From 90f7c6a300369c9f91fdc973f4391645f114192e Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Wed, 10 Jun 2026 00:45:11 -0400 Subject: [PATCH 20/95] Add more identity comparison tests --- test/signals/identityComparison.spec.tsx | 472 +++++++++++++++++++++++ 1 file changed, 472 insertions(+) diff --git a/test/signals/identityComparison.spec.tsx b/test/signals/identityComparison.spec.tsx index 55ff28b5f..c09f9ad11 100644 --- a/test/signals/identityComparison.spec.tsx +++ b/test/signals/identityComparison.spec.tsx @@ -49,6 +49,10 @@ interface Item { name: string } +interface NestedObj { + label: string +} + interface IdentityTestState { items: Item[] selectedItem: Item | null @@ -58,6 +62,12 @@ interface IdentityTestState { saved: Record listA: Item[] setB: number[] // IDs in set B + // Additional fields for edge case tests + objA: NestedObj + objB: NestedObj + deep: { nested: { target: NestedObj } } + maybeNull: NestedObj | null + siblingPrimitive: number } const initialItems: Item[] = [ @@ -67,6 +77,7 @@ const initialItems: Item[] = [ ] const sharedObj = { title: 'shared', version: 1 } +const sharedNested: NestedObj = { label: 'shared-nested' } const identitySlice = createSlice({ name: 'identity', @@ -79,6 +90,11 @@ const identitySlice = createSlice({ saved: { x: 1 } as Record, // different ref, same value listA: initialItems, setB: [1, 3], // IDs of items in set B + objA: sharedNested, + objB: sharedNested, // same ref as objA + deep: { nested: { target: sharedNested } }, + maybeNull: sharedNested as NestedObj | null, + siblingPrimitive: 0, } as IdentityTestState, reducers: { selectItem(state, action: PayloadAction) { @@ -122,6 +138,22 @@ const identitySlice = createSlice({ state.items = action.payload // selectedItem is now stale (points to old array's item) }, + // Edge case reducers + setObjA(state, action: PayloadAction) { + state.objA = action.payload + }, + setObjB(state, action: PayloadAction) { + state.objB = action.payload + }, + setDeepTarget(state, action: PayloadAction) { + state.deep.nested.target = action.payload + }, + setMaybeNull(state, action: PayloadAction) { + state.maybeNull = action.payload + }, + bumpSiblingPrimitive(state) { + state.siblingPrimitive += 1 + }, }, }) @@ -935,4 +967,444 @@ describe('object identity comparison in selectors', () => { expect(getByTestId('result').textContent).toBe('0') }) }) + + // ========================================================================== + // Pattern 11: Null/undefined identity transitions + // + // Object goes from non-null to null and back. Tests that the transition + // between object and null is tracked (type changes at a path). + // ========================================================================== + + describe('Pattern 11: null identity transitions', () => { + it('tracks object → null transition', () => { + const Comp = () => { + const isNull = useSignalSelector( + (state: RootState) => state.identity.maybeNull === null, + ) + return
{String(isNull)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + // Initially maybeNull is sharedNested (not null) + expect(getByTestId('result').textContent).toBe('false') + + rtl.act(() => { + store.dispatch(identitySlice.actions.setMaybeNull(null)) + }) + + expect(getByTestId('result').textContent).toBe('true') + }) + + it('tracks null → object transition', () => { + // Start by setting to null + store.dispatch(identitySlice.actions.setMaybeNull(null)) + + const Comp = () => { + const isNull = useSignalSelector( + (state: RootState) => state.identity.maybeNull === null, + ) + return
{String(isNull)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + + rtl.act(() => { + store.dispatch( + identitySlice.actions.setMaybeNull({ label: 'restored' }), + ) + }) + + expect(getByTestId('result').textContent).toBe('false') + }) + + it('tracks object → null → different object', () => { + let selectorCalls = 0 + + const Comp = () => { + const label = useSignalSelector((state: RootState) => { + selectorCalls++ + const obj = state.identity.maybeNull + return obj === null ? 'none' : 'exists' + }) + return
{label}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('exists') + + rtl.act(() => { + store.dispatch(identitySlice.actions.setMaybeNull(null)) + }) + expect(getByTestId('result').textContent).toBe('none') + + rtl.act(() => { + store.dispatch( + identitySlice.actions.setMaybeNull({ label: 'new-obj' }), + ) + }) + expect(getByTestId('result').textContent).toBe('exists') + }) + }) + + // ========================================================================== + // Pattern 12: Nested leaf object (deep traversal, identity at leaf) + // + // state.deep.nested.target used for identity. Three levels of intermediate + // traversal, leaf object at the end. + // ========================================================================== + + describe('Pattern 12: nested leaf object identity', () => { + it('tracks identity change on deeply nested object', () => { + const Comp = () => { + const isSame = useSignalSelector( + (state: RootState) => + state.identity.deep.nested.target === state.identity.objA, + ) + return
{String(isSame)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + // Initially deep.nested.target and objA are both sharedNested + expect(getByTestId('result').textContent).toBe('true') + + // Change deep.nested.target to a different object + rtl.act(() => { + store.dispatch( + identitySlice.actions.setDeepTarget({ label: 'different' }), + ) + }) + + expect(getByTestId('result').textContent).toBe('false') + }) + + it('tracks when deeply nested object becomes same ref as sibling', () => { + // First diverge them + store.dispatch( + identitySlice.actions.setObjA({ label: 'diverged' }), + ) + + const Comp = () => { + const isSame = useSignalSelector( + (state: RootState) => + state.identity.deep.nested.target === state.identity.objA, + ) + return
{String(isSame)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('false') + + // Set deep.nested.target to a new object with same content as objA + // (Immer will make them different refs) + rtl.act(() => { + store.dispatch( + identitySlice.actions.setDeepTarget({ label: 'diverged' }), + ) + }) + + // After Immer, these are different refs even with same content + const state = store.getState() + const expected = + state.identity.deep.nested.target === state.identity.objA + expect(getByTestId('result').textContent).toBe(String(expected)) + }) + }) + + // ========================================================================== + // Pattern 13: Multiple leaf objects from same parent + // + // state.objA === state.objB where both are siblings under identity. + // Tests that leaf detection works when both leaves share a parent. + // ========================================================================== + + describe('Pattern 13: sibling leaf objects', () => { + it('tracks identity between sibling objects', () => { + const Comp = () => { + const isSame = useSignalSelector( + (state: RootState) => + state.identity.objA === state.identity.objB, + ) + return
{String(isSame)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + // Initially both are sharedNested + expect(getByTestId('result').textContent).toBe('true') + + // Diverge objA + rtl.act(() => { + store.dispatch( + identitySlice.actions.setObjA({ label: 'changed' }), + ) + }) + + expect(getByTestId('result').textContent).toBe('false') + }) + + it('tracks when siblings converge to same ref', () => { + // Diverge first + store.dispatch( + identitySlice.actions.setObjA({ label: 'different' }), + ) + + const Comp = () => { + const isSame = useSignalSelector( + (state: RootState) => + state.identity.objA === state.identity.objB, + ) + return
{String(isSame)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('false') + + // Change objB to match objA's content (Immer creates new refs) + rtl.act(() => { + store.dispatch( + identitySlice.actions.setObjB({ label: 'different' }), + ) + }) + + const state = store.getState() + const expected = state.identity.objA === state.identity.objB + expect(getByTestId('result').textContent).toBe(String(expected)) + }) + }) + + // ========================================================================== + // Pattern 14: Parent version signal false-positive suppression + // + // Sibling primitive changes → parent version signal fires → selector + // re-runs → returns same result → equality check suppresses re-render. + // Verifies the "benign false positive" trade-off of parent version signals. + // ========================================================================== + + describe('Pattern 14: false-positive suppression via equality check', () => { + it('does not re-render when sibling changes but selector result is stable', () => { + let renderCount = 0 + + const Comp = () => { + renderCount++ + const isSame = useSignalSelector( + (state: RootState) => + state.identity.objA === state.identity.objB, + ) + return
{String(isSame)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + const rendersAfterMount = renderCount + + // Change siblingPrimitive — shares the identity parent with objA/objB. + // This fires the identity parent version signal. The selector re-runs + // but objA === objB is still true. Equality check suppresses re-render. + rtl.act(() => { + store.dispatch(identitySlice.actions.bumpSiblingPrimitive()) + }) + + // Result should still be true, and render count should not increase + expect(getByTestId('result').textContent).toBe('true') + expect(renderCount).toBe(rendersAfterMount) + }) + }) + + // ========================================================================== + // Pattern 15: Conditional property access based on identity + // + // Selector first checks identity, then conditionally reads properties. + // On first run it may only read objects (leaf detection needed). + // On re-run after state change, it reads different paths. + // ========================================================================== + + describe('Pattern 15: conditional property access after identity check', () => { + it('handles selector that conditionally reads properties', () => { + const Comp = () => { + const result = useSignalSelector((state: RootState) => { + if (state.identity.objA === state.identity.objB) { + // Same ref — just return the label + return state.identity.objA.label + } + // Different refs — return comparison string + return `${state.identity.objA.label} vs ${state.identity.objB.label}` + }) + return
{result}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + // Initially same ref → returns label + expect(getByTestId('result').textContent).toBe('shared-nested') + + // Diverge objA — different refs now + rtl.act(() => { + store.dispatch( + identitySlice.actions.setObjA({ label: 'alpha' }), + ) + }) + + expect(getByTestId('result').textContent).toBe('alpha vs shared-nested') + }) + + it('handles short-circuit: reads only one side when first is null', () => { + // Set maybeNull to null first + store.dispatch(identitySlice.actions.setMaybeNull(null)) + + const Comp = () => { + const result = useSignalSelector((state: RootState) => { + // Short circuit: if maybeNull is null, don't read objA + if (state.identity.maybeNull === null) { + return 'no-object' + } + return state.identity.maybeNull === state.identity.objA + ? 'same' + : 'different' + }) + return
{result}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('no-object') + + // Restore maybeNull — now it's a different ref from objA + rtl.act(() => { + store.dispatch( + identitySlice.actions.setMaybeNull({ label: 'restored' }), + ) + }) + + expect(getByTestId('result').textContent).toBe('different') + }) + }) + + // ========================================================================== + // Pattern 16: Returning a leaf object (terminal proxy check) + // + // Selector returns an object directly (not a boolean from comparison). + // The terminal proxy check in useSignalSelector should handle this. + // ========================================================================== + + describe('Pattern 16: selector returning leaf object', () => { + it('re-runs when returned object ref changes', () => { + let selectorCalls = 0 + + const Comp = () => { + const obj = useSignalSelector((state: RootState) => { + selectorCalls++ + return state.identity.objA + }) + return
{(obj as NestedObj).label}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('shared-nested') + const callsAfterMount = selectorCalls + + rtl.act(() => { + store.dispatch( + identitySlice.actions.setObjA({ label: 'updated' }), + ) + }) + + expect(getByTestId('result').textContent).toBe('updated') + expect(selectorCalls).toBeGreaterThan(callsAfterMount) + }) + }) + + // ========================================================================== + // Pattern 17: Mixed identity + primitive in same selector + // + // Selector reads both object identity and primitive properties. + // Tests that leaf object detection and parent version signals + // work together without interference. + // ========================================================================== + + describe('Pattern 17: mixed identity and primitive reads', () => { + it('tracks both identity and primitive changes', () => { + const Comp = () => { + const result = useSignalSelector((state: RootState) => { + const isSame = + state.identity.draft === state.identity.published + const count = state.identity.siblingPrimitive + return `${isSame}-${count}` + }) + return
{result}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true-0') + + // Change primitive + rtl.act(() => { + store.dispatch(identitySlice.actions.bumpSiblingPrimitive()) + }) + expect(getByTestId('result').textContent).toBe('true-1') + + // Change identity + rtl.act(() => { + store.dispatch(identitySlice.actions.divergeDraftFromPublished()) + }) + expect(getByTestId('result').textContent).toBe('false-1') + }) + }) }) From 7a7ef9a60fef44b44020d8b850e0550895ad1692 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Wed, 10 Jun 2026 09:17:57 +0200 Subject: [PATCH 21/95] Handle item swaps while pruning --- src/signals/pathSignalRegistry.ts | 7 +++++++ test/signals/identityComparison.spec.tsx | 4 +--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/signals/pathSignalRegistry.ts b/src/signals/pathSignalRegistry.ts index e0bb64d6a..adda11e3b 100644 --- a/src/signals/pathSignalRegistry.ts +++ b/src/signals/pathSignalRegistry.ts @@ -168,6 +168,13 @@ export function createPathSignalRegistry( } // Remove from signals or prefix-only, and decrement prefix counts if (signals.has(key)) { + const sig = signals.get(key)! + // Fire the signal before removing it. This ensures any computed + // that depended on this path re-evaluates with the new state. + // Prune runs inside engine.batch() via reconcileState, so the + // computed won't re-run until the batch completes. + const current = sig.get() + sig.set(typeof current === 'number' ? current + 1 : current) signals.delete(key) decrementPrefixes(prefixCounts, key) } else if (prefixOnlyPaths.has(key)) { diff --git a/test/signals/identityComparison.spec.tsx b/test/signals/identityComparison.spec.tsx index c09f9ad11..d632965d2 100644 --- a/test/signals/identityComparison.spec.tsx +++ b/test/signals/identityComparison.spec.tsx @@ -272,9 +272,7 @@ describe('object identity comparison in selectors', () => { expect(getByTestId('result').textContent).toBe('false') }) - // KNOWN FAILURE: items array replaced but selectedItem still points to old - // item. No signal deps on either path since both are objects. - it.fails('updates when items array is replaced (selected item becomes stale)', () => { + it('updates when items array is replaced (selected item becomes stale)', () => { const Comp = () => { const isSelected = useSignalSelector( (state: RootState) => From f5b0e79f104e42eefd5839ee4832a801c2578047 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 11 Jun 2026 12:14:04 +0200 Subject: [PATCH 22/95] Add unwrap method for proxy edge cases --- src/exports.ts | 3 +- src/index-rsc.ts | 1 + src/signals/arrayMethodOverrides.ts | 4 +- src/signals/index.ts | 2 +- src/signals/trackingProxy.ts | 26 +++++-- test/signals/trackingProxy.spec.ts | 107 +++++++++++++++++++++++++++- 6 files changed, 133 insertions(+), 10 deletions(-) diff --git a/src/exports.ts b/src/exports.ts index e5ed97bc7..bfc108328 100644 --- a/src/exports.ts +++ b/src/exports.ts @@ -9,7 +9,7 @@ import shallowEqual from './utils/shallowEqual' import Provider from './components/Provider' import { defaultNoopBatch } from './utils/batch' -import { SignalProvider, useSignalSelector } from './signals' +import { SignalProvider, useSignalSelector, unwrap } from './signals' export { ReactReduxContext } from './components/Context' export type { ReactReduxContextValue } from './components/Context' @@ -56,5 +56,6 @@ export { legacy_connect, shallowEqual, SignalProvider, + unwrap, useSignalSelector, } diff --git a/src/index-rsc.ts b/src/index-rsc.ts index 4ad8acf71..42c40338e 100644 --- a/src/index-rsc.ts +++ b/src/index-rsc.ts @@ -31,6 +31,7 @@ export { throwNotSupportedError as useSelector, throwNotSupportedError as useStore, throwNotSupportedError as SignalProvider, + throwNotSupportedError as unwrap, throwNotSupportedError as useSignalSelector, } export const ReactReduxContext = {} as any diff --git a/src/signals/arrayMethodOverrides.ts b/src/signals/arrayMethodOverrides.ts index f086efe22..a383156d0 100644 --- a/src/signals/arrayMethodOverrides.ts +++ b/src/signals/arrayMethodOverrides.ts @@ -1,4 +1,4 @@ -import { getProxyTarget } from './trackingProxy' +import { unwrap } from './trackingProxy' /** * Non-mutating array methods that we override on the tracking proxy. @@ -147,7 +147,7 @@ export function createArrayMethodInterceptor( // so the comparison works against raw array elements. if (m === 'includes' || m === 'indexOf' || m === 'lastIndexOf') { const unwrappedArgs = args.map((arg, i) => - i === 0 ? getProxyTarget(arg) : arg, + i === 0 ? unwrap(arg) : arg, ) return (target as any)[m](...unwrappedArgs) } diff --git a/src/signals/index.ts b/src/signals/index.ts index 78bfe425f..39c5ed04f 100644 --- a/src/signals/index.ts +++ b/src/signals/index.ts @@ -18,6 +18,6 @@ export type { export { createPathSignalRegistry } from './pathSignalRegistry' export type { PathSignalRegistry } from './pathSignalRegistry' -export { createTrackingProxy } from './trackingProxy' +export { createTrackingProxy, unwrap } from './trackingProxy' export { diffAndUpdateSignals, reconcileState } from './diff' diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index 5492ae9e6..85afb759e 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -60,13 +60,28 @@ export function getProxyPath(value: unknown): string | undefined { /** * Get the raw target object from a tracking proxy, or the value itself if not a proxy. - * Used to unwrap proxy arguments in array methods like includes/indexOf. - * @param value - The value to unwrap - * @returns The raw target object, or the original value if not a proxy + * + * Use this when you need identity comparison between values that may be + * tracking proxies. Since `proxy === rawObject` is always `false` in JS, + * unwrapping both sides allows correct identity checks: + * + * ```ts + * import { unwrap } from 'react-redux/signals' + * + * const selector = (state) => { + * const current = unwrap(state.current) + * return state.items.find(item => item === current) + * } + * ``` + * + * Safe to call on non-proxy values — returns them unchanged. + * + * @param value - The value to unwrap (proxy or raw) + * @returns The raw target object, or the original value if not a tracking proxy */ -export function getProxyTarget(value: unknown): unknown { +export function unwrap(value: T): T { if (value !== null && typeof value === 'object') { - return proxyTargetMap.get(value) ?? value + return (proxyTargetMap.get(value as object) as T) ?? value } return value } @@ -88,6 +103,7 @@ export function getProxyTarget(value: unknown): unknown { * @param parentPath - Dot-separated path to this object in the state tree * @param registry - Signal registry for dependency tracking * @param cache - Proxy cache for deduplication + * @param leafTracker - Optional tracker for detecting leaf object accesses * @returns A tracking proxy wrapping the target */ export function createTrackingProxy( diff --git a/test/signals/trackingProxy.spec.ts b/test/signals/trackingProxy.spec.ts index 789c2df51..1f88242c4 100644 --- a/test/signals/trackingProxy.spec.ts +++ b/test/signals/trackingProxy.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest' -import { createTrackingProxy, getProxyPath } from '../../src/signals/trackingProxy' +import { createTrackingProxy, getProxyPath, unwrap } from '../../src/signals/trackingProxy' import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' import { alienEngine } from '../../src/signals/engine' import type { PathSignalRegistry } from '../../src/signals/pathSignalRegistry' @@ -707,3 +707,108 @@ describe('createTrackingProxy', () => { scope.stop() }) }) + +describe('unwrap', () => { + function makeRegistry(): PathSignalRegistry { + return createPathSignalRegistry(alienEngine) + } + + it('returns the raw target from a tracking proxy', () => { + const state = { name: 'Alice', nested: { x: 1 } } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(unwrap(proxy)).toBe(state) + }) + + it('returns nested raw target from a child proxy', () => { + const state = { nested: { x: 1 } } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + const childProxy = proxy.nested + expect(childProxy).not.toBe(state.nested) // is a proxy + expect(unwrap(childProxy)).toBe(state.nested) // unwraps to raw + }) + + it('returns the value unchanged for non-proxy objects', () => { + const obj = { a: 1 } + expect(unwrap(obj)).toBe(obj) + }) + + it('returns primitives unchanged', () => { + expect(unwrap(42)).toBe(42) + expect(unwrap('hello')).toBe('hello') + expect(unwrap(true)).toBe(true) + expect(unwrap(null)).toBe(null) + expect(unwrap(undefined)).toBe(undefined) + }) + + it('enables identity comparison between unwrapped proxy and raw value', () => { + const item = { id: 1, name: 'first' } + const state = { items: [item], current: item } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + // Without unwrap: proxy !== raw + const proxiedCurrent = proxy.current + expect(proxiedCurrent === item).toBe(false) + + // With unwrap: raw === raw + expect(unwrap(proxiedCurrent) === item).toBe(true) + }) + + it('indexOf/includes auto-unwrap proxy arguments', () => { + const state = { + items: [ + { id: 1, name: 'a' }, + { id: 2, name: 'b' }, + { id: 3, name: 'c' }, + ], + } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + // Access an item through the proxy — get back a proxy wrapper + const proxiedItem = proxy.items[1] + expect(proxiedItem).not.toBe(state.items[1]) // it's a proxy + + // indexOf/includes auto-unwrap proxy args internally, so these just work + expect(proxy.items.indexOf(proxiedItem)).toBe(1) + expect(proxy.items.includes(proxiedItem)).toBe(true) + + // Also works with manually unwrapped value + expect(proxy.items.indexOf(unwrap(proxiedItem))).toBe(1) + }) + + it('find() callback needs unwrap for identity comparison', () => { + const state = { + items: [ + { id: 1, name: 'a' }, + { id: 2, name: 'b' }, + { id: 3, name: 'c' }, + ], + } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + // Get a proxy-wrapped item + const proxiedItem = proxy.items[1] + const rawItem = unwrap(proxiedItem) + + // find callback receives raw elements; unwrapped proxy === raw element + const found = proxy.items.find( + (item: { id: number; name: string }) => item === rawItem, + ) + + expect(found).toBeDefined() + // find() returns a proxied result (lazy signal registration) + expect(found).not.toBe(state.items[1]) + expect(unwrap(found)).toBe(state.items[1]) + + // Accessing properties on the found proxy registers signals + const name = (found as { name: string }).name + expect(name).toBe('b') + expect(registry.has('items.{id:2}.name')).toBe(true) + }) +}) From 2f6d97402655334b5b7b1ee90a4dbcb1563c4831 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 11 Jun 2026 17:16:08 +0200 Subject: [PATCH 23/95] Add tests for array method cases --- src/signals/pathSignalRegistry.ts | 7 + test/signals/trackingProxy.spec.ts | 432 +++++++++++++++++++++++++++++ 2 files changed, 439 insertions(+) diff --git a/src/signals/pathSignalRegistry.ts b/src/signals/pathSignalRegistry.ts index adda11e3b..c0b637c9a 100644 --- a/src/signals/pathSignalRegistry.ts +++ b/src/signals/pathSignalRegistry.ts @@ -27,6 +27,9 @@ export interface PathSignalRegistry { /** Check if any tracked signal exists at or below this path prefix. */ hasPrefix(prefix: string): boolean + /** Return all registered signal paths (for testing/debugging). */ + debugPaths(): string[] + /** Proxy cache for reusing proxies across evaluations (keyed by object identity). */ proxyCache: ProxyCache @@ -208,6 +211,10 @@ export function createPathSignalRegistry( return (prefixCounts.get(prefix) || 0) > 0 }, + debugPaths(): string[] { + return Array.from(signals.keys()).sort() + }, + proxyCache: proxyWeakMap, getArrayMeta(arrayPath: string): ArrayMeta | undefined { diff --git a/test/signals/trackingProxy.spec.ts b/test/signals/trackingProxy.spec.ts index 1f88242c4..0484eaf07 100644 --- a/test/signals/trackingProxy.spec.ts +++ b/test/signals/trackingProxy.spec.ts @@ -1,7 +1,9 @@ import { describe, it, expect, vi } from 'vitest' import { createTrackingProxy, getProxyPath, unwrap } from '../../src/signals/trackingProxy' import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import { reconcileState } from '../../src/signals/diff' import { alienEngine } from '../../src/signals/engine' +import type { LeafObjectTracker } from '../../src/signals/trackingProxy' import type { PathSignalRegistry } from '../../src/signals/pathSignalRegistry' function makeRegistry(): PathSignalRegistry { @@ -812,3 +814,433 @@ describe('unwrap', () => { expect(registry.has('items.{id:2}.name')).toBe(true) }) }) + +// ─── Array method dependency tracking ─── +// These tests verify which signals are registered when using array methods +// through the tracking proxy, and whether state changes trigger re-evaluation. +// The pattern simulates what useSignalSelector does: computed → proxy → selector → diff → re-eval. + +describe('array method dependency tracking', () => { + type TodoItem = { id: number; text: string; done: boolean } + type TodoState = { items: TodoItem[] } + + /** Create a frozen state. For structural sharing, pass existing items unchanged. */ + function makeFrozenState(items: TodoItem[]): TodoState { + return Object.freeze({ + items: Object.freeze(items.map(i => Object.freeze(i))), + }) as TodoState + } + + /** Simulate Immer-style structural sharing: only replace the item that changed. */ + function updateItem( + state: TodoState, + id: number, + patch: Partial, + ): TodoState { + return Object.freeze({ + items: Object.freeze( + state.items.map(item => + item.id === id ? Object.freeze({ ...item, ...patch }) : item, + ), + ), + }) as TodoState + } + + /** Simulate what useSignalSelector does: run selector in a computed with leaf tracking. */ + function createSelectorComputed( + getState: () => S, + selector: (state: S) => R, + registry: PathSignalRegistry, + ) { + let callCount = 0 + const scope = alienEngine.createScope() + + const computed = scope.run(() => + alienEngine.computed(() => { + callCount++ + const state = getState() + + const leafTracker: LeafObjectTracker = { + accessedObjects: new Map(), + traversedPaths: new Set(), + } + + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + leafTracker, + ) + const result = selector(proxy as S) + + // Terminal object dependency (same as useSignalSelector) + const proxyPath = getProxyPath(result) + if (proxyPath !== undefined) { + registry.getOrCreate(proxyPath, result).get() + } + + // Leaf object tracking (same as useSignalSelector) + for (const [objPath, rawValue] of leafTracker.accessedObjects) { + if (!leafTracker.traversedPaths.has(objPath)) { + if (objPath !== '') { + registry.getOrCreate(objPath, rawValue).get() + } + } + } + + return result + }), + ) + + return { + computed, + getCallCount: () => callCount, + stop: () => scope.stop(), + } + } + + // ─── find() with property predicate ─── + + it('find(x => x.done) — re-runs when matched element changes', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: true }, + { id: 3, text: 'c', done: false }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.find(x => x.done)?.text, + registry, + ) + + expect(c.get()).toBe('b') + expect(getCallCount()).toBe(1) + + // Change the matched element's text — should re-run (structural sharing) + const prevState = state + state = updateItem(state, 2, { text: 'b-updated' }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('b-updated') + expect(getCallCount()).toBe(2) + + stop() + }) + + it('find(x => x.done) — SHOULD re-run when a non-matching element becomes done', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: true }, + { id: 3, text: 'c', done: false }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.find(x => x.done)?.text, + registry, + ) + + expect(c.get()).toBe('b') + expect(getCallCount()).toBe(1) + + // Item 1 becomes done — it should now be the first match. + // Use structural sharing: only item 1 is a new object. + const prevState = state + state = updateItem(state, 1, { done: true }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('a') // should be 'a' now + expect(getCallCount()).toBe(2) + + stop() + }) + + it('find(x => x.done) — SHOULD re-run when a new element is added', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.find(x => x.done)?.text ?? 'none', + registry, + ) + + expect(c.get()).toBe('none') + expect(getCallCount()).toBe(1) + + // Add a done item + const prevState = state + state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: true }, // new, done + ]) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('b') + expect(getCallCount()).toBe(2) + + stop() + }) + + // ─── filter() with property predicate ─── + + it('filter(x => x.done) — re-runs when matched element changes', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: true }, + { id: 2, text: 'b', done: false }, + { id: 3, text: 'c', done: true }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.filter(x => x.done).map(x => x.text).join(','), + registry, + ) + + expect(c.get()).toBe('a,c') + expect(getCallCount()).toBe(1) + + // Change text of matched element (structural sharing) + const prevState = state + state = updateItem(state, 1, { text: 'a-updated' }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('a-updated,c') + expect(getCallCount()).toBe(2) + + stop() + }) + + it('filter(x => x.done) — SHOULD re-run when a non-matching element becomes done', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: true }, + { id: 2, text: 'b', done: false }, + { id: 3, text: 'c', done: true }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.filter(x => x.done).map(x => x.text).join(','), + registry, + ) + + expect(c.get()).toBe('a,c') + expect(getCallCount()).toBe(1) + + // Item 2 becomes done (structural sharing — only item 2 changes) + const prevState = state + state = updateItem(state, 2, { done: true }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('a,b,c') + expect(getCallCount()).toBe(2) + + stop() + }) + + // ─── some() / every() with property predicate ─── + + it('some(x => x.done) — SHOULD re-run when result changes from true to false', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: true }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.some(x => x.done), + registry, + ) + + expect(c.get()).toBe(true) + expect(getCallCount()).toBe(1) + + // Item 2 becomes not done (structural sharing) + const prevState = state + state = updateItem(state, 2, { done: false }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe(false) + expect(getCallCount()).toBe(2) + + stop() + }) + + it('every(x => x.done) — SHOULD re-run when one element becomes not done', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: true }, + { id: 2, text: 'b', done: true }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.every(x => x.done), + registry, + ) + + expect(c.get()).toBe(true) + expect(getCallCount()).toBe(1) + + // Item 1 becomes not done (structural sharing) + const prevState = state + state = updateItem(state, 1, { done: false }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe(false) + expect(getCallCount()).toBe(2) + + stop() + }) + + // ─── map() (NOT overridden — should track all element properties) ─── + + it('map(x => x.text) — tracks all elements, re-runs when any element text changes', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: false }, + { id: 3, text: 'c', done: false }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.map(x => x.text).join(','), + registry, + ) + + expect(c.get()).toBe('a,b,c') + expect(getCallCount()).toBe(1) + + // Change item 3's text (structural sharing) + const prevState = state + state = updateItem(state, 3, { text: 'c-updated' }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('a,b,c-updated') + expect(getCallCount()).toBe(2) + + stop() + }) + + it('map(x => x.text) — does NOT re-run when unrelated property (done) changes', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: false }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.map(x => x.text).join(','), + registry, + ) + + expect(c.get()).toBe('a,b') + expect(getCallCount()).toBe(1) + + // Change done but not text (structural sharing) + const prevState = state + state = updateItem(state, 1, { done: true }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('a,b') + // Ideally callCount stays 1, but diff fires item's parent signal + // which causes re-eval. The computed's value is === equal so the effect + // won't notify React, but the computed itself re-evaluates. + // Accept 1 or 2 here — the important thing is correctness. + expect(getCallCount()).toBeLessThanOrEqual(2) + + stop() + }) + + // ─── includes/indexOf — identity methods ─── + + it('includes() — SHOULD re-run when array structure changes', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: false }, + ]) + const registry = makeRegistry() + const targetItem = state.items[0] // raw ref + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.includes(targetItem as TodoItem), + registry, + ) + + expect(c.get()).toBe(true) + expect(getCallCount()).toBe(1) + + // Remove item 1 from array (structural sharing: item 2 is same ref) + const prevState = state + state = Object.freeze({ + items: Object.freeze([Object.freeze({ id: 2, text: 'b', done: false })]), + }) as TodoState + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe(false) + expect(getCallCount()).toBe(2) + + stop() + }) + + // ─── slice — structural ─── + + it('slice(0, 2) — SHOULD re-run when array gains elements', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.slice(0, 2).map(x => x.text).join(','), + registry, + ) + + expect(c.get()).toBe('a') + expect(getCallCount()).toBe(1) + + // Add element + const prevState = state + state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: false }, + ]) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('a,b') + expect(getCallCount()).toBe(2) + + stop() + }) + + // ─── findIndex — predicate, returns primitive ─── + + it('findIndex(x => x.done) — SHOULD re-run when a different element becomes done', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: true }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.findIndex(x => x.done), + registry, + ) + + expect(c.get()).toBe(1) + expect(getCallCount()).toBe(1) + + // Item 1 becomes done (structural sharing — item 2 unchanged) + const prevState = state + state = updateItem(state, 1, { done: true }) + reconcileState(prevState, state, registry, alienEngine) + // findIndex should now return 0 since item 1 is now done (first match) + expect(c.get()).toBe(0) + expect(getCallCount()).toBe(2) + + stop() + }) +}) From c573d2a556c36393fa5bd853eef39d9080bb4a73 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 11 Jun 2026 17:56:48 +0200 Subject: [PATCH 24/95] Always register dependencies on arrays during method calls --- src/signals/arrayMethodOverrides.ts | 21 +++++++++++++++++++++ src/signals/trackingProxy.ts | 2 +- test/signals/trackingProxy.spec.ts | 22 +++++++++++++++++++--- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/signals/arrayMethodOverrides.ts b/src/signals/arrayMethodOverrides.ts index a383156d0..151fb6b50 100644 --- a/src/signals/arrayMethodOverrides.ts +++ b/src/signals/arrayMethodOverrides.ts @@ -1,3 +1,4 @@ +import type { PathSignalRegistry } from './pathSignalRegistry' import { unwrap } from './trackingProxy' /** @@ -10,6 +11,12 @@ import { unwrap } from './trackingProxy' * Example: `state.items.find(i => i.id === 42)` scans 1000 raw items (fast), * then returns a proxy for the one match (registers signals for that element only). * + * **Whole-array dependency:** Every overridden method registers a signal on the + * array itself. Because callbacks scan raw (unproxied) elements, individual + * element changes wouldn't be detected otherwise. The coarse array signal + * ensures the selector re-runs when ANY element changes. This may over-fire, + * but the selector's `===` equality check on its result bails out cheaply. + * * Categories: * - Subset operations (find, findLast, filter, slice): return proxied elements * - Primitive-returning (findIndex, indexOf, some, every, includes, etc.): return as-is @@ -78,19 +85,33 @@ function normalizeSliceIndex(index: number, length: number): number { * array, bypassing per-element proxy creation. Only results that the caller * will actually use get wrapped in tracking proxies. * + * Registers a coarse dependency on the array itself so that changes to ANY + * element (even ones the callback scanned but didn't match) trigger re-evaluation. + * * @param target - The raw frozen array * @param proxy - The tracking proxy wrapping this array (used to return proxied elements) * @param method - The method name being intercepted + * @param registry - Signal registry for dependency tracking + * @param parentPath - Path to this array in the state tree * @returns An interceptor function that delegates to the raw array */ export function createArrayMethodInterceptor( target: readonly unknown[], proxy: object, method: string, + registry: PathSignalRegistry, + parentPath: string, ): (...args: unknown[]) => unknown { return function intercepted(...args: unknown[]): unknown { const m = method as OverriddenMethod + // Register a dependency on the array itself. Since callbacks receive + // raw (unproxied) elements, per-element signals aren't created during + // the scan. This coarse signal ensures re-evaluation when any element + // changes. diffArray fires registry.update(parentPath, ...) for every + // visited array, so this signal will fire on any descendant change. + registry.getOrCreate(parentPath, target).get() + // --- Subset operations: scan raw, return proxied results --- if (m === 'filter') { diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index 85afb759e..ce1b77986 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -132,7 +132,7 @@ export function createTrackingProxy( // Functions: intercept array methods to avoid per-element proxy creation if (typeof value === 'function') { if (Array.isArray(target) && isOverriddenArrayMethod(prop as string)) { - return createArrayMethodInterceptor(target, proxy, prop as string) + return createArrayMethodInterceptor(target, proxy, prop as string, registry, parentPath) } return value } diff --git a/test/signals/trackingProxy.spec.ts b/test/signals/trackingProxy.spec.ts index 0484eaf07..11320d7d1 100644 --- a/test/signals/trackingProxy.spec.ts +++ b/test/signals/trackingProxy.spec.ts @@ -824,14 +824,24 @@ describe('array method dependency tracking', () => { type TodoItem = { id: number; text: string; done: boolean } type TodoState = { items: TodoItem[] } - /** Create a frozen state. For structural sharing, pass existing items unchanged. */ + /** + * Create a frozen state. For structural sharing, pass existing items unchanged. + * @param items - Array of todo items + * @returns Deeply frozen TodoState + */ function makeFrozenState(items: TodoItem[]): TodoState { return Object.freeze({ items: Object.freeze(items.map(i => Object.freeze(i))), }) as TodoState } - /** Simulate Immer-style structural sharing: only replace the item that changed. */ + /** + * Simulate Immer-style structural sharing: only replace the item that changed. + * @param state - Current state + * @param id - ID of item to update + * @param patch - Partial fields to merge + * @returns New frozen state with structural sharing + */ function updateItem( state: TodoState, id: number, @@ -846,7 +856,13 @@ describe('array method dependency tracking', () => { }) as TodoState } - /** Simulate what useSignalSelector does: run selector in a computed with leaf tracking. */ + /** + * Simulate what useSignalSelector does: run selector in a computed with leaf tracking. + * @param getState - Returns current state + * @param selector - Selector function to run through tracking proxy + * @param registry - Signal registry for dependency tracking + * @returns Object with computed, call count getter, and stop function + */ function createSelectorComputed( getState: () => S, selector: (state: S) => R, From 15e2273f602c160439492ed831a8a020e5ad80d0 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 26 Jul 2026 13:15:18 -0400 Subject: [PATCH 25/95] Track column & structural signals for array scan methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Array callback methods (find, filter, some, every, ...) now pass a lightweight scan-recorder proxy to the callback instead of raw elements. The recorder tracks which properties the callback reads, then registers one column signal per (array, property) pair — e.g. `items.{*}.done`. The diff engine fires column signals when any surviving element's tracked property changes, and fires structural signals selectively based on the kind of membership change (append, insertOrReorder, remove). Also: - Remove unnecessary parent-version signals for intermediate objects --- src/signals/arrayMethodOverrides.ts | 313 +++++++++++++--- src/signals/diff.ts | 194 +++++++++- src/signals/pathSignalRegistry.ts | 115 +++++- src/signals/trackingProxy.ts | 47 ++- test/signals/arrayMethodOverrides.spec.ts | 41 ++- test/signals/diff.spec.ts | 8 +- test/signals/trackingProxy.spec.ts | 420 +++++++++++++++++++++- 7 files changed, 1048 insertions(+), 90 deletions(-) diff --git a/src/signals/arrayMethodOverrides.ts b/src/signals/arrayMethodOverrides.ts index 151fb6b50..b28a6ae5d 100644 --- a/src/signals/arrayMethodOverrides.ts +++ b/src/signals/arrayMethodOverrides.ts @@ -1,21 +1,32 @@ -import type { PathSignalRegistry } from './pathSignalRegistry' -import { unwrap } from './trackingProxy' +import type { PathSignalRegistry, StructureKind } from './pathSignalRegistry' +import { registerRecordingHolder, unwrap } from './trackingProxy' /** * Non-mutating array methods that we override on the tracking proxy. * - * The key optimization: callbacks receive RAW frozen values (no proxy, no signal - * registration). Only the RESULTS get proxied — so signal dependencies are - * established only for the elements the selector actually uses. + * The key optimization: callbacks scan elements WITHOUT per-element proxy + * creation or per-element signal registration. Only the RESULTS get proxied — + * so fine-grained signal dependencies are established only for the elements + * the selector actually uses. * - * Example: `state.items.find(i => i.id === 42)` scans 1000 raw items (fast), + * Example: `state.items.find(i => i.id === 42)` scans 1000 items (fast), * then returns a proxy for the one match (registers signals for that element only). * - * **Whole-array dependency:** Every overridden method registers a signal on the - * array itself. Because callbacks scan raw (unproxied) elements, individual - * element changes wouldn't be detected otherwise. The coarse array signal - * ensures the selector re-runs when ANY element changes. This may over-fire, - * but the selector's `===` equality check on its result bails out cheaply. + * **Dependency tracking for scans:** + * - Callback methods (find, filter, some, every, findIndex, ...) pass a + * lightweight *scan recorder* proxy to the callback. It records WHICH + * properties the callback reads (e.g. `done` in `find(x => x.done)`) and + * forwards values from the raw element. After the scan, one *column signal* + * per (array, property) pair is registered — e.g. `items.{*}.done`. The + * diff fires a column signal when any element's value for that property + * changes, or when membership changes. This keeps scans O(N) with O(props) + * signals instead of O(N×props). + * - If the callback reads nested objects, enumerates keys, or elements are + * primitives, we fall back to a coarse whole-array version signal (fires + * on any descendant change — may over-fire, but the selector's `===` + * result check bails out cheaply). + * - Non-callback methods that depend on all elements (includes, indexOf, + * join, concat, slice, ...) always register the coarse array signal. * * Categories: * - Subset operations (find, findLast, filter, slice): return proxied elements @@ -42,6 +53,17 @@ type OverriddenMethod = SubsetMethod | PrimitiveMethod | TransformMethod const FIND_METHODS = new Set(['find', 'findLast']) +/** Methods whose element dependencies come from what the callback reads. */ +const CALLBACK_METHODS = new Set([ + 'find', + 'findLast', + 'filter', + 'findIndex', + 'findLastIndex', + 'some', + 'every', +]) + const OVERRIDDEN_METHODS = new Set([ // Subset — return proxied results 'find', @@ -80,14 +102,194 @@ function normalizeSliceIndex(index: number, length: number): number { return Math.min(index, length) } +/** + * A reusable recorder for array scan callbacks. One proxy is created per + * method call (not per element); it reads from `holder.current`, which is + * swapped to each element as the scan advances. + */ +interface ScanRecorder { + proxy: object + holder: { current: unknown } + /** String property names the callback read on elements. */ + recorded: Set + /** True when column tracking can't capture the callback's reads + * (nested object reads, key enumeration, primitive elements). */ + fallback: boolean +} + +function createScanRecorder(): ScanRecorder { + const holder: { current: unknown } = { current: undefined } + const recorder: ScanRecorder = { + proxy: null as unknown as object, + holder, + recorded: new Set(), + fallback: false, + } + + recorder.proxy = new Proxy({} as Record, { + get(_obj, prop) { + const current = holder.current as Record + if (typeof prop === 'symbol') return current[prop] + recorder.recorded.add(prop) + const value = current[prop] + if (value !== null && typeof value === 'object') { + // Nested object read — column signals only cover depth-1 property + // values. Fall back to the coarse array signal for correctness. + recorder.fallback = true + } + return value + }, + has(_obj, prop) { + if (typeof prop === 'string') recorder.recorded.add(prop) + return Reflect.has(holder.current as object, prop) + }, + ownKeys() { + recorder.fallback = true + return Reflect.ownKeys(holder.current as object) + }, + getOwnPropertyDescriptor(_obj, prop) { + recorder.fallback = true + const desc = Object.getOwnPropertyDescriptor( + holder.current as object, + prop, + ) + // Frozen state props are non-configurable; the recorder's dummy + // target doesn't have them, so report configurable to satisfy + // proxy invariants. + return desc ? { ...desc, configurable: true } : desc + }, + getPrototypeOf() { + return Object.getPrototypeOf(holder.current as object) + }, + set() { + return false + }, + deleteProperty() { + return false + }, + }) + + registerRecordingHolder(recorder.proxy, holder) + return recorder +} + +/** + * Present an element to a scan callback: objects go through the recorder + * proxy (so property reads are recorded), primitives pass through raw + * (and force coarse fallback since column tracking can't cover them). + * @param recorder - The scan recorder for this method call + * @param value - The raw element value + * @returns The value to pass to the user callback + */ +function presentElement(recorder: ScanRecorder, value: unknown): unknown { + if (value !== null && typeof value === 'object') { + recorder.holder.current = value + return recorder.proxy + } + recorder.fallback = true + return value +} + +/** + * Which structural change kinds each (method, outcome) pair depends on. + * + * Semantics per method (outcome = did the scan find a match?): + * - find/matched: first match at position i — appends can't precede it, + * removals of earlier elements are non-matching by definition, and + * removal of the match itself fires its element signals via prune. + * Only inserts/reorders before the match can change the result. + * - find/missed: a new element anywhere could become the first match. + * - findLast: appends can also change the result (new last match). + * - findIndex/matched: like find/matched, but removals shift the index. + * - findLastIndex/matched: appends and removals both shift the index. + * - filter: membership of the result changes with any structural change. + * - some/true: only removal of the witness can flip true→false + * (replacements fire remove too). Adds can't flip true. + * - some/false: a new element could flip false→true. + * - every/true: a new element could flip true→false. + * - every/false: only removal of the counterexample can flip false→true. + * + * Value changes on elements are covered separately by column signals. + * Errors in this table must err toward including a kind (over-firing). + */ +const STRUCTURE_DEPS: Record< + string, + { matched: readonly StructureKind[]; missed: readonly StructureKind[] } +> = { + find: { + matched: ['insertOrReorder'], + missed: ['append', 'insertOrReorder'], + }, + findLast: { + matched: ['append', 'insertOrReorder'], + missed: ['append', 'insertOrReorder'], + }, + findIndex: { + matched: ['insertOrReorder', 'remove'], + missed: ['append', 'insertOrReorder'], + }, + findLastIndex: { + matched: ['append', 'insertOrReorder', 'remove'], + missed: ['append', 'insertOrReorder'], + }, + filter: { + matched: ['append', 'insertOrReorder', 'remove'], + missed: ['append', 'insertOrReorder', 'remove'], + }, + some: { + matched: ['remove'], + missed: ['append', 'insertOrReorder'], + }, + every: { + // For every, "matched" = returned true (no counterexample found) + matched: ['append', 'insertOrReorder'], + // "missed" = returned false (counterexample exists) + missed: ['remove'], + }, +} + +/** + * After a scan completes, register the reactive dependencies it implies: + * column signals for each recorded property plus structural signals per + * the (method, outcome) matrix — or the coarse array signal when column + * tracking can't capture the callback's behavior. + * @param recorder - The scan recorder for this method call + * @param registry - Signal registry for dependency tracking + * @param parentPath - Path to the array in the state tree + * @param target - The raw frozen array + * @param method - The scan method that ran + * @param matched - Whether the scan found a match (see STRUCTURE_DEPS) + * @returns void + */ +function finalizeScanDeps( + recorder: ScanRecorder, + registry: PathSignalRegistry, + parentPath: string, + target: readonly unknown[], + method: OverriddenMethod, + matched: boolean, +): void { + if (recorder.fallback || recorder.recorded.size === 0) { + registry.getOrCreate(parentPath, target).get() + return + } + for (const prop of recorder.recorded) { + registry.trackColumn(parentPath, prop).get() + } + const kinds = STRUCTURE_DEPS[method] + if (kinds) { + const wanted = matched ? kinds.matched : kinds.missed + for (const kind of wanted) { + registry.trackStructure(parentPath, kind).get() + } + } +} + /** * Create an interceptor for an array method that operates on the raw frozen * array, bypassing per-element proxy creation. Only results that the caller * will actually use get wrapped in tracking proxies. * - * Registers a coarse dependency on the array itself so that changes to ANY - * element (even ones the callback scanned but didn't match) trigger re-evaluation. - * * @param target - The raw frozen array * @param proxy - The tracking proxy wrapping this array (used to return proxied elements) * @param method - The method name being intercepted @@ -105,49 +307,66 @@ export function createArrayMethodInterceptor( return function intercepted(...args: unknown[]): unknown { const m = method as OverriddenMethod - // Register a dependency on the array itself. Since callbacks receive - // raw (unproxied) elements, per-element signals aren't created during - // the scan. This coarse signal ensures re-evaluation when any element - // changes. diffArray fires registry.update(parentPath, ...) for every - // visited array, so this signal will fire on any descendant change. - registry.getOrCreate(parentPath, target).get() + // --- Callback-driven scans: record property reads, register column signals --- - // --- Subset operations: scan raw, return proxied results --- - - if (m === 'filter') { - const predicate = args[0] as ( + if (CALLBACK_METHODS.has(m)) { + const callback = args[0] as ( value: unknown, index: number, array: readonly unknown[], ) => boolean - const result: unknown[] = [] - for (let i = 0; i < target.length; i++) { - if (predicate(target[i], i, target)) { - // Access through proxy to register signals for matching elements - result.push((proxy as Record)[i]) + const recorder = createScanRecorder() + + let result: unknown + let matched: boolean + + if (m === 'filter') { + const matches: unknown[] = [] + for (let i = 0; i < target.length; i++) { + if (callback(presentElement(recorder, target[i]), i, target)) { + // Access through proxy to register signals for matching elements + matches.push((proxy as Record)[i]) + } + } + result = matches + matched = matches.length > 0 + } else if (FIND_METHODS.has(m)) { + const isForward = m === 'find' + const step = isForward ? 1 : -1 + const start = isForward ? 0 : target.length - 1 + result = undefined + for (let i = start; i >= 0 && i < target.length; i += step) { + if (callback(presentElement(recorder, target[i]), i, target)) { + // Return proxied element — registers signals for just this one + result = (proxy as Record)[i] + break + } + } + matched = result !== undefined + } else { + // findIndex / findLastIndex / some / every — primitive results + result = (target as any)[m]( + (value: unknown, i: number, arr: readonly unknown[]) => + callback(presentElement(recorder, value), i, arr), + ) + if (m === 'findIndex' || m === 'findLastIndex') { + matched = result !== -1 + } else { + // some: true = witness found; every: true = no counterexample + matched = result === true } } + + finalizeScanDeps(recorder, registry, parentPath, target, m, matched) return result } - if (FIND_METHODS.has(m)) { - const predicate = args[0] as ( - value: unknown, - index: number, - array: readonly unknown[], - ) => boolean - const isForward = m === 'find' - const step = isForward ? 1 : -1 - const start = isForward ? 0 : target.length - 1 - - for (let i = start; i >= 0 && i < target.length; i += step) { - if (predicate(target[i], i, target)) { - // Return proxied element — registers signals for just this one - return (proxy as Record)[i] - } - } - return undefined - } + // --- Non-callback methods depend on all elements: coarse array signal --- + // diffArray fires registry.update(parentPath, ...) for every visited + // array, so this signal fires on any descendant change. That's the + // right granularity: these methods read element identity or content + // wholesale (includes/indexOf compare refs, join/concat serialize all). + registry.getOrCreate(parentPath, target).get() if (m === 'slice') { const rawStart = (args[0] as number) ?? 0 diff --git a/src/signals/diff.ts b/src/signals/diff.ts index 54f7f0fa0..241955234 100644 --- a/src/signals/diff.ts +++ b/src/signals/diff.ts @@ -122,6 +122,72 @@ function diffArray( } } +/** + * Compare one surviving element's tracked column properties and fire + * column signals for any that changed. Non-object elements fire all + * remaining columns (can't shallow-compare — err toward firing). + * @param prevItem - Previous element value + * @param nextItem - Next element value + * @param parentPath - Path to the containing array + * @param registry - Signal registry to update + * @param cols - Tracked column property names for this array + * @param fired - Set of already-fired columns (dedup across elements) + * @returns void + */ +function bumpColumnsForElement( + prevItem: unknown, + nextItem: unknown, + parentPath: string, + registry: PathSignalRegistry, + cols: Set, + fired: Set, +): void { + if ( + prevItem !== null && + nextItem !== null && + typeof prevItem === 'object' && + typeof nextItem === 'object' + ) { + for (const prop of cols) { + if (fired.has(prop)) continue + if ( + (prevItem as Record)[prop] !== + (nextItem as Record)[prop] + ) { + registry.bumpColumn(parentPath, prop) + fired.add(prop) + } + } + } else { + // Non-object element changed — can't shallow-compare, fire everything + for (const prop of cols) { + if (!fired.has(prop)) { + registry.bumpColumn(parentPath, prop) + fired.add(prop) + } + } + } +} + +/** + * Fire every tracked column signal for an array. Used when elements + * can't be aligned between prev and next (unkeyed length changes). + * @param parentPath - Path to the array + * @param registry - Signal registry to update + * @param cols - Tracked column property names, if any + * @returns void + */ +function bumpAllColumns( + parentPath: string, + registry: PathSignalRegistry, + cols: Set | undefined, +): void { + if (!cols) return + for (const prop of cols) { + registry.bumpColumn(parentPath, prop) + } +} + /** * Walk prev and next state trees, updating path signals for changed values. * Only visits subtrees that have tracked signals (registered paths). @@ -191,8 +257,13 @@ function diffArrayByKey( ): void { const { keyField, entityMap: prevEntityMap } = meta + const cols = registry.getTrackedColumns(parentPath) + const fired = cols && cols.size > 0 ? new Set() : null + const structs = registry.getTrackedStructures(parentPath) + // Fast path: same-length arrays with no key shifts. // Only touch changed elements. Update entityMap incrementally instead of rebuilding. + // Pure value changes — no structural bumps needed. if (prev.length === next.length) { let usedFastPath = true for (let i = 0; i < next.length; i++) { @@ -211,6 +282,9 @@ function diffArrayByKey( if (registry.hasPrefix(identityPath)) { diffAndUpdateSignals(prev[i], next[i], identityPath, registry) } + if (fired && cols) { + bumpColumnsForElement(prev[i], next[i], parentPath, registry, cols, fired) + } prevEntityMap.set(nextKv, next[i]) } if (usedFastPath) { @@ -235,7 +309,12 @@ function diffArrayByKey( // If we skipped the entire overlap and next is longer, it's a pure append. // No Map needed — just update entityMap for new tail entries. + // Overlap is identical, so no value changes are possible — membership + // change is covered by the `append` structural signal alone. if (startIdx === minLen && next.length >= prev.length) { + if (structs && next.length > prev.length) { + registry.bumpStructure(parentPath, 'append') + } for (let i = startIdx; i < next.length; i++) { const nextItem = next[i] const kv = getKeyValue(nextItem, keyField) @@ -253,6 +332,9 @@ function diffArrayByKey( // If we skipped the entire overlap and prev is longer, it's a pure truncation. // Prune the removed tail entries from entityMap. if (startIdx === minLen && prev.length > next.length) { + if (structs) { + registry.bumpStructure(parentPath, 'remove') + } for (let i = startIdx; i < prev.length; i++) { const prevItem = prev[i] const kv = getKeyValue(prevItem, keyField) @@ -284,23 +366,72 @@ function diffArrayByKey( } } + // Structural classification state (only maintained when tracked): + // - addedAny: any element whose key wasn't in prev + // - middleInsert: an added element appeared before a surviving element + // - reorder: survivors are not a subsequence of prev's order + // (checked with a forward-only pointer into prev) + // - survivorCount: next elements (from startIdx) whose key existed in prev + // - sawUnkeyed: element without a key — can't classify, fire everything + let addedAny = false + let middleInsert = false + let reorder = false + let survivorCount = 0 + let sawUnkeyed = false + let pi = startIdx + for (let i = startIdx; i < next.length; i++) { const nextItem = next[i] const kv = getKeyValue(nextItem, keyField) if (kv === undefined) { + sawUnkeyed = true const childPath = parentPath ? parentPath + '.' + i : String(i) - if (registry.hasPrefix(childPath)) { - const prevItem = i < prev.length ? prev[i] : undefined - if (prevItem !== nextItem) { + const prevItem = i < prev.length ? prev[i] : undefined + if (prevItem !== nextItem) { + if (registry.hasPrefix(childPath)) { diffAndUpdateSignals(prevItem, nextItem, childPath, registry) } + if (fired && cols) { + bumpColumnsForElement( + prevItem, + nextItem, + parentPath, + registry, + cols, + fired, + ) + } } continue } nextEntityMap.set(kv, nextItem) const prevItem = prevEntityMap.get(kv) + const isSurvivor = prevItem !== undefined + + if (isSurvivor) { + survivorCount++ + if (addedAny) middleInsert = true + if (structs && !reorder) { + // Subsequence check: survivors must appear in prev in the same + // relative order. Advance pointer to this key; if we run off the + // end, order changed. + while ( + pi < prev.length && + getKeyValue(prev[pi], keyField) !== kv + ) { + pi++ + } + if (pi >= prev.length) { + reorder = true + } else { + pi++ + } + } + } else { + addedAny = true + } if (prevItem === nextItem) { if (seenPrevKeys) seenPrevKeys.add(kv) @@ -311,10 +442,20 @@ function diffArrayByKey( const identityPath = buildIdentityPath(parentPath, keyField, kv) - if (prevItem !== undefined) { + if (isSurvivor) { if (registry.hasPrefix(identityPath)) { diffAndUpdateSignals(prevItem, nextItem, identityPath, registry) } + if (fired && cols) { + bumpColumnsForElement( + prevItem, + nextItem, + parentPath, + registry, + cols, + fired, + ) + } } else { if (registry.hasPrefix(identityPath)) { diffAndUpdateSignals(undefined, nextItem, identityPath, registry) @@ -332,6 +473,23 @@ function diffArrayByKey( } meta.entityMap = nextEntityMap + + if (structs) { + // Removals: prev suffix elements not matched by a surviving key. + // Over-counts in rare duplicate-key cases — errs toward firing. + const removedAny = prev.length - startIdx > survivorCount + if (sawUnkeyed) { + registry.bumpStructure(parentPath, 'append') + registry.bumpStructure(parentPath, 'insertOrReorder') + registry.bumpStructure(parentPath, 'remove') + } else { + if (addedAny) registry.bumpStructure(parentPath, 'append') + if (middleInsert || reorder) { + registry.bumpStructure(parentPath, 'insertOrReorder') + } + if (removedAny) registry.bumpStructure(parentPath, 'remove') + } + } } /** @@ -349,6 +507,22 @@ function diffArrayByIndex( parentPath: string, registry: PathSignalRegistry, ): void { + const cols = registry.getTrackedColumns(parentPath) + const fired = cols && cols.size > 0 ? new Set() : null + + if (prev.length !== next.length) { + // Unkeyed length change: elements can't be aligned between prev and + // next, so we can't classify the change — fire all structural kinds + // and all columns (err toward firing). + const structs = registry.getTrackedStructures(parentPath) + if (structs) { + registry.bumpStructure(parentPath, 'append') + registry.bumpStructure(parentPath, 'insertOrReorder') + registry.bumpStructure(parentPath, 'remove') + } + bumpAllColumns(parentPath, registry, cols) + } + const minLen = Math.min(prev.length, next.length) for (let i = 0; i < minLen; i++) { if (prev[i] !== next[i]) { @@ -356,6 +530,18 @@ function diffArrayByIndex( if (registry.hasPrefix(childPath)) { diffAndUpdateSignals(prev[i], next[i], childPath, registry) } + // Same-length: aligned compare per changed index covers value + // changes AND swaps (both indexes show ref mismatches). + if (fired && cols && prev.length === next.length) { + bumpColumnsForElement( + prev[i], + next[i], + parentPath, + registry, + cols, + fired, + ) + } } } for (let i = minLen; i < next.length; i++) { diff --git a/src/signals/pathSignalRegistry.ts b/src/signals/pathSignalRegistry.ts index c0b637c9a..6fe8a6b45 100644 --- a/src/signals/pathSignalRegistry.ts +++ b/src/signals/pathSignalRegistry.ts @@ -2,6 +2,15 @@ import type { ArrayMeta } from './arrayKeys' import type { ProxyCache } from './trackingProxy' import type { PathKey, ReactiveSignal, SignalEngine } from './types' +/** + * Kinds of structural array changes that scans can depend on selectively. + * - `append`: elements added after all surviving elements + * - `insertOrReorder`: elements inserted before surviving elements, or + * surviving elements changed relative order + * - `remove`: elements removed (relative order of survivors preserved) + */ +export type StructureKind = 'append' | 'insertOrReorder' | 'remove' + export interface PathSignalRegistry { /** Get or create a signal for a path. Object/array values use version counters. */ getOrCreate(pathKey: PathKey, currentValue: unknown): ReactiveSignal @@ -38,6 +47,51 @@ export interface PathSignalRegistry { /** Set/update array metadata for identity-based tracking. */ setArrayMeta(arrayPath: string, meta: ArrayMeta): void + + /** Register a column signal for an array: a dependency on property `prop` + * of ANY element of the array at `arrayPath`. Fired by diff when any + * element's value for that property changes (or membership changes). */ + trackColumn(arrayPath: string, prop: string): ReactiveSignal + + /** Get the set of tracked column properties for an array path. */ + getTrackedColumns(arrayPath: string): Set | undefined + + /** Fire the column signal for (arrayPath, prop), if one exists. */ + bumpColumn(arrayPath: string, prop: string): void + + /** Register a structural signal for an array: a dependency on a specific + * kind of membership/order change (append, insertOrReorder, remove). + * Fired selectively by diff based on how the array actually changed. */ + trackStructure(arrayPath: string, kind: StructureKind): ReactiveSignal + + /** Get the set of tracked structure kinds for an array path. */ + getTrackedStructures(arrayPath: string): Set | undefined + + /** Fire the structural signal for (arrayPath, kind), if one exists. */ + bumpStructure(arrayPath: string, kind: StructureKind): void +} + +/** + * Build the path key for a column signal. + * Uses `{*}` as the element segment — it can never collide with real + * object keys or identity paths (`{keyField:value}`). + * @param arrayPath - Path to the array in the state tree + * @param prop - Element property name being tracked + * @returns The column signal path key + */ +function buildColumnPath(arrayPath: string, prop: string): string { + return arrayPath + '.{*}.' + prop +} + +/** + * Build the path key for a structural signal. + * Uses `@@` segments (like `@@keys`) that can't collide with real keys. + * @param arrayPath - Path to the array in the state tree + * @param kind - The structural change kind + * @returns The structural signal path key + */ +function buildStructurePath(arrayPath: string, kind: StructureKind): string { + return arrayPath + '.@@' + kind } function isObjectOrArray(v: unknown): v is object { @@ -100,6 +154,12 @@ export function createPathSignalRegistry( // Per-array metadata for identity-based tracking. // Maps array path → ArrayMeta (keyField + entityMap). const arrayMetas = new Map() + // Tracked column properties per array path. + // Lets diffArray know which element properties to shallow-compare. + const columnsByArray = new Map>() + // Tracked structural change kinds per array path. + // Lets diffArray know whether structural classification is needed. + const structuresByArray = new Map>() // Register a path in the parent→children index. // Only links to immediate parent: "a.b.c" → childIndex["a.b"].add("a.b.c") @@ -117,7 +177,7 @@ export function createPathSignalRegistry( children.add(pathKey) } - return { + const registry: PathSignalRegistry = { getOrCreate(pathKey: PathKey, currentValue: unknown): ReactiveSignal { let sig = signals.get(pathKey) if (!sig) { @@ -161,6 +221,9 @@ export function createPathSignalRegistry( const stack: string[] = [pathKey] while (stack.length > 0) { const key = stack.pop()! + // Drop column/structure tracking for any array at or below the pruned path + columnsByArray.delete(key) + structuresByArray.delete(key) // Push children onto stack before deleting const children = childIndex.get(key) if (children) { @@ -224,5 +287,55 @@ export function createPathSignalRegistry( setArrayMeta(arrayPath: string, meta: ArrayMeta): void { arrayMetas.set(arrayPath, meta) }, + + trackColumn(arrayPath: string, prop: string): ReactiveSignal { + let cols = columnsByArray.get(arrayPath) + if (!cols) { + cols = new Set() + columnsByArray.set(arrayPath, cols) + } + cols.add(prop) + // Register the intermediate `{*}` segment so prune(arrayPath) + // reaches column signals through the child index. + registry.ensurePrefix(arrayPath + '.{*}') + return registry.getOrCreate(buildColumnPath(arrayPath, prop), 0) + }, + + getTrackedColumns(arrayPath: string): Set | undefined { + return columnsByArray.get(arrayPath) + }, + + bumpColumn(arrayPath: string, prop: string): void { + const sig = signals.get(buildColumnPath(arrayPath, prop)) + if (!sig) return + const current = sig.get() + sig.set(typeof current === 'number' ? current + 1 : 0) + }, + + trackStructure( + arrayPath: string, + kind: StructureKind, + ): ReactiveSignal { + let kinds = structuresByArray.get(arrayPath) + if (!kinds) { + kinds = new Set() + structuresByArray.set(arrayPath, kinds) + } + kinds.add(kind) + return registry.getOrCreate(buildStructurePath(arrayPath, kind), 0) + }, + + getTrackedStructures(arrayPath: string): Set | undefined { + return structuresByArray.get(arrayPath) + }, + + bumpStructure(arrayPath: string, kind: StructureKind): void { + const sig = signals.get(buildStructurePath(arrayPath, kind)) + if (!sig) return + const current = sig.get() + sig.set(typeof current === 'number' ? current + 1 : 0) + }, } + + return registry } diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index ce1b77986..42693a362 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -23,6 +23,28 @@ const proxyPathMap = new WeakMap() */ const proxyTargetMap = new WeakMap() +/** + * Maps scan-recorder proxies (used by array method callbacks) to their + * mutable holders. A recorder proxy is reused across all elements of a + * scan, reading from `holder.current`. unwrap() consults this map so + * identity comparisons inside callbacks can be unwrapped correctly. + */ +const recordingHolders = new WeakMap() + +/** + * Register a scan-recorder proxy so unwrap() can resolve it to the + * element currently being scanned. + * @param proxy - The recorder proxy passed to array method callbacks + * @param holder - Mutable holder whose `current` is the raw element + * @returns void + */ +export function registerRecordingHolder( + proxy: object, + holder: { current: unknown }, +): void { + recordingHolders.set(proxy, holder) +} + /** * Tracks which object-typed proxy accesses are "leaf" accesses — * objects that were read by the selector but never had their properties @@ -81,7 +103,10 @@ export function getProxyPath(value: unknown): string | undefined { */ export function unwrap(value: T): T { if (value !== null && typeof value === 'object') { - return (proxyTargetMap.get(value as object) as T) ?? value + const target = proxyTargetMap.get(value as object) + if (target !== undefined) return target as T + const holder = recordingHolders.get(value as object) + if (holder !== undefined) return holder.current as T } return value } @@ -132,6 +157,13 @@ export function createTrackingProxy( // Functions: intercept array methods to avoid per-element proxy creation if (typeof value === 'function') { if (Array.isArray(target) && isOverriddenArrayMethod(prop as string)) { + // Mark the array as traversed: the interceptor registers its own + // precise dependencies (column/structural signals or coarse + // fallback), so the leaf tracker must NOT also subscribe to the + // array's version signal (which fires on every array change). + if (leafTracker) { + leafTracker.traversedPaths.add(parentPath) + } return createArrayMethodInterceptor(target, proxy, prop as string, registry, parentPath) } return value @@ -193,13 +225,12 @@ export function createTrackingProxy( } // Primitive leaf: read signal to establish dependency. - // Also read the immediate parent's version signal so that identity - // changes to the parent object (ref swaps) are tracked. Skip for - // root-level primitives (parentPath === '') since the root object - // changes on every dispatch and would defeat the optimization. - if (parentPath !== '') { - registry.getOrCreate(parentPath, target).get() - } + // Note: we intentionally do NOT subscribe to the parent object's + // version signal here. Doing so causes sibling fan-out (any sibling + // change fires the parent version under Immer structural sharing) + // and zombie-subscription cascades after prune. Parent deletion is + // covered by prune firing this leaf signal, and key reappearance is + // covered by diff's added-key handling firing the leaf path. registry.getOrCreate(pathKey, value).get() // Return the actual value diff --git a/test/signals/arrayMethodOverrides.spec.ts b/test/signals/arrayMethodOverrides.spec.ts index bb45a71ec..b6c2bec35 100644 --- a/test/signals/arrayMethodOverrides.spec.ts +++ b/test/signals/arrayMethodOverrides.spec.ts @@ -2,7 +2,7 @@ import { describe, test, expect, vi } from 'vitest' import { alienEngine } from '@internal/signals/engine' import { createPathSignalRegistry } from '@internal/signals/pathSignalRegistry' import { createTrackingProxy, type ProxyCache } from '@internal/signals/trackingProxy' -import { getProxyPath } from '@internal/signals/trackingProxy' +import { getProxyPath, unwrap } from '@internal/signals/trackingProxy' function deepFreeze(obj: T): T { if (obj === null || typeof obj !== 'object') return obj @@ -52,20 +52,24 @@ describe('Array method overrides on tracking proxy', () => { expect(found).toBeUndefined() }) - test('callback receives raw values, not proxies', () => { + test('callback receives scan recorder, unwrap resolves raw element', () => { const state = createTestData() const { proxy } = setup(state) - const receivedValues: unknown[] = [] + const unwrapped: unknown[] = [] + let callCount = 0 proxy.items.find((item) => { - receivedValues.push(item) + callCount++ + // Not a tracking proxy (no path registered) + expect(getProxyPath(item)).toBeUndefined() + // unwrap resolves to the raw frozen element + unwrapped.push(unwrap(item)) return false // never match, scan all }) - // Callbacks should receive raw frozen objects, not proxies - for (const val of receivedValues) { - expect(getProxyPath(val)).toBeUndefined() - expect(Object.isFrozen(val)).toBe(true) + expect(callCount).toBe(5) + for (let i = 0; i < 5; i++) { + expect(unwrapped[i]).toBe(state.items[i]) + expect(Object.isFrozen(unwrapped[i])).toBe(true) } - expect(receivedValues).toHaveLength(5) }) test('only registers signals for the found element, not all scanned elements', () => { @@ -184,14 +188,17 @@ describe('Array method overrides on tracking proxy', () => { expect(registry.has('items.{id:3}.value')).toBe(false) }) - test('callback receives raw values', () => { + test('callback receives scan recorder, unwrap resolves raw element', () => { const state = createTestData() const { proxy } = setup(state) + let i = 0 proxy.items.findIndex((item) => { expect(getProxyPath(item)).toBeUndefined() - expect(Object.isFrozen(item)).toBe(true) + expect(unwrap(item)).toBe(state.items[i]) + i++ return false }) + expect(i).toBe(5) }) }) @@ -232,18 +239,16 @@ describe('Array method overrides on tracking proxy', () => { } }) - test('callback receives raw values, not proxies', () => { + test('callback receives scan recorder, not tracking proxies', () => { const state = createTestData() const { proxy } = setup(state) - const receivedValues: unknown[] = [] + const unwrapped: unknown[] = [] proxy.items.filter((item) => { - receivedValues.push(item) + expect(getProxyPath(item)).toBeUndefined() + unwrapped.push(unwrap(item)) return false }) - for (const val of receivedValues) { - expect(getProxyPath(val)).toBeUndefined() - expect(Object.isFrozen(val)).toBe(true) - } + expect(unwrapped).toEqual([...state.items]) }) test('only registers signals for matching elements', () => { diff --git a/test/signals/diff.spec.ts b/test/signals/diff.spec.ts index b618a82df..2a8316103 100644 --- a/test/signals/diff.spec.ts +++ b/test/signals/diff.spec.ts @@ -180,12 +180,12 @@ describe('diffAndUpdateSignals', () => { // Leaf signal should be updated expect(registry.getOrCreate('a.b.c.d', 'changed').get()).toBe('changed') - // Non-immediate ancestors have no signals (only prefix registrations) + // Intermediate objects have no signals (only prefix registrations) + // They don't get version bumps — that's the optimization expect(registry.has('a')).toBe(false) expect(registry.has('a.b')).toBe(false) - // Immediate parent of leaf gets a version signal (identity tracking) - expect(registry.has('a.b.c')).toBe(true) - // Prefix index knows about all ancestors + expect(registry.has('a.b.c')).toBe(false) + // But prefix index knows about them expect(registry.hasPrefix('a')).toBe(true) expect(registry.hasPrefix('a.b')).toBe(true) expect(registry.hasPrefix('a.b.c')).toBe(true) diff --git a/test/signals/trackingProxy.spec.ts b/test/signals/trackingProxy.spec.ts index 11320d7d1..bf76278d4 100644 --- a/test/signals/trackingProxy.spec.ts +++ b/test/signals/trackingProxy.spec.ts @@ -319,14 +319,12 @@ describe('createTrackingProxy', () => { proxy.a.b.c - // Non-immediate ancestors only have prefix registrations, no signals + // Intermediate objects (a, a.b) only have prefix registrations, no signals expect(registry.has('a')).toBe(false) + expect(registry.has('a.b')).toBe(false) expect(registry.hasPrefix('a')).toBe(true) expect(registry.hasPrefix('a.b')).toBe(true) - // Immediate parent of leaf gets a version signal (for identity tracking) - expect(registry.has('a.b')).toBe(true) - // Leaf has a signal with the actual value expect(registry.has('a.b.c')).toBe(true) const sigABC = registry.getOrCreate('a.b.c', 42) @@ -426,8 +424,8 @@ describe('createTrackingProxy', () => { proxy1.user.profile.name proxy2.user.profile.name - // Leaf + its immediate parent get signals - expect(registry.size()).toBe(2) // user.profile (parent version) + user.profile.name (leaf) + // Only the leaf creates a signal; user and user.profile are prefix-only + expect(registry.size()).toBe(1) // user.profile.name (leaf) }) it('child proxies from different root proxies track same signal paths', () => { @@ -798,9 +796,10 @@ describe('unwrap', () => { const proxiedItem = proxy.items[1] const rawItem = unwrap(proxiedItem) - // find callback receives raw elements; unwrapped proxy === raw element + // find callback receives a scan recorder proxy; unwrap it for + // identity comparison against raw state values const found = proxy.items.find( - (item: { id: number; name: string }) => item === rawItem, + (item: { id: number; name: string }) => unwrap(item) === rawItem, ) expect(found).toBeDefined() @@ -1259,4 +1258,409 @@ describe('array method dependency tracking', () => { stop() }) + + // ─── Structural signal matrix (append / insertOrReorder / remove) ─── + // Each scan method subscribes to a specific subset of structural signals + // depending on whether it matched. These tests verify both the "should + // re-run" and "should NOT re-run" cells of the matrix. + + describe('structural signal matrix', () => { + /** + * Append an item with structural sharing (existing item refs reused). + * @param state - Current state + * @param item - New item to append + * @returns New frozen state + */ + function appendItem(state: TodoState, item: TodoItem): TodoState { + return Object.freeze({ + items: Object.freeze([...state.items, Object.freeze(item)]), + }) as TodoState + } + + /** + * Remove an item by id with structural sharing. + * @param state - Current state + * @param id - ID of item to remove + * @returns New frozen state + */ + function removeItem(state: TodoState, id: number): TodoState { + return Object.freeze({ + items: Object.freeze(state.items.filter(i => i.id !== id)), + }) as TodoState + } + + /** + * Insert an item at an index with structural sharing. + * @param state - Current state + * @param index - Position to insert at + * @param item - New item + * @returns New frozen state + */ + function insertItem(state: TodoState, index: number, item: TodoItem): TodoState { + const items = [...state.items] + items.splice(index, 0, Object.freeze(item) as TodoItem) + return Object.freeze({ items: Object.freeze(items) }) as TodoState + } + + it('find (matched) — does NOT re-run on append', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: true }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.find(x => x.done)?.text, + registry, + ) + + expect(c.get()).toBe('b') + expect(getCallCount()).toBe(1) + + // Append can never change the FIRST match — no re-run + const prevState = state + state = appendItem(state, { id: 3, text: 'c', done: true }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('b') + expect(getCallCount()).toBe(1) + + stop() + }) + + it('find (matched) — does NOT re-run when a later element is removed', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: true }, + { id: 3, text: 'c', done: false }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.find(x => x.done)?.text, + registry, + ) + + expect(c.get()).toBe('b') + expect(getCallCount()).toBe(1) + + // Removing a later (never-matching, never-returned) element can't + // change the first match — no re-run + const prevState = state + state = removeItem(state, 3) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('b') + expect(getCallCount()).toBe(1) + + stop() + }) + + it('find (matched) — SHOULD re-run when a matching element is inserted before the match', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 3, text: 'c', done: true }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.find(x => x.done)?.text, + registry, + ) + + expect(c.get()).toBe('c') + expect(getCallCount()).toBe(1) + + // Insert a done item BEFORE the current match — new first match + const prevState = state + state = insertItem(state, 1, { id: 2, text: 'b', done: true }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('b') + expect(getCallCount()).toBe(2) + + stop() + }) + + it('find (matched) — does NOT re-run when an untracked prop of a non-matched element changes', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: true }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.find(x => x.done)?.text, + registry, + ) + + expect(c.get()).toBe('b') + expect(getCallCount()).toBe(1) + + // Only `done` is a tracked column; `text` of a non-matched element + // isn't read anywhere — no re-run + const prevState = state + state = updateItem(state, 1, { text: 'a-changed' }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('b') + expect(getCallCount()).toBe(1) + + stop() + }) + + it('findIndex (matched) — SHOULD re-run when an earlier element is removed (index shifts)', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: true }, + { id: 3, text: 'c', done: false }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.findIndex(x => x.done), + registry, + ) + + expect(c.get()).toBe(1) + expect(getCallCount()).toBe(1) + + // Removing an earlier element shifts the matched index from 1 → 0 + const prevState = state + state = removeItem(state, 1) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe(0) + expect(getCallCount()).toBe(2) + + stop() + }) + + it('some (true) — SHOULD re-run when the witness element is removed', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: true }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.some(x => x.done), + registry, + ) + + expect(c.get()).toBe(true) + expect(getCallCount()).toBe(1) + + // Removing the only done element flips some() to false + const prevState = state + state = removeItem(state, 2) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe(false) + expect(getCallCount()).toBe(2) + + stop() + }) + + it('some (true) — does NOT re-run on append', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: true }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.some(x => x.done), + registry, + ) + + expect(c.get()).toBe(true) + expect(getCallCount()).toBe(1) + + // Appends can never flip some() from true to false — no re-run + const prevState = state + state = appendItem(state, { id: 2, text: 'b', done: false }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe(true) + expect(getCallCount()).toBe(1) + + stop() + }) + + it('every (true) — SHOULD re-run on append (new element might fail predicate)', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: true }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.every(x => x.done), + registry, + ) + + expect(c.get()).toBe(true) + expect(getCallCount()).toBe(1) + + const prevState = state + state = appendItem(state, { id: 2, text: 'b', done: false }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe(false) + expect(getCallCount()).toBe(2) + + stop() + }) + + it('every (true) — does NOT re-run when an element is removed', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: true }, + { id: 2, text: 'b', done: true }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.every(x => x.done), + registry, + ) + + expect(c.get()).toBe(true) + expect(getCallCount()).toBe(1) + + // Removing an element can't flip every() from true to false — no re-run + const prevState = state + state = removeItem(state, 2) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe(true) + expect(getCallCount()).toBe(1) + + stop() + }) + + it('every (false) — SHOULD re-run when the counterexample is removed', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: true }, + { id: 2, text: 'b', done: false }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.every(x => x.done), + registry, + ) + + expect(c.get()).toBe(false) + expect(getCallCount()).toBe(1) + + // Removing the only not-done element flips every() to true + const prevState = state + state = removeItem(state, 2) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe(true) + expect(getCallCount()).toBe(2) + + stop() + }) + + it('filter — SHOULD re-run when a matching element is removed', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: true }, + { id: 2, text: 'b', done: false }, + { id: 3, text: 'c', done: true }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.filter(x => x.done).map(x => x.text).join(','), + registry, + ) + + expect(c.get()).toBe('a,c') + expect(getCallCount()).toBe(1) + + const prevState = state + state = removeItem(state, 3) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('a') + expect(getCallCount()).toBe(2) + + stop() + }) + + it('filter — SHOULD re-run on append', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: true }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.filter(x => x.done).map(x => x.text).join(','), + registry, + ) + + expect(c.get()).toBe('a') + expect(getCallCount()).toBe(1) + + const prevState = state + state = appendItem(state, { id: 2, text: 'b', done: true }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('a,b') + expect(getCallCount()).toBe(2) + + stop() + }) + + it('find (missed) — SHOULD re-run on append', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + ]) + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.find(x => x.done)?.text ?? 'none', + registry, + ) + + expect(c.get()).toBe('none') + expect(getCallCount()).toBe(1) + + const prevState = state + state = appendItem(state, { id: 2, text: 'b', done: true }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('b') + expect(getCallCount()).toBe(2) + + stop() + }) + + it('primitive elements fall back to coarse array signal (any change re-runs)', () => { + type NumState = { nums: number[] } + let state = Object.freeze({ + nums: Object.freeze([1, 2, 3]), + }) as NumState + const registry = makeRegistry() + + const { computed: c, getCallCount, stop } = createSelectorComputed( + () => state, + (s: NumState) => s.nums.find(x => x > 2), + registry, + ) + + expect(c.get()).toBe(3) + expect(getCallCount()).toBe(1) + + // Primitive scans use the coarse array signal — any array change re-runs + const prevState = state + state = Object.freeze({ nums: Object.freeze([1, 2, 3, 4]) }) as NumState + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe(3) + expect(getCallCount()).toBe(2) + + stop() + }) + }) }) From 9858c6a084b2a9cfb426b89c853429ceb1f801cf Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 26 Jul 2026 13:15:32 -0400 Subject: [PATCH 26/95] Fix effect scope teardown in useSignalSelector (use scope.stop()) --- src/signals/useSignalSelector.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index 919fc9369..74c87e195 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -39,7 +39,6 @@ export function useSignalSelector( let currentResult: R let version = 0 let notifyReact: (() => void) | null = null - let effectDispose: (() => void) | null | void = null // Create a computed that runs the selector through a tracking proxy. // This establishes signal dependencies on the paths the selector reads. @@ -102,8 +101,11 @@ export function useSignalSelector( // Create an effect that fires when the computed value changes. // We apply the user's equalityFn here since alien-signals // doesn't support custom equality per-computed. + // The effect lives inside a scope so unsubscribe can dispose it — + // engine.effect() itself returns void, so the scope is the only + // way to tear down the subscription. const scope = engine.createScope() - effectDispose = scope.run(() => { + scope.run(() => { let isFirst = true return engine.effect(() => { const newValue = selectorComputed.get() @@ -125,8 +127,7 @@ export function useSignalSelector( return () => { notifyReact = null - effectDispose?.() - effectDispose = null + scope.stop() } }, From 02a833e39d7ee87e6ca911f37f8a8fd2f2390cfc Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 28 Jul 2026 21:17:55 -0400 Subject: [PATCH 27/95] Use coarse array signals for primitive array elements --- src/signals/trackingProxy.ts | 34 ++++++++++++++++++++++ test/signals/diff.spec.ts | 14 +++++---- test/signals/identityArrayTracking.spec.ts | 18 ++++++++---- test/signals/trackingProxy.spec.ts | 13 +++++---- 4 files changed, 62 insertions(+), 17 deletions(-) diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index 42693a362..a1b98fb84 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -9,6 +9,15 @@ function isObjectOrArray(v: unknown): v is object { return v !== null && typeof v === 'object' } +// Fast check for array-index-like property names ('0', '42', ...). +// Canonical array index strings always start with a digit; named array +// props ('length', 'includes', ...) never do. Cheaper than Number()+isNaN +// on the hottest trap path (element scans). +function isIndexProp(prop: string): boolean { + const c = prop.charCodeAt(0) + return c >= 48 && c <= 57 +} + /** * Maps proxy objects to their path keys. * Used by useSignalSelector to detect when a selector returns a proxy (object) @@ -169,6 +178,31 @@ export function createTrackingProxy( return value } + // Primitive array elements: subscribe to the array's version signal + // instead of creating one signal per index. Primitive arrays (number + // history buffers, id lists, etc.) can be huge — per-index signals + // cost O(N) creation at mount for little precision benefit. The + // array version fires on any array change (diffArray always bumps + // it), so this over-fires only when a selector reads a subset of a + // primitive array — consistent with erring toward firing. + // Checked BEFORE building pathKey: element scans are the hottest + // path through this trap, and the coarse signal only needs the + // (stable, hash-memoized) parentPath string — no per-read allocs. + // Root arrays (parentPath === '') keep per-index signals since the + // root version signal is never fired by diff. + if ( + Array.isArray(target) && + parentPath !== '' && + isIndexProp(prop as string) && + !isObjectOrArray(value) + ) { + if (leafTracker) { + leafTracker.traversedPaths.add(parentPath) + } + registry.getOrCreate(parentPath, target).get() + return value + } + let pathKey = parentPath ? parentPath + '.' + (prop as string) : (prop as string) if (isObjectOrArray(value)) { diff --git a/test/signals/diff.spec.ts b/test/signals/diff.spec.ts index 2a8316103..740418cef 100644 --- a/test/signals/diff.spec.ts +++ b/test/signals/diff.spec.ts @@ -440,15 +440,17 @@ describe('diffAndUpdateSignals', () => { void (s.data as any)[1] }) - expect(registry.has('data.0')).toBe(true) - expect(registry.has('data.1')).toBe(true) + // Primitive array elements use a coarse signal on the array itself + expect(registry.has('data')).toBe(true) + expect(registry.has('data.0')).toBe(false) + + const dataSig = registry.getOrCreate('data', prev.data) + const before = dataSig.get() diffAndUpdateSignals(prev, next, '', registry) - // Array index signals should be pruned (type mismatch) - // The path 'data' gets updated, and since prev was array and next is object, - // only the leaf update path runs — no pruning of children in this case - // because both are objects. But array→object means different key structures. + // The coarse array signal fires on the array → object transition + expect(dataSig.get()).not.toBe(before) }) }) diff --git a/test/signals/identityArrayTracking.spec.ts b/test/signals/identityArrayTracking.spec.ts index 956b54291..2a3a7f052 100644 --- a/test/signals/identityArrayTracking.spec.ts +++ b/test/signals/identityArrayTracking.spec.ts @@ -215,15 +215,18 @@ describe('identity-based array tracking: proxy', () => { expect(registry.has('items.1.name')).toBe(true) }) - it('falls back to index path for primitive arrays', () => { + it('uses a coarse array signal for primitive arrays', () => { const state = deepFreeze({ tags: ['a', 'b', 'c'] }) const registry = setupRegistry(state, (s) => { s.tags[0] s.tags[1] }) - expect(registry.has('tags.0')).toBe(true) - expect(registry.has('tags.1')).toBe(true) + // Primitive elements don't get per-index signals — one coarse + // signal on the array covers all element reads. + expect(registry.has('tags')).toBe(true) + expect(registry.has('tags.0')).toBe(false) + expect(registry.has('tags.1')).toBe(false) }) it('creates ArrayMeta on first element access', () => { @@ -295,9 +298,12 @@ describe('identity-based array tracking: proxy', () => { s.matrix[1][1] }) - // Inner arrays are arrays, not objects — should use index paths - expect(registry.has('matrix.0.0')).toBe(true) - expect(registry.has('matrix.1.1')).toBe(true) + // Inner arrays use index paths (matrix.0, matrix.1); their primitive + // elements share a coarse signal on each inner array. + expect(registry.has('matrix.0')).toBe(true) + expect(registry.has('matrix.1')).toBe(true) + expect(registry.has('matrix.0.0')).toBe(false) + expect(registry.has('matrix.1.1')).toBe(false) }) }) diff --git a/test/signals/trackingProxy.spec.ts b/test/signals/trackingProxy.spec.ts index bf76278d4..89f19bb92 100644 --- a/test/signals/trackingProxy.spec.ts +++ b/test/signals/trackingProxy.spec.ts @@ -352,8 +352,9 @@ describe('createTrackingProxy', () => { // Identity-based: users array has objects with 'id', uses {id:1} expect(registry.hasPrefix('entities.users.{id:1}')).toBe(true) // intermediate (identity path) expect(registry.hasPrefix('entities.users.{id:1}.tags')).toBe(true) // intermediate - // tags is string array (no id), so uses index-based 0 - expect(registry.has('entities.users.{id:1}.tags.0')).toBe(true) // leaf + // tags is a primitive array — coarse signal on the array itself + expect(registry.has('entities.users.{id:1}.tags')).toBe(true) + expect(registry.has('entities.users.{id:1}.tags.0')).toBe(false) // Not accessed expect(registry.has('entities.users.{id:1}.id')).toBe(false) expect(registry.has('entities.users.{id:1}.tags.1')).toBe(false) @@ -388,9 +389,11 @@ describe('createTrackingProxy', () => { proxy.items[0] proxy.items[2] - expect(registry.has('items.0')).toBe(true) - expect(registry.has('items.2')).toBe(true) - expect(registry.has('items.1')).toBe(false) // skipped + // Primitive array elements use a single coarse signal on the array + // itself instead of per-index signals (mount cost optimization). + expect(registry.has('items')).toBe(true) + expect(registry.has('items.0')).toBe(false) + expect(registry.has('items.2')).toBe(false) }) it('nested iteration creates @@keys at each level', () => { From 92954746376e5feeb655baf3b22d54c3efa0c37a Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 28 Jul 2026 21:38:20 -0400 Subject: [PATCH 28/95] Reduce per-proxy allocation overhead (pathKey cache, reusable leaf tracker, direct effect dispose) --- src/signals/engine.ts | 4 +-- src/signals/trackingProxy.ts | 21 ++++++++--- src/signals/types.ts | 3 +- src/signals/useSignalSelector.ts | 60 ++++++++++++++++---------------- 4 files changed, 51 insertions(+), 37 deletions(-) diff --git a/src/signals/engine.ts b/src/signals/engine.ts index 36b63862f..09fa3979f 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -27,8 +27,8 @@ export const alienEngine: SignalEngine = { return { get: () => c() } }, - effect(fn: () => void): void { - alienEffect(fn) + effect(fn: () => void): () => void { + return alienEffect(fn) }, batch(fn: () => void): void { diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index a1b98fb84..b243778af 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -156,6 +156,20 @@ export function createTrackingProxy( // so that Array.isArray, instanceof, etc. work correctly. const shell = Array.isArray(target) ? [] : Object.create(Object.getPrototypeOf(target)) + // Per-proxy cache of prop -> full path string. parentPath is fixed for + // this proxy's lifetime, so path keys are stable. Reusing the same + // string instance avoids re-allocating on every read AND lets V8 cache + // the string hash, speeding up the registry Map lookups downstream. + const pathKeyCache = new Map() + function getPathKey(prop: string): string { + let key = pathKeyCache.get(prop) + if (key === undefined) { + key = parentPath ? parentPath + '.' + prop : prop + pathKeyCache.set(prop, key) + } + return key + } + const proxy = new Proxy(shell, { get(_obj, prop, _receiver) { // Symbols: read from actual target (iterator protocol, toStringTag, etc.) @@ -203,7 +217,7 @@ export function createTrackingProxy( return value } - let pathKey = parentPath ? parentPath + '.' + (prop as string) : (prop as string) + let pathKey = getPathKey(prop as string) if (isObjectOrArray(value)) { // For array element access: check if parent array has identity-based tracking. @@ -273,15 +287,14 @@ export function createTrackingProxy( // Track when selectors iterate keys (Object.keys, for...in, .map, .filter, etc.) ownKeys(_obj) { - const keysPath = parentPath ? parentPath + '.@@keys' : '@@keys' - registry.getOrCreate(keysPath, Reflect.ownKeys(target)).get() + registry.getOrCreate(getPathKey('@@keys'), Reflect.ownKeys(target)).get() return Reflect.ownKeys(target) }, // Track has() checks for conditional property access (e.g., 'key' in obj) has(_obj, prop) { if (typeof prop === 'symbol') return Reflect.has(target, prop) - const pathKey = parentPath ? parentPath + '.' + (prop as string) : (prop as string) + const pathKey = getPathKey(prop as string) registry.getOrCreate(pathKey, (target as Record)[prop]).get() return Reflect.has(target, prop) }, diff --git a/src/signals/types.ts b/src/signals/types.ts index e42791794..64474083c 100644 --- a/src/signals/types.ts +++ b/src/signals/types.ts @@ -12,7 +12,8 @@ export interface ReactiveComputed { export interface SignalEngine { signal(value: T): ReactiveSignal computed(fn: () => T): ReactiveComputed - effect(fn: () => void): void + /** Create an effect. Returns a dispose function that stops it. */ + effect(fn: () => void): () => void batch(fn: () => void): void createScope(): SignalScope } diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index 74c87e195..b27d5a706 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -47,16 +47,22 @@ export function useSignalSelector( // "false sharing" where siblings cause re-runs). Only leaf primitive reads // create deps automatically. If the selector returns a proxy (object result), // we explicitly read that object's signal to establish the terminal dependency. + // Track leaf object accesses for identity comparison support. + // Objects read by the selector but never traversed deeper are + // "leaf objects" — their identity matters (e.g., `a === b`). + // Reused across evaluations (cleared at the start of each) — a + // computed never re-enters its own evaluation, so this is safe and + // avoids two allocations per eval. + const leafTracker: LeafObjectTracker = { + accessedObjects: new Map(), + traversedPaths: new Set(), + } + const selectorComputed = engine.computed(() => { const state = store.getState() as S & object - // Track leaf object accesses for identity comparison support. - // Objects read by the selector but never traversed deeper are - // "leaf objects" — their identity matters (e.g., `a === b`). - const leafTracker: LeafObjectTracker = { - accessedObjects: new Map(), - traversedPaths: new Set(), - } + leafTracker.accessedObjects.clear() + leafTracker.traversedPaths.clear() const proxy = createTrackingProxy( state, @@ -101,33 +107,27 @@ export function useSignalSelector( // Create an effect that fires when the computed value changes. // We apply the user's equalityFn here since alien-signals // doesn't support custom equality per-computed. - // The effect lives inside a scope so unsubscribe can dispose it — - // engine.effect() itself returns void, so the scope is the only - // way to tear down the subscription. - const scope = engine.createScope() - scope.run(() => { - let isFirst = true - return engine.effect(() => { - const newValue = selectorComputed.get() - - if (isFirst) { - // First effect run — just establish tracking, don't notify - isFirst = false - return - } - - // Apply user's equality function - if (!equalityFnRef.current(currentResult, newValue)) { - currentResult = newValue - version++ - notifyReact?.() - } - }) + let isFirst = true + const dispose = engine.effect(() => { + const newValue = selectorComputed.get() + + if (isFirst) { + // First effect run — just establish tracking, don't notify + isFirst = false + return + } + + // Apply user's equality function + if (!equalityFnRef.current(currentResult, newValue)) { + currentResult = newValue + version++ + notifyReact?.() + } }) return () => { notifyReact = null - scope.stop() + dispose() } }, From 4c706126016d778783343e28a3d93d1bd5c0b3da Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 28 Jul 2026 23:37:34 -0400 Subject: [PATCH 29/95] Add tests for known edge cases --- test/signals/edgeCases.spec.ts | 375 +++++++++++++++++++++++++++ test/signals/edgeCasesReact.spec.tsx | 312 ++++++++++++++++++++++ 2 files changed, 687 insertions(+) create mode 100644 test/signals/edgeCases.spec.ts create mode 100644 test/signals/edgeCasesReact.spec.tsx diff --git a/test/signals/edgeCases.spec.ts b/test/signals/edgeCases.spec.ts new file mode 100644 index 000000000..2277450c5 --- /dev/null +++ b/test/signals/edgeCases.spec.ts @@ -0,0 +1,375 @@ +/** + * Edge case audit tests (unit level: proxy / registry / diff). + * + * Tests marked `it.fails` assert the DESIRED behavior for confirmed bugs — + * they pass while the bug exists and will flip to failing once the bug is + * fixed (at which point remove the `.fails` marker). + * + * Tests named "documented limitation: ..." assert CURRENT behavior that we + * accept and document rather than fix. + * + * See dev-plans research doc: 2026-07-28-edge-case-audit.md + */ +import { describe, it, expect } from 'vitest' +import { alienEngine } from '../../src/signals/engine' +import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import { + createTrackingProxy, + type LeafObjectTracker, +} from '../../src/signals/trackingProxy' +import { reconcileState } from '../../src/signals/diff' +import { buildIdentityPath } from '../../src/signals/arrayKeys' + +function createTracker(): LeafObjectTracker { + return { accessedObjects: new Map(), traversedPaths: new Set() } +} + +describe('edge cases: non-plain objects in state', () => { + // The proxy shell is Object.create(proto) with no internal slots. + // Prototype methods that need internal slots ([[DateValue]], [[MapData]], + // [[SetData]]) are returned raw and invoked with `this` = proxy → TypeError. + // Desired fix: treat non-plain objects as opaque leaves (return unproxied). + + it.fails('supports calling Date methods on a state Date', () => { + const createdAt = new Date(2020, 0, 1) + const state = { createdAt } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy.createdAt.getTime()).toBe(createdAt.getTime()) + }) + + it.fails('supports calling Map methods on a state Map', () => { + const state = { lookup: new Map([['a', 1]]) } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy.lookup.get('a')).toBe(1) + }) + + it.fails('supports calling Set methods on a state Set', () => { + const state = { tags: new Set(['x']) } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy.tags.has('x')).toBe(true) + }) + + it('instanceof checks work through the proxy (getPrototypeOf trap)', () => { + const state = { createdAt: new Date(2020, 0, 1) } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy.createdAt instanceof Date).toBe(true) + }) + + it('reads Map.size through the proxy (getter invoked on the real target)', () => { + const state = { lookup: new Map([['a', 1]]) } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy.lookup.size).toBe(1) + }) + + it('documented limitation: class instance children are not diffed (reference-only tracking)', () => { + class User { + constructor(public name: string) {} + } + const prev = { user: new User('Alice') } + const next = { user: new User('Bob') } + + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(prev, '', registry, registry.proxyCache) + // Establishes a signal at 'user.name' with value 'Alice' + expect(proxy.user.name).toBe('Alice') + + reconcileState(prev, next, registry, alienEngine) + + // diff treats non-plain objects as leaves: it bumps the 'user' path but + // never recurses, so 'user.name' silently keeps the stale value. + // Documented guidance: keep state to plain objects and arrays. + const nameSig = registry.getOrCreate('user.name', 'unused') + expect(nameSig.get()).toBe('Alice') + }) +}) + +describe('edge cases: identity key values', () => { + it.fails('numeric and string key values produce distinct identity paths', () => { + // {id: 1} and {id: "1"} currently both build "items.{id:1}" — + // entities collide in signal path space. + expect(buildIdentityPath('items', 'id', 1)).not.toBe( + buildIdentityPath('items', 'id', '1'), + ) + }) + + it.fails('key values containing dots do not corrupt the prefix index', () => { + // Emails and composite keys as ids are common. All prefix bookkeeping + // splits on '.', so "users.{id:a@b.com}.name" produces garbage + // ancestor entries like "users.{id:a@b". + const state = { users: [{ id: 'a@b.com', name: 'Alice' }] } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy.users[0].name).toBe('Alice') + expect(registry.hasPrefix('users.{id:a@b')).toBe(false) + }) + + it('removing an entity whose id contains dots still prunes its child signals', () => { + // Verified: prune survives dotted ids. Child paths link to their parent + // via lastIndexOf('.'), which lands on the separator AFTER the identity + // segment ("users.{id:a@b.com}" + ".name"), so the child index is + // correct for children. Only the identity segment's own ancestor walk + // is corrupted (garbage prefix entries — see previous test). The dotted + // id bug is memory/bookkeeping pollution, not broken pruning. + const prev = { users: [{ id: 'a@b.com', name: 'Alice' }] } + const next = { users: [] as { id: string; name: string }[] } + + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(prev, '', registry, registry.proxyCache) + expect(proxy.users[0].name).toBe('Alice') + expect(registry.has('users.{id:a@b.com}.name')).toBe(true) + + reconcileState(prev, next, registry, alienEngine) + + expect(registry.has('users.{id:a@b.com}.name')).toBe(false) + }) +}) + +describe('edge cases: same-length keyed array replacement (remove + add)', () => { + // diffArrayByKey's general path only builds `seenPrevKeys` when + // next.length < prev.length. A same-length replacement (remove one entity, + // add another) never prunes the removed entity's signals — components + // subscribed to it go permanently stale. + + it.fails('prunes signals for an entity removed in a same-length replacement', () => { + const shared = { id: 1, text: 'first' } + const prev = { items: [shared, { id: 2, text: 'second' }] } + const next = { items: [shared, { id: 9, text: 'ninth' }] } + + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(prev, '', registry, registry.proxyCache) + expect(proxy.items[0].text).toBe('first') + expect(proxy.items[1].text).toBe('second') + expect(registry.has('items.{id:2}.text')).toBe(true) + + reconcileState(prev, next, registry, alienEngine) + + expect(registry.has('items.{id:2}.text')).toBe(false) + }) + + it.fails('temp-id → server-id swap prunes the temp entity signals', () => { + // Optimistic update pattern: entity created with a temp id, server + // responds with the real id. Same array length, key value changes. + const prev = { items: [{ id: 'temp-1', text: 'draft' }] } + const next = { items: [{ id: 42, text: 'draft' }] } + + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(prev, '', registry, registry.proxyCache) + expect(proxy.items[0].text).toBe('draft') + expect(registry.has('items.{id:temp-1}.text')).toBe(true) + + reconcileState(prev, next, registry, alienEngine) + + expect(registry.has('items.{id:temp-1}.text')).toBe(false) + }) + + it('fires the structural remove signal for a same-length replacement', () => { + // The structural classification is computed independently of the prune + // bookkeeping, so scans (filter/find/...) DO see the removal. + const shared = { id: 1, text: 'first' } + const prev = { items: [shared, { id: 2, text: 'second' }] } + const next = { items: [shared, { id: 9, text: 'ninth' }] } + + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(prev, '', registry, registry.proxyCache) + expect(proxy.items[1].text).toBe('second') + + const removeSig = registry.trackStructure('items', 'remove') + const before = removeSig.get() + + reconcileState(prev, next, registry, alienEngine) + + expect(removeSig.get()).not.toBe(before) + }) +}) + +describe('edge cases: aliased references (same object at two paths)', () => { + // The proxyCache is keyed by target object identity only. When the same + // raw object is reachable at two paths (state.selected = state.items[0]), + // whichever path is proxied first wins — reads through the second path + // register signals under the FIRST path. + + it.fails('reads through a second path register signals under that path', () => { + const item = { id: 1, name: 'a' } + const state = { items: [item], selected: item } + + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + // Traverse via items first — item's proxy is cached with path "items.{id:1}" + expect(proxy.items[0].name).toBe('a') + // Now read the same object via selected — cache hit returns the + // items-path proxy, so this registers "items.{id:1}.name" again + // instead of "selected.name". + expect(proxy.selected.name).toBe('a') + + expect(registry.has('selected.name')).toBe(true) + }) + + it('leaf-object tracking still records the aliased path itself (coarse rescue)', () => { + // The alias path IS recorded as a leaf object access (the child reads + // go under the wrong path, but 'selected' itself lands in the tracker + // untraversed). The hook then subscribes to the 'selected' version + // signal, so *replacement* of the aliased ref still re-runs the + // selector — precision is lost, correctness mostly survives. + const item = { id: 1, name: 'a' } + const state = { items: [item], selected: item } + + const registry = createPathSignalRegistry(alienEngine) + const tracker = createTracker() + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + tracker, + ) + + expect(proxy.items[0].name).toBe('a') + expect(proxy.selected.name).toBe('a') + + expect(tracker.accessedObjects.has('selected')).toBe(true) + expect(tracker.traversedPaths.has('selected')).toBe(false) + }) +}) + +describe('edge cases: leafTracker capture through the shared proxy cache', () => { + // Cached proxies close over the leafTracker of the evaluation that + // CREATED them. The proxyCache is registry-wide, so a second component's + // reads through a cached proxy record into the first component's tracker. + // The second component then misses identity-change (ref swap) deps. + + it.fails('a second evaluation records leaf accesses into its own tracker', () => { + const state = { settings: { theme: 'dark' } } + const registry = createPathSignalRegistry(alienEngine) + + // Evaluation A creates and caches all the proxies + const trackerA = createTracker() + const proxyA = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + trackerA, + ) + expect(proxyA.settings.theme).toBe('dark') + + // Evaluation B gets cache hits — every trap still records into trackerA + const trackerB = createTracker() + const proxyB = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + trackerB, + ) + // Leaf (identity-only) access by evaluation B + void proxyB.settings + + expect(trackerB.accessedObjects.has('settings')).toBe(true) + }) +}) + +describe('edge cases: registry lifecycle', () => { + it.fails('prune clears array identity metadata (entityMap retention)', () => { + const state = { items: [{ id: 1, v: 1 }] } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + // Element access detects the key field and creates ArrayMeta + expect(proxy.items[0].v).toBe(1) + expect(registry.getArrayMeta('items')).toBeDefined() + + registry.prune('items') + + // The entityMap holds strong refs to old entities — after a reset + // action prunes the slice, the metadata should be released too. + expect(registry.getArrayMeta('items')).toBeUndefined() + }) +}) + +describe('edge cases: NaN leaf values', () => { + it.fails('does not re-run a computed when an untouched NaN leaf is re-diffed', () => { + // CONFIRMED BUG: prev[key] === next[key] is false for NaN, so the diff + // descends and calls update(path, NaN) on every dispatch that touches + // the parent. alien-signals uses !== equality, so setting NaN over NaN + // re-fires the signal — the selector re-runs on every such dispatch. + // Fix: use Object.is semantics in the diff's skip check (and/or the + // signal update). + const prev = { metrics: { ratio: NaN, count: 1 } } + const next = { metrics: { ratio: NaN, count: 2 } } + + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(prev, '', registry, registry.proxyCache) + + let runs = 0 + const c = alienEngine.computed(() => { + runs++ + return proxy.metrics.ratio + }) + expect(Number.isNaN(c.get() as number)).toBe(true) + expect(runs).toBe(1) + + reconcileState(prev, next, registry, alienEngine) + + c.get() + expect(runs).toBe(1) + }) +}) + +describe('edge cases: common selector patterns (smoke)', () => { + it('JSON.stringify over a proxied subtree matches the raw state', () => { + const state = { + settings: { theme: 'dark', flags: { beta: true }, list: [1, 2, 3] }, + } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(JSON.stringify(proxy.settings)).toBe(JSON.stringify(state.settings)) + }) + + it('Object.values / Object.entries work through the proxy', () => { + const state = { counts: { a: 1, b: 2 } } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(Object.values(proxy.counts)).toEqual([1, 2]) + expect(Object.entries(proxy.counts)).toEqual([ + ['a', 1], + ['b', 2], + ]) + }) + + it('destructuring a proxied object reads tracked values', () => { + const state = { settings: { theme: 'dark', fontSize: 12 } } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + const { theme, fontSize } = proxy.settings + expect(theme).toBe('dark') + expect(fontSize).toBe(12) + expect(registry.has('settings.theme')).toBe(true) + expect(registry.has('settings.fontSize')).toBe(true) + }) + + it('reading a missing index of a primitive array subscribes coarsely and returns undefined', () => { + const state = { ids: [1, 2, 3] } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy.ids[5]).toBeUndefined() + // Coarse array signal, not a per-index signal + expect(registry.has('ids')).toBe(true) + expect(registry.has('ids.5')).toBe(false) + }) +}) diff --git a/test/signals/edgeCasesReact.spec.tsx b/test/signals/edgeCasesReact.spec.tsx new file mode 100644 index 000000000..62c742f9f --- /dev/null +++ b/test/signals/edgeCasesReact.spec.tsx @@ -0,0 +1,312 @@ +/** + * Edge case audit tests (React integration level). + * + * Tests marked `it.fails` assert the DESIRED behavior for confirmed bugs — + * they pass while the bug exists and will flip to failing once the bug is + * fixed (at which point remove the `.fails` marker). + * + * See dev-plans research doc: 2026-07-28-edge-case-audit.md + */ +import * as rtl from '@testing-library/react' +import React, { StrictMode, useLayoutEffect, useState } from 'react' +import { renderToString } from 'react-dom/server' +import { + configureStore, + createSlice, + type PayloadAction, +} from '@reduxjs/toolkit' +import { SignalProvider, useSignalSelector, unwrap } from '../../src/signals' + +interface Counter { + id: string + label: string + value: number +} + +const countersSlice = createSlice({ + name: 'counters', + initialState: { + counter1: { id: 'counter1', label: 'Clicks', value: 0 } as Counter, + counter2: { id: 'counter2', label: 'Views', value: 42 } as Counter, + }, + reducers: { + increment(state, action: PayloadAction<'counter1' | 'counter2'>) { + state[action.payload].value += 1 + }, + }, +}) + +const filterSlice = createSlice({ + name: 'filter', + initialState: 'all' as 'all' | 'active' | 'completed', + reducers: { + set(_state, action: PayloadAction<'all' | 'active' | 'completed'>) { + return action.payload + }, + }, +}) + +const settingsSlice = createSlice({ + name: 'settings', + initialState: { theme: 'dark', fontSize: 12 }, + reducers: { + setFontSize(state, action: PayloadAction) { + state.fontSize = action.payload + }, + setTheme(state, action: PayloadAction) { + state.theme = action.payload + }, + }, +}) + +function createTestStore() { + return configureStore({ + reducer: { + counters: countersSlice.reducer, + filter: filterSlice.reducer, + settings: settingsSlice.reducer, + }, + }) +} + +type TestStore = ReturnType +type TestState = ReturnType + +describe('edge cases: React integration', () => { + let store: TestStore + + beforeEach(() => { + store = createTestStore() + }) + + afterEach(() => { + rtl.cleanup() + }) + + describe('selector closing over props', () => { + // selectorRef is updated in a layout effect, but nothing invalidates + // the computed when the selector function changes. The hook keeps + // returning the previous selector's cached result until some store + // change happens to fire one of the OLD selector's signals. + it.fails('re-selects when a prop used by the selector changes', () => { + function CounterValue({ which }: { which: 'counter1' | 'counter2' }) { + const value = useSignalSelector( + (s: TestState) => s.counters[which].value, + ) + return
{value}
+ } + + let setWhich: (w: 'counter1' | 'counter2') => void = () => {} + function Parent() { + const [which, set] = useState<'counter1' | 'counter2'>('counter1') + setWhich = set + return + } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('value').textContent).toBe('0') + + rtl.act(() => { + setWhich('counter2') + }) + + // counter2's value is 42 — the stale closure keeps showing counter1's 0 + expect(getByTestId('value').textContent).toBe('42') + }) + }) + + describe('root identity selector', () => { + // The root proxy's path is '' and the diff never updates the '' path, + // so `s => s` establishes no firing dependency. + it.fails('state => state re-renders on dispatch', () => { + function WholeState() { + const state = useSignalSelector((s: TestState) => s) + return
{state.filter}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('filter').textContent).toBe('all') + + rtl.act(() => { + store.dispatch(filterSlice.actions.set('active')) + }) + + expect(getByTestId('filter').textContent).toBe('active') + }) + }) + + describe('identity comparison across multiple components', () => { + it('control: a lone component tracks identity changes of a leaf object', () => { + const originalSettings = store.getState().settings + + function IdentityWatcher() { + const result = useSignalSelector((s: TestState) => + unwrap(s.settings) === originalSettings ? 'same' : 'changed', + ) + return
{result}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('identity').textContent).toBe('same') + + rtl.act(() => { + store.dispatch(settingsSlice.actions.setFontSize(99)) + }) + + expect(getByTestId('identity').textContent).toBe('changed') + }) + + // Cached proxies close over the leafTracker of the evaluation that + // created them. When component A mounts first and traverses the state, + // component B's identity-only reads are recorded into A's tracker — + // B's computed ends up with no dependencies and never re-runs. + it.fails('a second component still tracks identity changes when another component traversed the same slice first', () => { + const originalSettings = store.getState().settings + + function ThemeReader() { + const theme = useSignalSelector((s: TestState) => s.settings.theme) + return
{theme}
+ } + + function IdentityWatcher() { + const result = useSignalSelector((s: TestState) => + unwrap(s.settings) === originalSettings ? 'same' : 'changed', + ) + return
{result}
+ } + + const { getByTestId } = rtl.render( + + {/* A mounts first, creating + caching the proxies with its tracker */} + + + , + ) + + expect(getByTestId('identity').textContent).toBe('same') + + // Changes fontSize only — theme untouched, settings ref replaced + rtl.act(() => { + store.dispatch(settingsSlice.actions.setFontSize(99)) + }) + + expect(getByTestId('identity').textContent).toBe('changed') + }) + }) + + describe('StrictMode', () => { + it('renders and updates correctly under StrictMode', () => { + function FilterReader() { + const filter = useSignalSelector((s: TestState) => s.filter) + return
{filter}
+ } + + const { getByTestId } = rtl.render( + + + + + , + ) + + expect(getByTestId('filter').textContent).toBe('all') + + rtl.act(() => { + store.dispatch(filterSlice.actions.set('active')) + }) + + expect(getByTestId('filter').textContent).toBe('active') + }) + }) + + describe('re-entrant dispatch', () => { + it('handles a component dispatching from a layout effect in response to a change', () => { + function ChainedDispatcher() { + const value = useSignalSelector( + (s: TestState) => s.counters.counter1.value, + ) + useLayoutEffect(() => { + if (value === 1) { + store.dispatch(countersSlice.actions.increment('counter1')) + } + }, [value]) + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('chained').textContent).toBe('0') + + rtl.act(() => { + store.dispatch(countersSlice.actions.increment('counter1')) + }) + + // The layout effect chains a second increment: 0 → 1 → 2 + expect(getByTestId('chained').textContent).toBe('2') + }) + + it('handles a store subscriber dispatching during notification', () => { + function FilterReader() { + const filter = useSignalSelector((s: TestState) => s.filter) + return
{filter}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + const unsubscribe = store.subscribe(() => { + if (store.getState().filter === 'active') { + store.dispatch(filterSlice.actions.set('completed')) + } + }) + + rtl.act(() => { + store.dispatch(filterSlice.actions.set('active')) + }) + + expect(getByTestId('filter').textContent).toBe('completed') + unsubscribe() + }) + }) + + describe('server-side rendering', () => { + // useSyncExternalStore is called without a getServerSnapshot argument, + // so any server render (Next.js etc.) throws. + it.fails('supports renderToString', () => { + function FilterReader() { + const filter = useSignalSelector((s: TestState) => s.filter) + return
{filter}
+ } + + const html = renderToString( + + + , + ) + + expect(html).toContain('all') + }) + }) +}) From 1c45fe38a296d83c7227219c981e2d8f1fdb88f8 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Wed, 29 Jul 2026 00:10:01 -0400 Subject: [PATCH 30/95] Fix multiple edge case bugs --- src/signals/arrayKeys.ts | 46 ++++++++- src/signals/diff.ts | 70 +++++++------ src/signals/pathSignalRegistry.ts | 27 ++++- src/signals/trackingProxy.ts | 47 ++++++++- src/signals/useSignalSelector.ts | 62 +++++++++-- test/benchmarks/reconcile/benchEngine.ts | 3 +- test/signals/edgeCases.spec.ts | 126 +++++++++++++---------- test/signals/edgeCasesReact.spec.tsx | 22 ++-- 8 files changed, 295 insertions(+), 108 deletions(-) diff --git a/src/signals/arrayKeys.ts b/src/signals/arrayKeys.ts index 08b5de5c5..17c4da41b 100644 --- a/src/signals/arrayKeys.ts +++ b/src/signals/arrayKeys.ts @@ -46,9 +46,52 @@ export interface ArrayMeta { entityMap: Map } +// Characters that corrupt path bookkeeping if they appear in a key value: +// '.' breaks the ancestor walks (lastIndexOf('.')), '{'/'}' can collide +// with meta segments, '%' is the escape character itself, and '"' is +// reserved for quoting numeric-looking strings (a literal quote in a key +// would otherwise collide with the quoted form of another key). +const NEEDS_ESCAPE = /[%.{}"]/ + +function escapeSegment(s: string): string { + return s + .replace(/%/g, '%25') + .replace(/\./g, '%2E') + .replace(/\{/g, '%7B') + .replace(/\}/g, '%7D') + .replace(/"/g, '%22') +} + +/** + * Render a key value as a path segment fragment. + * + * Two invariants: + * 1. No '.', '{', or '}' in the output (escaped as %XX) so path + * prefix walking and pruning stay correct for ids like "a@b.com". + * 2. A string key never renders identically to a number key. Strings + * whose escaped form matches a number's rendering (e.g. "1", "1.5") + * are wrapped in quotes: number 1 → `1`, string "1" → `"1"`. + * @param keyValue - The identity key value + * @returns The encoded segment fragment + */ +function encodeKeyValue(keyValue: string | number): string { + if (typeof keyValue === 'number') { + // Integers (the common case) can't contain escapable characters + if (Number.isInteger(keyValue)) return String(keyValue) + return escapeSegment(String(keyValue)) + } + const escaped = NEEDS_ESCAPE.test(keyValue) + ? escapeSegment(keyValue) + : keyValue + // Quote strings that would collide with a number's rendering + return String(Number(keyValue)) === keyValue ? '"' + escaped + '"' : escaped +} + /** * Build the identity path segment for an array element. * e.g., arrayPath="items", keyField="id", element={id: 42} → "items.{id:42}" + * Key values are encoded: dots/braces are %-escaped, and numeric-looking + * string keys are quoted to stay distinct from number keys. * @param arrayPath - Parent array's path * @param keyField - The identity key field name * @param keyValue - The identity key value @@ -59,7 +102,8 @@ export function buildIdentityPath( keyField: string, keyValue: string | number, ): string { - return arrayPath ? `${arrayPath}.{${keyField}:${keyValue}}` : `{${keyField}:${keyValue}}` + const segment = `{${keyField}:${encodeKeyValue(keyValue)}}` + return arrayPath ? `${arrayPath}.${segment}` : segment } /** diff --git a/src/signals/diff.ts b/src/signals/diff.ts index 241955234..9469e266d 100644 --- a/src/signals/diff.ts +++ b/src/signals/diff.ts @@ -45,11 +45,16 @@ function diffObject( // Hoist reference equality check before path construction. // Structural sharing means most keys are unchanged — skip early. - if (prev[key] === next[key]) continue + // The self-inequality test catches NaN (NaN !== NaN would otherwise + // descend and re-fire the leaf signal on every dispatch). + const prevVal = prev[key] + const nextVal = next[key] + if (prevVal === nextVal || (prevVal !== prevVal && nextVal !== nextVal)) + continue const childPath = parentPath ? parentPath + '.' + key : key if (registry.hasPrefix(childPath)) { - diffAndUpdateSignals(prev[key], next[key], childPath, registry) + diffAndUpdateSignals(prevVal, nextVal, childPath, registry) } } @@ -206,8 +211,10 @@ export function diffAndUpdateSignals( parentPath: string, registry: PathSignalRegistry, ): void { - // Structural sharing: identical references mean nothing changed - if (prev === next) return + // Structural sharing: identical references mean nothing changed. + // The self-inequality test catches NaN (NaN !== NaN would otherwise + // fall through to the leaf branch and re-fire on every dispatch). + if (prev === next || (prev !== prev && next !== next)) return // Both plain objects: recurse into properties if (isPlainObject(prev) && isPlainObject(next)) { @@ -221,18 +228,26 @@ export function diffAndUpdateSignals( return } - // Leaf value change (primitive, or type mismatch like object→primitive) + // Leaf value change: primitive, non-plain object (Date, Map, class + // instance — tracked by reference only), or type mismatch + // (object→primitive, array→object, ...) if (parentPath) { registry.update(parentPath, next) } - // If prev was an object/array and next is not, prune child signals - if ( - prev !== null && - typeof prev === 'object' && - (next === null || typeof next !== 'object') - ) { - registry.prune(parentPath) + // If prev was an object/array, we did NOT recurse — any child signals + // registered under this path would go silently stale. + if (prev !== null && typeof prev === 'object') { + if (next === null || typeof next !== 'object') { + // object → primitive: the path itself is no longer an object; drop + // the whole subtree (fires child signals before removal) + registry.prune(parentPath) + } else { + // object → object we can't diff into (class instance replaced, + // array↔object type change): keep this path's just-updated signal + // but fire-and-drop the children + registry.pruneChildren(parentPath) + } } } @@ -347,9 +362,7 @@ function diffArrayByKey( return } - // General case: build Map only from startIdx onward - const mayHaveRemovals = next.length < prev.length - + // General case: build Map only from startIdx onward. const nextEntityMap = new Map() // Carry over skipped prefix entries from prevEntityMap for (let i = 0; i < startIdx; i++) { @@ -357,13 +370,14 @@ function diffArrayByKey( if (kv !== undefined) nextEntityMap.set(kv, next[i]) } - const seenPrevKeys = mayHaveRemovals ? new Set() : null + // Track every prev key seen in next so removed entities get pruned. + // Removals can happen at ANY relative length — a same-length + // remove+add (page swap, temp-id → server-id) still removes entities. + const seenPrevKeys = new Set() // Mark skipped prefix keys as seen (they can't be removed) - if (seenPrevKeys) { - for (let i = 0; i < startIdx; i++) { - const kv = getKeyValue(prev[i], keyField) - if (kv !== undefined) seenPrevKeys.add(kv) - } + for (let i = 0; i < startIdx; i++) { + const kv = getKeyValue(prev[i], keyField) + if (kv !== undefined) seenPrevKeys.add(kv) } // Structural classification state (only maintained when tracked): @@ -434,11 +448,11 @@ function diffArrayByKey( } if (prevItem === nextItem) { - if (seenPrevKeys) seenPrevKeys.add(kv) + seenPrevKeys.add(kv) continue } - if (seenPrevKeys) seenPrevKeys.add(kv) + seenPrevKeys.add(kv) const identityPath = buildIdentityPath(parentPath, keyField, kv) @@ -463,12 +477,10 @@ function diffArrayByKey( } } - if (seenPrevKeys) { - for (const [kv] of prevEntityMap) { - if (!seenPrevKeys.has(kv)) { - const identityPath = buildIdentityPath(parentPath, keyField, kv) - registry.prune(identityPath) - } + for (const [kv] of prevEntityMap) { + if (!seenPrevKeys.has(kv)) { + const identityPath = buildIdentityPath(parentPath, keyField, kv) + registry.prune(identityPath) } } diff --git a/src/signals/pathSignalRegistry.ts b/src/signals/pathSignalRegistry.ts index 6fe8a6b45..d1a93b0ce 100644 --- a/src/signals/pathSignalRegistry.ts +++ b/src/signals/pathSignalRegistry.ts @@ -1,5 +1,5 @@ import type { ArrayMeta } from './arrayKeys' -import type { ProxyCache } from './trackingProxy' +import type { LeafObjectTracker, ProxyCache } from './trackingProxy' import type { PathKey, ReactiveSignal, SignalEngine } from './types' /** @@ -27,6 +27,12 @@ export interface PathSignalRegistry { * Uses a parent→children index for O(subtree) instead of O(total signals). */ prune(pathKey: PathKey): void + /** Remove signals for all child paths, keeping the path's own signal. + * Used when a subtree is replaced by a value the diff can't recurse + * into (e.g., a class instance) — child reads fire once instead of + * going silently stale. */ + pruneChildren(pathKey: PathKey): void + /** Number of active signals. */ size(): number @@ -42,6 +48,11 @@ export interface PathSignalRegistry { /** Proxy cache for reusing proxies across evaluations (keyed by object identity). */ proxyCache: ProxyCache + /** Holder for the leaf tracker of the evaluation currently running. + * Proxies read this at trap time instead of closing over a tracker, + * so cached proxies record into whichever evaluation is active. */ + leafTrackerHolder: { current: LeafObjectTracker | undefined } + /** Get array metadata for identity-based tracking at a given path. */ getArrayMeta(arrayPath: string): ArrayMeta | undefined @@ -221,9 +232,10 @@ export function createPathSignalRegistry( const stack: string[] = [pathKey] while (stack.length > 0) { const key = stack.pop()! - // Drop column/structure tracking for any array at or below the pruned path + // Drop column/structure/identity tracking for any array at or below the pruned path columnsByArray.delete(key) structuresByArray.delete(key) + arrayMetas.delete(key) // Push children onto stack before deleting const children = childIndex.get(key) if (children) { @@ -262,6 +274,15 @@ export function createPathSignalRegistry( } }, + pruneChildren(pathKey: PathKey): void { + const children = childIndex.get(pathKey) + if (!children) return + // prune() mutates the parent's child set — iterate over a copy + for (const child of Array.from(children)) { + registry.prune(child) + } + }, + size(): number { return signals.size }, @@ -280,6 +301,8 @@ export function createPathSignalRegistry( proxyCache: proxyWeakMap, + leafTrackerHolder: { current: undefined }, + getArrayMeta(arrayPath: string): ArrayMeta | undefined { return arrayMetas.get(arrayPath) }, diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index b243778af..d61dd597e 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -9,6 +9,11 @@ function isObjectOrArray(v: unknown): v is object { return v !== null && typeof v === 'object' } +function isPlainObject(v: object): boolean { + const proto = Object.getPrototypeOf(v) + return proto === Object.prototype || proto === null +} + // Fast check for array-index-like property names ('0', '42', ...). // Canonical array index strings always start with a digit; named array // props ('length', 'includes', ...) never do. Cheaper than Number()+isNaN @@ -74,6 +79,16 @@ export interface LeafObjectTracker { * Caches proxies by their target object identity. * Since Immer uses structural sharing, unchanged subtrees keep the same * object reference across state snapshots — so we can reuse their proxies. + * + * KNOWN TRADEOFF: an object aliased at two paths (e.g. + * `state.selected = state.items[0]`) reuses the proxy created for + * whichever path was read first, so child reads through the second path + * register signals under the first path. Per-path proxies would fix the + * attribution but break identity comparison (`state.selected === + * state.items[0]` must hold, and two proxies are never `===`). The alias + * path itself still gets a version-signal dependency via the leaf-object + * tracker, so replacing the aliased object re-runs the selector — the + * misattribution can cause excess re-runs, never missed ones. */ export type ProxyCache = WeakMap @@ -137,7 +152,10 @@ export function unwrap(value: T): T { * @param parentPath - Dot-separated path to this object in the state tree * @param registry - Signal registry for dependency tracking * @param cache - Proxy cache for deduplication - * @param leafTracker - Optional tracker for detecting leaf object accesses + * @param leafTracker - Optional tracker for detecting leaf object accesses. + * When provided, it becomes the ACTIVE tracker for the registry: all + * proxy traps (including those of previously cached proxies) record + * into it until another evaluation installs a different tracker. * @returns A tracking proxy wrapping the target */ export function createTrackingProxy( @@ -147,6 +165,13 @@ export function createTrackingProxy( cache: ProxyCache, leafTracker?: LeafObjectTracker, ): T { + // Install the tracker for this evaluation. Traps read the holder at + // access time instead of closing over a tracker, so proxies cached by + // an earlier evaluation record into the current evaluation's tracker. + if (leafTracker !== undefined) { + registry.leafTrackerHolder.current = leafTracker + } + // Check proxy cache — reuse proxy if we've already wrapped this exact object const cached = cache.get(target) if (cached) return cached as T @@ -176,6 +201,7 @@ export function createTrackingProxy( if (typeof prop === 'symbol') return Reflect.get(target, prop) const value = (target as Record)[prop] + const leafTracker = registry.leafTrackerHolder.current // Functions: intercept array methods to avoid per-element proxy creation if (typeof value === 'function') { @@ -240,6 +266,20 @@ export function createTrackingProxy( } } + // Non-plain objects (Date, Map, Set, class instances): return the + // raw object, tracked by reference only. Wrapping them in a proxy + // shell breaks prototype methods that need internal slots + // ("this is not a Date object"), and the diff can't recurse into + // them anyway. Reading the version signal here means a ref swap + // at this path re-runs the selector. + if (!Array.isArray(value) && !isPlainObject(value as object)) { + if (leafTracker) { + leafTracker.traversedPaths.add(parentPath) + } + registry.getOrCreate(pathKey, value).get() + return value + } + // Register in prefix index (for hasPrefix/diff tracking) but DON'T // create a signal. This avoids allocating signals for intermediate // objects that are only traversed, not read as terminal values. @@ -250,13 +290,14 @@ export function createTrackingProxy( leafTracker.traversedPaths.add(parentPath) } - // Return cached child proxy (createTrackingProxy checks cache internally) + // Return cached child proxy (createTrackingProxy checks cache internally). + // No tracker argument: the trap already reads the registry's + // active-tracker holder, which this evaluation installed. const childProxy = createTrackingProxy( value as object, pathKey, registry, cache, - leafTracker, ) // Track this object access — may be a leaf (identity-only usage) diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index b27d5a706..5b0d6b717 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -1,5 +1,4 @@ import { React } from '../utils/react' -import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' import { useSignalContext } from './context' import { createTrackingProxy, @@ -26,19 +25,20 @@ export function useSignalSelector( ): R { const { store, registry, engine } = useSignalContext() - // Track latest selector/equalityFn via refs, synced in layout effect (not during render) + // Track latest selector/equalityFn via refs. Updated during render + // (idempotent ref writes, same approach as React's + // useSyncExternalStoreWithSelector) so a selector closing over changed + // props is applied in THIS render, not a layout effect later. const selectorRef = useRef(selector) const equalityFnRef = useRef(equalityFn) - useIsomorphicLayoutEffect(() => { - selectorRef.current = selector - equalityFnRef.current = equalityFn - }) + equalityFnRef.current = equalityFn // Create the signal bridge once (stable across renders) const bridge = useMemo(() => { let currentResult: R let version = 0 let notifyReact: (() => void) | null = null + let suppressNotify = false // Create a computed that runs the selector through a tracking proxy. // This establishes signal dependencies on the paths the selector reads. @@ -58,7 +58,30 @@ export function useSignalSelector( traversedPaths: new Set(), } + // Bumped when the component re-renders with a different selector + // function (e.g., an inline selector closing over changed props). + // The computed reads it, so bumping forces a re-evaluation with the + // new closure — otherwise the selector would keep returning the old + // closure's result until an unrelated store change fired a signal. + const selectorVersionSignal = engine.signal(0) + + const setSelector = (nextSelector: (state: S) => R): void => { + selectorRef.current = nextSelector + // Recompute in place WITHOUT notifying React: this runs during + // render, and getSnapshot picks up the fresh value in the same + // pass. Scheduling a re-render here would loop forever for + // selectors that return a new reference on every run. + suppressNotify = true + try { + selectorVersionSignal.set(selectorVersionSignal.get() + 1) + currentResult = selectorComputed.get() + } finally { + suppressNotify = false + } + } + const selectorComputed = engine.computed(() => { + selectorVersionSignal.get() const state = store.getState() as S & object leafTracker.accessedObjects.clear() @@ -117,6 +140,15 @@ export function useSignalSelector( return } + if (suppressNotify) { + // Render-phase selector swap (setSelector): adopt the value + // so the equality baseline is current, but let the ongoing + // render pick it up via getSnapshot instead of scheduling + // another render. + currentResult = newValue + return + } + // Apply user's equality function if (!equalityFnRef.current(currentResult, newValue)) { currentResult = newValue @@ -134,9 +166,19 @@ export function useSignalSelector( getSnapshot(): R { return currentResult }, + + setSelector, } }, [store, registry, engine]) + // Render-phase selector swap: if this render brought a different + // selector function (inline selector closing over changed props), + // re-evaluate now so getSnapshot returns the new closure's value in + // this same render. + if (selectorRef.current !== selector) { + bridge.setSelector(selector) + } + // Cleanup scope on unmount useEffect(() => { return () => { @@ -144,5 +186,11 @@ export function useSignalSelector( } }, [bridge]) - return useSyncExternalStore(bridge.subscribe, bridge.getSnapshot) + // getSnapshot doubles as getServerSnapshot: the computed already ran + // during useMemo, so SSR renders the current selected value. + return useSyncExternalStore( + bridge.subscribe, + bridge.getSnapshot, + bridge.getSnapshot, + ) } diff --git a/test/benchmarks/reconcile/benchEngine.ts b/test/benchmarks/reconcile/benchEngine.ts index aa0ede77e..a92c0ec8f 100644 --- a/test/benchmarks/reconcile/benchEngine.ts +++ b/test/benchmarks/reconcile/benchEngine.ts @@ -34,8 +34,9 @@ export const benchEngine: SignalEngine = { computed(fn: () => T) { return { get: fn } }, - effect(_fn: () => void): void { + effect(_fn: () => void): () => void { // no-op + return () => {} }, batch(fn: () => void): void { fn() diff --git a/test/signals/edgeCases.spec.ts b/test/signals/edgeCases.spec.ts index 2277450c5..1607187eb 100644 --- a/test/signals/edgeCases.spec.ts +++ b/test/signals/edgeCases.spec.ts @@ -25,12 +25,11 @@ function createTracker(): LeafObjectTracker { } describe('edge cases: non-plain objects in state', () => { - // The proxy shell is Object.create(proto) with no internal slots. - // Prototype methods that need internal slots ([[DateValue]], [[MapData]], - // [[SetData]]) are returned raw and invoked with `this` = proxy → TypeError. - // Desired fix: treat non-plain objects as opaque leaves (return unproxied). + // Non-plain objects (Date, Map, Set, class instances) are opaque leaves: + // the proxy returns them raw (no shell, so internal-slot methods work) + // and tracks them by reference via a version signal at their path. - it.fails('supports calling Date methods on a state Date', () => { + it('supports calling Date methods on a state Date', () => { const createdAt = new Date(2020, 0, 1) const state = { createdAt } const registry = createPathSignalRegistry(alienEngine) @@ -39,7 +38,7 @@ describe('edge cases: non-plain objects in state', () => { expect(proxy.createdAt.getTime()).toBe(createdAt.getTime()) }) - it.fails('supports calling Map methods on a state Map', () => { + it('supports calling Map methods on a state Map', () => { const state = { lookup: new Map([['a', 1]]) } const registry = createPathSignalRegistry(alienEngine) const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) @@ -47,7 +46,7 @@ describe('edge cases: non-plain objects in state', () => { expect(proxy.lookup.get('a')).toBe(1) }) - it.fails('supports calling Set methods on a state Set', () => { + it('supports calling Set methods on a state Set', () => { const state = { tags: new Set(['x']) } const registry = createPathSignalRegistry(alienEngine) const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) @@ -71,41 +70,63 @@ describe('edge cases: non-plain objects in state', () => { expect(proxy.lookup.size).toBe(1) }) - it('documented limitation: class instance children are not diffed (reference-only tracking)', () => { + it('documented limitation: class instances are opaque leaves tracked by reference', () => { class User { constructor(public name: string) {} } - const prev = { user: new User('Alice') } + const alice = new User('Alice') + const prev = { user: alice } const next = { user: new User('Bob') } const registry = createPathSignalRegistry(alienEngine) const proxy = createTrackingProxy(prev, '', registry, registry.proxyCache) - // Establishes a signal at 'user.name' with value 'Alice' + + // The instance comes back raw — no proxy shell, no child signals. + expect(proxy.user).toBe(alice) expect(proxy.user.name).toBe('Alice') + expect(registry.has('user.name')).toBe(false) + expect(registry.has('user')).toBe(true) - reconcileState(prev, next, registry, alienEngine) + // Replacing the instance bumps the reference signal at 'user'. + // Mutating the instance in place would NOT be detected — documented + // guidance: keep state to plain objects and arrays. + let runs = 0 + const computed = alienEngine.computed(() => { + runs++ + const p = createTrackingProxy(next, '', registry, registry.proxyCache) + return p.user.name + }) + expect(computed.get()).toBe('Bob') + expect(runs).toBe(1) - // diff treats non-plain objects as leaves: it bumps the 'user' path but - // never recurses, so 'user.name' silently keeps the stale value. - // Documented guidance: keep state to plain objects and arrays. - const nameSig = registry.getOrCreate('user.name', 'unused') - expect(nameSig.get()).toBe('Alice') + reconcileState(prev, next, registry, alienEngine) + expect(computed.get()).toBe('Bob') + expect(runs).toBe(2) }) }) describe('edge cases: identity key values', () => { - it.fails('numeric and string key values produce distinct identity paths', () => { - // {id: 1} and {id: "1"} currently both build "items.{id:1}" — - // entities collide in signal path space. + it('numeric and string key values produce distinct identity paths', () => { + // {id: 1} builds "items.{id:1}" while {id: "1"} builds 'items.{id:"1"}' — + // numeric-looking string keys are quoted to avoid colliding. expect(buildIdentityPath('items', 'id', 1)).not.toBe( buildIdentityPath('items', 'id', '1'), ) }) - it.fails('key values containing dots do not corrupt the prefix index', () => { - // Emails and composite keys as ids are common. All prefix bookkeeping - // splits on '.', so "users.{id:a@b.com}.name" produces garbage - // ancestor entries like "users.{id:a@b". + it('literal quotes in key values stay distinct from the quoted numeric form', () => { + // string '"1"' must not render identically to string '1' (which gets + // quote-wrapped as {id:"1"}). Literal quotes are escaped as %22. + expect(buildIdentityPath('items', 'id', '"1"')).not.toBe( + buildIdentityPath('items', 'id', '1'), + ) + expect(buildIdentityPath('items', 'id', '"1"')).toBe('items.{id:%221%22}') + }) + + it('key values containing dots do not corrupt the prefix index', () => { + // Emails and composite keys as ids are common. Prefix bookkeeping + // splits on '.', so dots (and %, {, }) in key values are + // percent-escaped: "users.{id:a@b%2Ecom}.name". const state = { users: [{ id: 'a@b.com', name: 'Alice' }] } const registry = createPathSignalRegistry(alienEngine) const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) @@ -115,33 +136,28 @@ describe('edge cases: identity key values', () => { }) it('removing an entity whose id contains dots still prunes its child signals', () => { - // Verified: prune survives dotted ids. Child paths link to their parent - // via lastIndexOf('.'), which lands on the separator AFTER the identity - // segment ("users.{id:a@b.com}" + ".name"), so the child index is - // correct for children. Only the identity segment's own ancestor walk - // is corrupted (garbage prefix entries — see previous test). The dotted - // id bug is memory/bookkeeping pollution, not broken pruning. const prev = { users: [{ id: 'a@b.com', name: 'Alice' }] } const next = { users: [] as { id: string; name: string }[] } + const identityPath = buildIdentityPath('users', 'id', 'a@b.com') + expect(identityPath).toBe('users.{id:a@b%2Ecom}') const registry = createPathSignalRegistry(alienEngine) const proxy = createTrackingProxy(prev, '', registry, registry.proxyCache) expect(proxy.users[0].name).toBe('Alice') - expect(registry.has('users.{id:a@b.com}.name')).toBe(true) + expect(registry.has(`${identityPath}.name`)).toBe(true) reconcileState(prev, next, registry, alienEngine) - expect(registry.has('users.{id:a@b.com}.name')).toBe(false) + expect(registry.has(`${identityPath}.name`)).toBe(false) }) }) describe('edge cases: same-length keyed array replacement (remove + add)', () => { - // diffArrayByKey's general path only builds `seenPrevKeys` when - // next.length < prev.length. A same-length replacement (remove one entity, - // add another) never prunes the removed entity's signals — components - // subscribed to it go permanently stale. + // diffArrayByKey's general path tracks seen keys unconditionally, so a + // same-length replacement (remove one entity, add another) prunes the + // removed entity's signals even though the array length is unchanged. - it.fails('prunes signals for an entity removed in a same-length replacement', () => { + it('prunes signals for an entity removed in a same-length replacement', () => { const shared = { id: 1, text: 'first' } const prev = { items: [shared, { id: 2, text: 'second' }] } const next = { items: [shared, { id: 9, text: 'ninth' }] } @@ -157,7 +173,7 @@ describe('edge cases: same-length keyed array replacement (remove + add)', () => expect(registry.has('items.{id:2}.text')).toBe(false) }) - it.fails('temp-id → server-id swap prunes the temp entity signals', () => { + it('temp-id → server-id swap prunes the temp entity signals', () => { // Optimistic update pattern: entity created with a temp id, server // responds with the real id. Same array length, key value changes. const prev = { items: [{ id: 'temp-1', text: 'draft' }] } @@ -194,10 +210,15 @@ describe('edge cases: same-length keyed array replacement (remove + add)', () => }) describe('edge cases: aliased references (same object at two paths)', () => { - // The proxyCache is keyed by target object identity only. When the same - // raw object is reachable at two paths (state.selected = state.items[0]), - // whichever path is proxied first wins — reads through the second path - // register signals under the FIRST path. + // DOCUMENTED LIMITATION (deliberate tradeoff): the proxyCache is keyed by + // target object identity only. When the same raw object is reachable at + // two paths (state.selected = state.items[0]), whichever path is proxied + // first wins — reads through the second path register signals under the + // FIRST path. Per-path proxies would fix the attribution but break + // `state.selected === state.items[0]` identity checks, which the system + // guarantees. The leaf-object tracker keeps correctness: the alias path + // gets a version-signal dependency, so replacement still re-runs the + // selector (see next test). it.fails('reads through a second path register signals under that path', () => { const item = { id: 1, name: 'a' } @@ -244,12 +265,11 @@ describe('edge cases: aliased references (same object at two paths)', () => { }) describe('edge cases: leafTracker capture through the shared proxy cache', () => { - // Cached proxies close over the leafTracker of the evaluation that - // CREATED them. The proxyCache is registry-wide, so a second component's - // reads through a cached proxy record into the first component's tracker. - // The second component then misses identity-change (ref swap) deps. + // The active leafTracker lives in registry.leafTrackerHolder and is + // swapped in at the start of each evaluation, so cached proxies created + // by an earlier evaluation record into the CURRENT evaluation's tracker. - it.fails('a second evaluation records leaf accesses into its own tracker', () => { + it('a second evaluation records leaf accesses into its own tracker', () => { const state = { settings: { theme: 'dark' } } const registry = createPathSignalRegistry(alienEngine) @@ -281,7 +301,7 @@ describe('edge cases: leafTracker capture through the shared proxy cache', () => }) describe('edge cases: registry lifecycle', () => { - it.fails('prune clears array identity metadata (entityMap retention)', () => { + it('prune clears array identity metadata (entityMap retention)', () => { const state = { items: [{ id: 1, v: 1 }] } const registry = createPathSignalRegistry(alienEngine) const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) @@ -299,13 +319,11 @@ describe('edge cases: registry lifecycle', () => { }) describe('edge cases: NaN leaf values', () => { - it.fails('does not re-run a computed when an untouched NaN leaf is re-diffed', () => { - // CONFIRMED BUG: prev[key] === next[key] is false for NaN, so the diff - // descends and calls update(path, NaN) on every dispatch that touches - // the parent. alien-signals uses !== equality, so setting NaN over NaN - // re-fires the signal — the selector re-runs on every such dispatch. - // Fix: use Object.is semantics in the diff's skip check (and/or the - // signal update). + it('does not re-run a computed when an untouched NaN leaf is re-diffed', () => { + // The diff's skip checks use Object.is semantics for NaN (self-inequal + // values on both sides are treated as equal), so an untouched NaN leaf + // no longer re-fires its signal on every dispatch that touches the + // parent object. const prev = { metrics: { ratio: NaN, count: 1 } } const next = { metrics: { ratio: NaN, count: 2 } } diff --git a/test/signals/edgeCasesReact.spec.tsx b/test/signals/edgeCasesReact.spec.tsx index 62c742f9f..580a9932d 100644 --- a/test/signals/edgeCasesReact.spec.tsx +++ b/test/signals/edgeCasesReact.spec.tsx @@ -84,11 +84,11 @@ describe('edge cases: React integration', () => { }) describe('selector closing over props', () => { - // selectorRef is updated in a layout effect, but nothing invalidates - // the computed when the selector function changes. The hook keeps - // returning the previous selector's cached result until some store - // change happens to fire one of the OLD selector's signals. - it.fails('re-selects when a prop used by the selector changes', () => { + // A render that brings a different selector function triggers a + // render-phase swap: the computed is invalidated and re-evaluated in + // place, so getSnapshot returns the new closure's value in the same + // render (same approach as useSyncExternalStoreWithSelector). + it('re-selects when a prop used by the selector changes', () => { function CounterValue({ which }: { which: 'counter1' | 'counter2' }) { const value = useSignalSelector( (s: TestState) => s.counters[which].value, @@ -171,11 +171,11 @@ describe('edge cases: React integration', () => { expect(getByTestId('identity').textContent).toBe('changed') }) - // Cached proxies close over the leafTracker of the evaluation that - // created them. When component A mounts first and traverses the state, - // component B's identity-only reads are recorded into A's tracker — - // B's computed ends up with no dependencies and never re-runs. - it.fails('a second component still tracks identity changes when another component traversed the same slice first', () => { + // The active leafTracker lives in registry.leafTrackerHolder and is + // swapped in per evaluation, so component B's identity-only reads land + // in B's own tracker even when component A's earlier traversal already + // populated the proxy cache. + it('a second component still tracks identity changes when another component traversed the same slice first', () => { const originalSettings = store.getState().settings function ThemeReader() { @@ -294,7 +294,7 @@ describe('edge cases: React integration', () => { describe('server-side rendering', () => { // useSyncExternalStore is called without a getServerSnapshot argument, // so any server render (Next.js etc.) throws. - it.fails('supports renderToString', () => { + it('supports renderToString', () => { function FilterReader() { const filter = useSignalSelector((s: TestState) => s.filter) return
{filter}
From d78d6ba2f763e8a27e2bc8da1598acaaa4fec213 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Wed, 29 Jul 2026 01:16:30 -0400 Subject: [PATCH 31/95] Escape reserved path characters in object keys and column props - Shared encodePathSegment escapes % . { } " @ in path segments - Proxy pathKeys, diff childPaths, and column paths all encode via it - Fixes signal collisions for dotted keys (RTKQ cache keys), literal '@@keys' state keys, and identity-segment-shaped keys --- src/signals/arrayKeys.ts | 31 ++++--- src/signals/diff.ts | 12 ++- src/signals/pathSignalRegistry.ts | 7 +- src/signals/trackingProxy.ts | 18 +++- test/signals/edgeCases.spec.ts | 136 +++++++++++++++++++++++++++++- 5 files changed, 183 insertions(+), 21 deletions(-) diff --git a/src/signals/arrayKeys.ts b/src/signals/arrayKeys.ts index 17c4da41b..6d01affd1 100644 --- a/src/signals/arrayKeys.ts +++ b/src/signals/arrayKeys.ts @@ -46,20 +46,33 @@ export interface ArrayMeta { entityMap: Map } -// Characters that corrupt path bookkeeping if they appear in a key value: +// Characters that corrupt path bookkeeping if they appear in a path +// segment (an object key or an identity key value): // '.' breaks the ancestor walks (lastIndexOf('.')), '{'/'}' can collide -// with meta segments, '%' is the escape character itself, and '"' is -// reserved for quoting numeric-looking strings (a literal quote in a key -// would otherwise collide with the quoted form of another key). -const NEEDS_ESCAPE = /[%.{}"]/ +// with identity/column segments, '%' is the escape character itself, +// '"' is reserved for quoting numeric-looking strings (a literal quote +// in a key would otherwise collide with the quoted form of another key), +// and '@' guards meta segments — a state key literally named '@@keys' +// must not collide with the `parent.@@keys` meta signal. +const NEEDS_ESCAPE = /[%.{}"@]/ -function escapeSegment(s: string): string { +/** + * Encode a single path segment so it can be safely joined with '.' into + * a path string. Reserved characters are %-escaped. The common case + * (no reserved characters) returns the input string unchanged. + * Used for object property keys, identity key values, and column props. + * @param s - The raw segment (object key or stringified key value) + * @returns The encoded segment + */ +export function encodePathSegment(s: string): string { + if (!NEEDS_ESCAPE.test(s)) return s return s .replace(/%/g, '%25') .replace(/\./g, '%2E') .replace(/\{/g, '%7B') .replace(/\}/g, '%7D') .replace(/"/g, '%22') + .replace(/@/g, '%40') } /** @@ -78,11 +91,9 @@ function encodeKeyValue(keyValue: string | number): string { if (typeof keyValue === 'number') { // Integers (the common case) can't contain escapable characters if (Number.isInteger(keyValue)) return String(keyValue) - return escapeSegment(String(keyValue)) + return encodePathSegment(String(keyValue)) } - const escaped = NEEDS_ESCAPE.test(keyValue) - ? escapeSegment(keyValue) - : keyValue + const escaped = encodePathSegment(keyValue) // Quote strings that would collide with a number's rendering return String(Number(keyValue)) === keyValue ? '"' + escaped + '"' : escaped } diff --git a/src/signals/diff.ts b/src/signals/diff.ts index 9469e266d..d622ae56d 100644 --- a/src/signals/diff.ts +++ b/src/signals/diff.ts @@ -1,5 +1,6 @@ import { buildIdentityPath, + encodePathSegment, findKeyField, getKeyValue, } from './arrayKeys' @@ -52,7 +53,11 @@ function diffObject( if (prevVal === nextVal || (prevVal !== prevVal && nextVal !== nextVal)) continue - const childPath = parentPath ? parentPath + '.' + key : key + // Keys are encoded to match the proxy's pathKey construction — + // reserved path characters in state keys (dots, braces, '@') are + // %-escaped on both sides. + const segment = encodePathSegment(key) + const childPath = parentPath ? parentPath + '.' + segment : segment if (registry.hasPrefix(childPath)) { diffAndUpdateSignals(prevVal, nextVal, childPath, registry) } @@ -64,9 +69,8 @@ function diffObject( for (let i = 0; i < prevKeys.length; i++) { if (!(prevKeys[i] in next)) { - const childPath = parentPath - ? parentPath + '.' + prevKeys[i] - : prevKeys[i] + const segment = encodePathSegment(prevKeys[i]) + const childPath = parentPath ? parentPath + '.' + segment : segment registry.prune(childPath) } } diff --git a/src/signals/pathSignalRegistry.ts b/src/signals/pathSignalRegistry.ts index d1a93b0ce..60524b714 100644 --- a/src/signals/pathSignalRegistry.ts +++ b/src/signals/pathSignalRegistry.ts @@ -1,3 +1,4 @@ +import { encodePathSegment } from './arrayKeys' import type { ArrayMeta } from './arrayKeys' import type { LeafObjectTracker, ProxyCache } from './trackingProxy' import type { PathKey, ReactiveSignal, SignalEngine } from './types' @@ -91,7 +92,11 @@ export interface PathSignalRegistry { * @returns The column signal path key */ function buildColumnPath(arrayPath: string, prop: string): string { - return arrayPath + '.{*}.' + prop + // prop comes from user state (a scanned element property name), so it + // is encoded like any other path segment. Both trackColumn (proxy + // side) and bumpColumn (diff side) route through here, so the two + // sides always agree. + return arrayPath + '.{*}.' + encodePathSegment(prop) } /** diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index d61dd597e..8af9a5e2f 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -1,4 +1,9 @@ -import { buildIdentityPath, findKeyField, getKeyValue } from './arrayKeys' +import { + buildIdentityPath, + encodePathSegment, + findKeyField, + getKeyValue, +} from './arrayKeys' import { isOverriddenArrayMethod, createArrayMethodInterceptor, @@ -185,11 +190,15 @@ export function createTrackingProxy( // this proxy's lifetime, so path keys are stable. Reusing the same // string instance avoids re-allocating on every read AND lets V8 cache // the string hash, speeding up the registry Map lookups downstream. + // Property keys are encoded so reserved path characters in state keys + // (dots in RTKQ cache keys, literal '@@keys', etc.) can't collide with + // other paths or meta segments. The cache amortizes the encoding. const pathKeyCache = new Map() function getPathKey(prop: string): string { let key = pathKeyCache.get(prop) if (key === undefined) { - key = parentPath ? parentPath + '.' + prop : prop + const segment = encodePathSegment(prop) + key = parentPath ? parentPath + '.' + segment : segment pathKeyCache.set(prop, key) } return key @@ -327,8 +336,11 @@ export function createTrackingProxy( }, // Track when selectors iterate keys (Object.keys, for...in, .map, .filter, etc.) + // Built raw (NOT via getPathKey): '@@keys' is a meta segment, and + // getPathKey would %-escape its '@'s like a state key's. ownKeys(_obj) { - registry.getOrCreate(getPathKey('@@keys'), Reflect.ownKeys(target)).get() + const keysPath = parentPath ? parentPath + '.@@keys' : '@@keys' + registry.getOrCreate(keysPath, Reflect.ownKeys(target)).get() return Reflect.ownKeys(target) }, diff --git a/test/signals/edgeCases.spec.ts b/test/signals/edgeCases.spec.ts index 1607187eb..faa8ab2eb 100644 --- a/test/signals/edgeCases.spec.ts +++ b/test/signals/edgeCases.spec.ts @@ -125,8 +125,8 @@ describe('edge cases: identity key values', () => { it('key values containing dots do not corrupt the prefix index', () => { // Emails and composite keys as ids are common. Prefix bookkeeping - // splits on '.', so dots (and %, {, }) in key values are - // percent-escaped: "users.{id:a@b%2Ecom}.name". + // splits on '.', so dots (and %, {, }, ", @) in key values are + // percent-escaped: "users.{id:a%40b%2Ecom}.name". const state = { users: [{ id: 'a@b.com', name: 'Alice' }] } const registry = createPathSignalRegistry(alienEngine) const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) @@ -139,7 +139,7 @@ describe('edge cases: identity key values', () => { const prev = { users: [{ id: 'a@b.com', name: 'Alice' }] } const next = { users: [] as { id: string; name: string }[] } const identityPath = buildIdentityPath('users', 'id', 'a@b.com') - expect(identityPath).toBe('users.{id:a@b%2Ecom}') + expect(identityPath).toBe('users.{id:a%40b%2Ecom}') const registry = createPathSignalRegistry(alienEngine) const proxy = createTrackingProxy(prev, '', registry, registry.proxyCache) @@ -152,6 +152,136 @@ describe('edge cases: identity key values', () => { }) }) +describe('edge cases: object keys with reserved path characters', () => { + // Object property keys are encoded with the same %-escaping as identity + // key values. Without encoding, state like { 'a.b': 1, a: { b: 2 } } + // maps both locations to the path string "a.b" — updates clobber each + // other's signal and one side goes permanently stale. + + it('a dotted key and a nested path do not share a signal', () => { + const prev = { 'a.b': 1, a: { b: 2 } } + const registry = createPathSignalRegistry(alienEngine) + let current: typeof prev = prev + + let dottedRuns = 0 + const dotted = alienEngine.computed(() => { + dottedRuns++ + const p = createTrackingProxy(current, '', registry, registry.proxyCache) + return p['a.b'] + }) + let nestedRuns = 0 + const nested = alienEngine.computed(() => { + nestedRuns++ + const p = createTrackingProxy(current, '', registry, registry.proxyCache) + return p.a.b + }) + expect(dotted.get()).toBe(1) + expect(nested.get()).toBe(2) + + // Change ONLY the nested a.b — the dotted key's computed must not + // re-run, and its value must survive. + const next = { 'a.b': 1, a: { b: 99 } } + current = next + reconcileState(prev, next, registry, alienEngine) + expect(nested.get()).toBe(99) + expect(nestedRuns).toBe(2) + expect(dotted.get()).toBe(1) + expect(dottedRuns).toBe(1) + + // Now change ONLY the dotted key — this was the permanently-stale + // case before encoding (the signal already held 99, so the real + // change no-opped). + const final = { 'a.b': 42, a: { b: 99 } } + current = final + reconcileState(next, final, registry, alienEngine) + expect(dotted.get()).toBe(42) + expect(dottedRuns).toBe(2) + expect(nested.get()).toBe(99) + expect(nestedRuns).toBe(2) + }) + + it('RTK Query style cache keys with dots and quotes stay reactive', () => { + const key = 'getUser("a.b@c.com")' + const prev = { queries: { [key]: { status: 'pending' } } } + const next = { queries: { [key]: { status: 'fulfilled' } } } + + const registry = createPathSignalRegistry(alienEngine) + let runs = 0 + const computed = alienEngine.computed(() => { + runs++ + const p = createTrackingProxy( + runs === 1 ? prev : next, + '', + registry, + registry.proxyCache, + ) + return p.queries[key].status + }) + expect(computed.get()).toBe('pending') + + reconcileState(prev, next, registry, alienEngine) + expect(computed.get()).toBe('fulfilled') + expect(runs).toBe(2) + }) + + it('a state key literally named "@@keys" does not collide with the keys meta signal', () => { + const prev = { obj: { '@@keys': 'v1', x: 1 } } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(prev, '', registry, registry.proxyCache) + + expect(proxy.obj['@@keys']).toBe('v1') + // The literal key's signal lives at the encoded path. + expect(registry.has('obj.%40%40keys')).toBe(true) + expect(registry.has('obj.@@keys')).toBe(false) + + // Iterating keys registers the meta signal at the raw path. + expect(Object.keys(proxy.obj)).toEqual(['@@keys', 'x']) + expect(registry.has('obj.@@keys')).toBe(true) + + // Updating the literal '@@keys' property fires its own signal, and + // the key set is unchanged, so the meta signal must not fire. + let current = prev + let literalRuns = 0 + const literal = alienEngine.computed(() => { + literalRuns++ + const p = createTrackingProxy(current, '', registry, registry.proxyCache) + return p.obj['@@keys'] + }) + expect(literal.get()).toBe('v1') + + const next = { obj: { '@@keys': 'v2', x: 1 } } + current = next + reconcileState(prev, next, registry, alienEngine) + expect(literal.get()).toBe('v2') + expect(literalRuns).toBe(2) + }) + + it('a state key shaped like an identity segment does not collide with identity paths', () => { + const state = { + items: [{ id: 1, name: 'real' }], + lookup: { '{id:1}': 'literal' }, + } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy.items[0].name).toBe('real') + expect(proxy.lookup['{id:1}']).toBe('literal') + expect(registry.has('lookup.%7Bid:1%7D')).toBe(true) + expect(registry.has('lookup.{id:1}')).toBe(false) + }) + + it('column signals encode scanned property names', () => { + const state = { rows: [{ id: 1, 'a.b': 'x' }] } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + const found = proxy.rows.find((r) => r['a.b'] === 'x') + expect(found).toBeDefined() + expect(registry.has('rows.{*}.a%2Eb')).toBe(true) + expect(registry.has('rows.{*}.a.b')).toBe(false) + }) +}) + describe('edge cases: same-length keyed array replacement (remove + add)', () => { // diffArrayByKey's general path tracks seen keys unconditionally, so a // same-length replacement (remove one entity, add another) prunes the From 74af43d2a611ec33b009d61f5864e1d9308196b3 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Wed, 29 Jul 2026 01:20:12 -0400 Subject: [PATCH 32/95] Add recursive untrack at the hook boundary Selector results are stripped of tracking proxies before crossing into React (proxy-memoize approach): in-place swap, stops at proxies, cycle guard, frozen containers skipped. Strategy selectable via setUntrackStrategy for benchmarking. --- src/signals/trackingProxy.ts | 12 ++ src/signals/untrack.ts | 103 +++++++++++++ src/signals/useSignalSelector.ts | 7 +- test/signals/untrack.spec.tsx | 248 +++++++++++++++++++++++++++++++ 4 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 src/signals/untrack.ts create mode 100644 test/signals/untrack.spec.tsx diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index 8af9a5e2f..60c86eb19 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -130,6 +130,18 @@ export function getProxyPath(value: unknown): string | undefined { * @param value - The value to unwrap (proxy or raw) * @returns The raw target object, or the original value if not a tracking proxy */ +/** + * Get the raw target of a tracking proxy, or undefined if the value is + * not a tracking proxy. Unlike unwrap(), this distinguishes "was a + * proxy" from "was already raw", which the result-untracking walk needs + * (proxies mark subtree boundaries: everything below a target is raw). + * @param value - The value to check + * @returns The raw target, or undefined if not a tracking proxy + */ +export function getProxyTarget(value: object): object | undefined { + return proxyTargetMap.get(value) +} + export function unwrap(value: T): T { if (value !== null && typeof value === 'object') { const target = proxyTargetMap.get(value as object) diff --git a/src/signals/untrack.ts b/src/signals/untrack.ts new file mode 100644 index 000000000..96d185b04 --- /dev/null +++ b/src/signals/untrack.ts @@ -0,0 +1,103 @@ +import { getProxyTarget } from './trackingProxy' + +/** + * Strategy for stripping tracking proxies from selector results before + * they cross the hook boundary into React. + * + * - 'recursive': proxy-memoize style — walk the result, swap every + * tracking proxy for its raw target in place, stop descending at each + * proxy (everything below a raw target is raw). + * - 'registry': Immer/Mutative-style notification callbacks — proxies + * handed out during an evaluation register themselves; finalization + * uses those records instead of a blind recursive walk. + * - 'none': hand the tracking proxies to React unchanged (the old + * behavior). Kept for benchmarking the untrack overhead itself. + */ +export type UntrackStrategy = 'recursive' | 'registry' | 'none' + +let strategy: UntrackStrategy = 'recursive' + +export function setUntrackStrategy(next: UntrackStrategy): void { + strategy = next +} + +export function getUntrackStrategy(): UntrackStrategy { + return strategy +} + +/** + * Strip tracking proxies from a selector result so components, effects, + * DevTools, and dispatch payloads only ever see raw (frozen) state. + * + * Derived containers built by the selector are mutated IN PLACE (proxy + * values swapped for raw targets), preserving container identity across + * evaluations — a memoized (reselect-cached) array keeps its identity, + * and re-untracking it is an idempotent no-op. + * @param result - The selector's return value + * @returns The result with all tracking proxies replaced by raw state + */ +export function untrackResult(result: R): R { + if (strategy === 'none') return result + return untrackRecursive(result, null) +} + +function untrackRecursive(value: R, seen: WeakSet | null): R { + if (value === null || typeof value !== 'object') return value + + // Tracking proxy: swap for the raw target. No descent — everything + // reachable from a raw state object is raw. + const target = getProxyTarget(value) + if (target !== undefined) return target as R + + // Frozen non-proxy objects can't be repaired in place. Raw state + // subtrees (already frozen, can't contain proxies) are the common + // case — skipping them also avoids walking large unwrapped subtrees. + // A user-frozen derived container holding proxies is unsupported. + if (Object.isFrozen(value)) return value + + if (seen === null) seen = new WeakSet() + else if (seen.has(value)) return value + seen.add(value) + + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + const v: unknown = value[i] + const u = untrackRecursive(v, seen) + if (u !== v) value[i] = u + } + return value + } + + if (value instanceof Map) { + for (const [k, v] of value) { + const u = untrackRecursive(v, seen) + if (u !== v) value.set(k, u) + } + return value + } + + if (value instanceof Set) { + let replacements: Array<[unknown, unknown]> | null = null + for (const v of value) { + const u = untrackRecursive(v, seen) + if (u !== v) (replacements ??= []).push([v, u]) + } + if (replacements) { + // Rebuild preserving insertion order is not possible with targeted + // deletes; proxies in Sets are rare enough that delete+add is fine. + for (const [v, u] of replacements) { + value.delete(v) + value.add(u) + } + } + return value + } + + const record = value as Record + for (const k of Object.keys(record)) { + const v = record[k] + const u = untrackRecursive(v, seen) + if (u !== v) record[k] = u + } + return value +} diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index 5b0d6b717..c74f29d54 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -5,6 +5,7 @@ import { getProxyPath, type LeafObjectTracker, } from './trackingProxy' +import { untrackResult } from './untrack' const { useRef, useMemo, useEffect, useSyncExternalStore } = React @@ -117,7 +118,11 @@ export function useSignalSelector( } } - return result + // Strip tracking proxies before the result crosses into React. + // Must run AFTER the dependency reads above (they need the proxies + // in hand). Everything downstream — equalityFn, getSnapshot, + // components, dispatch payloads — sees only raw state. + return untrackResult(result) }) // Initialize with current value diff --git a/test/signals/untrack.spec.tsx b/test/signals/untrack.spec.tsx new file mode 100644 index 000000000..9fff42ba8 --- /dev/null +++ b/test/signals/untrack.spec.tsx @@ -0,0 +1,248 @@ +/** + * Tests for untrackResult — stripping tracking proxies from selector + * results at the hook boundary. + * + * Tracking proxies are evaluation-scoped tools. Components, effects, + * refs, DevTools, and dispatch payloads should only ever see raw + * (Immer-frozen) state. These tests pin that boundary. + */ +import { configureStore, createSlice } from '@reduxjs/toolkit' +import * as rtl from '@testing-library/react' +import * as React from 'react' +import { describe, expect, it, afterEach } from 'vitest' + +import { SignalProvider } from '../../src/signals/SignalProvider' +import { useSignalSelector } from '../../src/signals/useSignalSelector' +import { alienEngine } from '../../src/signals/engine' +import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import { createTrackingProxy, getProxyPath } from '../../src/signals/trackingProxy' +import { + untrackResult, + setUntrackStrategy, + getUntrackStrategy, +} from '../../src/signals/untrack' + +const isTrackingProxy = (v: unknown) => + v !== null && typeof v === 'object' && getProxyPath(v) !== undefined + +afterEach(() => { + rtl.cleanup() + setUntrackStrategy('recursive') +}) + +interface Todo { + id: number + text: string + done: boolean +} + +function makeStore() { + const todosSlice = createSlice({ + name: 'todos', + initialState: { + items: [ + { id: 1, text: 'one', done: false }, + { id: 2, text: 'two', done: true }, + ] as Todo[], + meta: { count: 2 }, + }, + reducers: { + toggle(state, action: { payload: number }) { + const t = state.items.find((t) => t.id === action.payload) + if (t) t.done = !t.done + }, + replaceAll(state, action: { payload: Todo[] }) { + state.items = action.payload + }, + }, + }) + const store = configureStore({ reducer: { todos: todosSlice.reducer } }) + return { store, actions: todosSlice.actions } +} + +type AppState = ReturnType['store']['getState']> + +function renderWithCapture( + store: ReturnType['store'], + selector: (s: AppState) => R, +) { + const captured: R[] = [] + function Comp() { + const value = useSignalSelector(selector) + captured.push(value) + return null + } + rtl.render( + + + , + ) + return captured +} + +describe('untrack at the hook boundary (recursive strategy)', () => { + it('a selector returning a state object yields the raw object, === to store state', () => { + const { store } = makeStore() + const captured = renderWithCapture(store, (s) => s.todos.items[0]) + + expect(captured.length).toBeGreaterThan(0) + const result = captured[captured.length - 1] + expect(isTrackingProxy(result)).toBe(false) + expect(result).toBe(store.getState().todos.items[0]) + }) + + it('a derived container has all proxy values swapped for raw state, in place', () => { + const { store } = makeStore() + const captured = renderWithCapture(store, (s) => ({ + first: s.todos.items[0], + all: s.todos.items, + nested: { meta: s.todos.meta }, + count: s.todos.meta.count, + })) + + const result = captured[captured.length - 1] + expect(isTrackingProxy(result.first)).toBe(false) + expect(isTrackingProxy(result.all)).toBe(false) + expect(isTrackingProxy(result.nested.meta)).toBe(false) + expect(result.first).toBe(store.getState().todos.items[0]) + expect(result.all).toBe(store.getState().todos.items) + expect(result.nested.meta).toBe(store.getState().todos.meta) + }) + + it('derived arrays (filter/slice results) contain raw elements', () => { + const { store } = makeStore() + const captured = renderWithCapture(store, (s) => + s.todos.items.filter((t) => t.done), + ) + + const result = captured[captured.length - 1] + expect(result).toHaveLength(1) + expect(isTrackingProxy(result[0])).toBe(false) + expect(result[0]).toBe(store.getState().todos.items[1]) + }) + + it('re-dispatching a selected value stores raw state, not proxies', () => { + const { store, actions } = makeStore() + const captured = renderWithCapture(store, (s) => s.todos.items) + + const selected = captured[captured.length - 1] + // Simulate the footgun: spread selected data back into an action + rtl.act(() => { + store.dispatch(actions.replaceAll(selected.map((t) => ({ ...t })))) + }) + + for (const item of store.getState().todos.items) { + expect(isTrackingProxy(item)).toBe(false) + for (const v of Object.values(item)) { + expect(isTrackingProxy(v)).toBe(false) + } + } + }) + + it('results stay raw across store-driven re-evaluations', () => { + const { store, actions } = makeStore() + const captured = renderWithCapture(store, (s) => s.todos.items[0]) + + rtl.act(() => { + store.dispatch(actions.toggle(1)) + }) + + const result = captured[captured.length - 1] + expect(isTrackingProxy(result)).toBe(false) + expect(result).toBe(store.getState().todos.items[0]) + expect(result.done).toBe(true) + }) +}) + +describe('untrackResult unit behavior', () => { + function setup() { + const registry = createPathSignalRegistry(alienEngine) + const state = { + a: { x: 1 }, + list: [{ id: 1, v: 'one' }], + } + Object.freeze(state.a) + Object.freeze(state.list[0]) + Object.freeze(state.list) + Object.freeze(state) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + return { state, proxy } + } + + it('returns primitives unchanged', () => { + expect(untrackResult(42)).toBe(42) + expect(untrackResult('x')).toBe('x') + expect(untrackResult(null)).toBe(null) + expect(untrackResult(undefined)).toBe(undefined) + }) + + it('swaps a bare proxy for its target', () => { + const { state, proxy } = setup() + expect(untrackResult(proxy.a)).toBe(state.a) + }) + + it('mutates derived containers in place, preserving container identity', () => { + const { state, proxy } = setup() + const container = { child: proxy.a, arr: [proxy.list[0]] } + const result = untrackResult(container) + expect(result).toBe(container) + expect(result.child).toBe(state.a) + expect(result.arr[0]).toBe(state.list[0]) + }) + + it('is idempotent (second pass over a memoized container is a no-op)', () => { + const { state, proxy } = setup() + const container = { child: proxy.a } + const once = untrackResult(container) + const twice = untrackResult(once) + expect(twice).toBe(container) + expect(twice.child).toBe(state.a) + }) + + it('handles Map values', () => { + const { state, proxy } = setup() + const m = new Map([['a', proxy.a]]) + const result = untrackResult(m) + expect(result).toBe(m) + expect(result.get('a')).toBe(state.a) + }) + + it('handles Set values', () => { + const { state, proxy } = setup() + const s = new Set([proxy.a]) + const result = untrackResult(s) + expect(result).toBe(s) + expect(result.has(state.a)).toBe(true) + expect(result.size).toBe(1) + }) + + it('handles self-referential containers without looping', () => { + const { state, proxy } = setup() + interface Cyclic { + child: object + self?: Cyclic + } + const container: Cyclic = { child: proxy.a } + container.self = container + const result = untrackResult(container) + expect(result.child).toBe(state.a) + expect(result.self).toBe(container) + }) + + it('documented limitation: frozen derived containers holding proxies are skipped', () => { + const { proxy } = setup() + const frozen = Object.freeze({ child: proxy.a }) + const result = untrackResult(frozen) + // Can't repair in place — proxies survive. Frozen raw state subtrees + // are skipped for the same check, which is the common (safe) case. + expect(isTrackingProxy(result.child)).toBe(true) + }) + + it("strategy 'none' passes proxies through untouched", () => { + const { proxy } = setup() + setUntrackStrategy('none') + expect(getUntrackStrategy()).toBe('none') + const container = { child: proxy.a } + expect(untrackResult(container).child).toBe(proxy.a) + }) +}) From ad7498e7244359f7bb25022e652f6ea4cd6af7bc Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Wed, 29 Jul 2026 01:25:03 -0400 Subject: [PATCH 33/95] Add untrack boundary benchmark Measures full-eval cost (proxy + selector + untrack) per result shape with the strategy toggled. Recursive strategy: trivial results free, proxy-dense derived arrays pay ~13-25% (map 1k +215us, 10k +3.1ms). --- test/benchmarks/untrack/runner.ts | 214 ++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 test/benchmarks/untrack/runner.ts diff --git a/test/benchmarks/untrack/runner.ts b/test/benchmarks/untrack/runner.ts new file mode 100644 index 000000000..af1e84409 --- /dev/null +++ b/test/benchmarks/untrack/runner.ts @@ -0,0 +1,214 @@ +/** + * Untrack-boundary micro-benchmark. + * + * Measures the cost of stripping tracking proxies from selector results + * (untrackResult) across result shapes, by running each selector through + * a tracking proxy with the untrack strategy toggled: + * + * overhead = (recursive) - (none) + * + * Usage: + * npx tsx test/benchmarks/untrack/runner.ts + * npx tsx test/benchmarks/untrack/runner.ts --iterations 5000 + */ +import { alienEngine } from '../../../src/signals/engine' +import { createPathSignalRegistry } from '../../../src/signals/pathSignalRegistry' +import { createTrackingProxy } from '../../../src/signals/trackingProxy' +import { + setUntrackStrategy, + untrackResult, + type UntrackStrategy, +} from '../../../src/signals/untrack' +import { deepFreeze } from '../reconcile/stateBuilders' + +const WARMUP = 500 +const DEFAULT_ITERATIONS = 5000 + +interface Entity { + id: number + name: string + email: string + status: string + score: number +} + +function makeEntity(i: number): Entity { + return { + id: i, + name: `User ${i}`, + email: `user${i}@example.com`, + status: i % 3 === 0 ? 'active' : 'inactive', + score: i * 10, + } +} + +function buildState(entityCount: number) { + const entities: Entity[] = [] + for (let i = 0; i < entityCount; i++) entities.push(makeEntity(i)) + return deepFreeze({ + entities, + meta: { count: entityCount, updatedAt: 12345 }, + settings: { theme: 'dark', fontSize: 12 }, + }) +} + +type State = ReturnType + +interface UntrackScenario { + name: string + description: string + entityCount: number + selector: (s: State) => unknown +} + +const scenarios: UntrackScenario[] = [ + { + name: 'return-primitive', + description: 'Selector returns a number (untrack floor: typeof check)', + entityCount: 1000, + selector: (s) => s.meta.count, + }, + { + name: 'return-one-proxy', + description: 'Selector returns one state object (untrack: 1 WeakMap hit)', + entityCount: 1000, + selector: (s) => s.settings, + }, + { + name: 'return-big-subtree', + description: 'Selector returns the 10k-entity array proxy (walk stops at proxy)', + entityCount: 10000, + selector: (s) => s.entities, + }, + { + name: 'filter-half-1k', + description: 'filter() over 1k entities, ~500 proxies in result array', + entityCount: 1000, + selector: (s) => s.entities.filter((e) => e.status === 'inactive'), + }, + { + name: 'map-identity-1k', + description: 'map(e => e) over 1k entities: 1k proxies in a fresh array', + entityCount: 1000, + selector: (s) => s.entities.map((e) => e), + }, + { + name: 'map-identity-10k', + description: 'map(e => e) over 10k entities: 10k proxies in a fresh array', + entityCount: 10000, + selector: (s) => s.entities.map((e) => e), + }, + { + name: 'map-containers-1k', + description: 'map to fresh {id, entity} wrappers: 1k containers each holding a proxy', + entityCount: 1000, + selector: (s) => s.entities.map((e) => ({ id: e.id, entity: e })), + }, + { + name: 'deep-container', + description: 'Nested derived object embedding proxies at several depths', + entityCount: 1000, + selector: (s) => ({ + top: s.entities[0], + group: { + settings: s.settings, + inner: { meta: s.meta, first: s.entities[1] }, + }, + list: [s.entities[2], s.entities[3], s.entities[4]], + }), + }, +] + +interface BenchRow { + name: string + noneMs: number + recursiveMs: number + overheadMs: number + overheadPct: number +} + +function timeStrategy( + scenario: UntrackScenario, + strategy: UntrackStrategy, + state: State, + iterations: number, +): number { + const registry = createPathSignalRegistry(alienEngine) + setUntrackStrategy(strategy) + + const run = () => { + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + const result = scenario.selector(proxy as State) + return untrackResult(result) + } + + for (let i = 0; i < WARMUP; i++) run() + + const start = performance.now() + for (let i = 0; i < iterations; i++) run() + return performance.now() - start +} + +function main() { + const args = process.argv.slice(2) + let iterations = DEFAULT_ITERATIONS + let filterName: string | undefined + for (let i = 0; i < args.length; i++) { + if (args[i] === '--iterations' || args[i] === '-n') { + iterations = parseInt(args[i + 1], 10) + i++ + } else if (!args[i].startsWith('-')) { + filterName = args[i] + } + } + + let selected = scenarios + if (filterName) { + selected = scenarios.filter((s) => s.name === filterName) + if (selected.length === 0) { + console.error(`Unknown scenario: ${filterName}`) + console.error(`Available: ${scenarios.map((s) => s.name).join(', ')}`) + process.exit(1) + } + } + + console.log(`\nUntrack Boundary Benchmark`) + console.log(`Warmup: ${WARMUP} | Iterations: ${iterations} (full eval: proxy + selector + untrack)\n`) + + const rows: BenchRow[] = [] + for (const scenario of selected) { + process.stdout.write(` Running ${scenario.name}...`) + const state = buildState(scenario.entityCount) + const noneMs = timeStrategy(scenario, 'none', state, iterations) + const recursiveMs = timeStrategy(scenario, 'recursive', state, iterations) + const overheadMs = recursiveMs - noneMs + rows.push({ + name: scenario.name, + noneMs, + recursiveMs, + overheadMs, + overheadPct: (overheadMs / noneMs) * 100, + }) + console.log( + ` none=${(noneMs / iterations).toFixed(4)} recursive=${(recursiveMs / iterations).toFixed(4)} ms/iter`, + ) + } + setUntrackStrategy('recursive') + + console.log( + `\n${'Scenario'.padEnd(20)} | ${'none µs/it'.padStart(11)} | ${'rec µs/it'.padStart(11)} | ${'ovh µs/it'.padStart(11)} | ${'overhead %'.padStart(10)}`, + ) + console.log(`${'-'.repeat(20)}-+-${'-'.repeat(11)}-+-${'-'.repeat(11)}-+-${'-'.repeat(11)}-+-${'-'.repeat(10)}`) + for (const r of rows) { + const us = (ms: number) => ((ms / iterations) * 1000).toFixed(2).padStart(11) + console.log( + `${r.name.padEnd(20)} | ${us(r.noneMs)} | ${us(r.recursiveMs)} | ${us(r.overheadMs)} | ${r.overheadPct.toFixed(1).padStart(9)}%`, + ) + } + + console.log('\nScenario descriptions:') + for (const s of selected) console.log(` ${s.name}: ${s.description}`) + console.log() +} + +main() From b5fb99dd0bbf5ba777316ed8fe300887bf3d8e5b Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Wed, 29 Jul 2026 01:51:52 -0400 Subject: [PATCH 34/95] Add registry (Immer-style) untrack strategy and benchmark both Ports the finalization-queue idea from Immer's callback rewrite: library-built containers (filter/slice results) register as escape candidates during evaluation; the boundary drains the queue and sweeps the result with a shared seen-set. Benchmarks (median of 5 interleaved samples) show it never beats the recursive walk - without write traps on user containers there is nothing for the queue to save. --- src/signals/arrayMethodOverrides.ts | 5 ++ src/signals/untrack.ts | 32 +++++++ src/signals/untrackQueue.ts | 42 +++++++++ src/signals/useSignalSelector.ts | 5 +- test/benchmarks/untrack/runner.ts | 65 ++++++++++---- test/signals/untrack.spec.tsx | 129 ++++++++++++++++++++++++++++ 6 files changed, 260 insertions(+), 18 deletions(-) create mode 100644 src/signals/untrackQueue.ts diff --git a/src/signals/arrayMethodOverrides.ts b/src/signals/arrayMethodOverrides.ts index b28a6ae5d..1592a6fbb 100644 --- a/src/signals/arrayMethodOverrides.ts +++ b/src/signals/arrayMethodOverrides.ts @@ -1,5 +1,6 @@ import type { PathSignalRegistry, StructureKind } from './pathSignalRegistry' import { registerRecordingHolder, unwrap } from './trackingProxy' +import { registerEscapeCandidate } from './untrackQueue' /** * Non-mutating array methods that we override on the tracking proxy. @@ -330,6 +331,9 @@ export function createArrayMethodInterceptor( } result = matches matched = matches.length > 0 + // Registry untrack strategy: we created this proxy-bearing array, + // so register it for direct finalization (Immer-callback analog). + registerEscapeCandidate(matches) } else if (FIND_METHODS.has(m)) { const isForward = m === 'find' const step = isForward ? 1 : -1 @@ -377,6 +381,7 @@ export function createArrayMethodInterceptor( for (let i = start; i < end; i++) { result.push((proxy as Record)[i]) } + registerEscapeCandidate(result) return result } diff --git a/src/signals/untrack.ts b/src/signals/untrack.ts index 96d185b04..4c66436ef 100644 --- a/src/signals/untrack.ts +++ b/src/signals/untrack.ts @@ -1,4 +1,9 @@ import { getProxyTarget } from './trackingProxy' +import { + beginEvaluationQueue, + clearEvaluationQueue, + drainEvaluationQueue, +} from './untrackQueue' /** * Strategy for stripping tracking proxies from selector results before @@ -25,6 +30,17 @@ export function getUntrackStrategy(): UntrackStrategy { return strategy } +/** + * Called at the start of each selector evaluation. For the 'registry' + * strategy this activates the finalization queue so array-method + * interceptors can register the containers they create; for the other + * strategies it deactivates the queue so registration is a no-op. + */ +export function beginUntrackEvaluation(): void { + if (strategy === 'registry') beginEvaluationQueue() + else clearEvaluationQueue() +} + /** * Strip tracking proxies from a selector result so components, effects, * DevTools, and dispatch payloads only ever see raw (frozen) state. @@ -38,6 +54,22 @@ export function getUntrackStrategy(): UntrackStrategy { */ export function untrackResult(result: R): R { if (strategy === 'none') return result + + if (strategy === 'registry') { + // Immer/Mutative-style finalization: first finalize the containers + // the library itself created during this evaluation (registered by + // the array-method interceptors), then sweep the result for + // user-built containers. The shared seen-set means the sweep skips + // anything the queue already finalized. + const containers = drainEvaluationQueue() + if (containers.length === 0) return untrackRecursive(result, null) + const seen = new WeakSet() + for (const container of containers) { + untrackRecursive(container, seen) + } + return untrackRecursive(result, seen) + } + return untrackRecursive(result, null) } diff --git a/src/signals/untrackQueue.ts b/src/signals/untrackQueue.ts new file mode 100644 index 000000000..cc07d43dc --- /dev/null +++ b/src/signals/untrackQueue.ts @@ -0,0 +1,42 @@ +/** + * Evaluation-scoped finalization queue for the 'registry' untrack strategy. + * + * Port of Immer/Mutative's finalization-callback idea: at the points where + * the LIBRARY creates proxy-bearing containers (filter/slice interceptor + * results), register the container so untrack can finalize it directly, + * instead of discovering it by walking the selector result. + * + * Standalone module with no imports: arrayMethodOverrides (registration + * side) and untrack (drain side) both import it, and trackingProxy <-> + * arrayMethodOverrides already form an import cycle we must not extend. + * + * The queue is null unless a registry-strategy evaluation is active, so + * registration is a no-op for the other strategies. + */ + +let queue: object[] | null = null + +export function beginEvaluationQueue(): void { + queue = [] +} + +export function clearEvaluationQueue(): void { + queue = null +} + +/** + * Record a library-created container holding tracking proxies (e.g. a + * filter() result array). No-op unless a registry-strategy evaluation + * is active. + */ +export function registerEscapeCandidate(container: object): void { + queue?.push(container) +} + +/** Take the current queue contents, leaving an empty active queue. */ +export function drainEvaluationQueue(): object[] { + if (queue === null || queue.length === 0) return [] + const drained = queue + queue = [] + return drained +} diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index c74f29d54..260fbeed2 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -5,7 +5,7 @@ import { getProxyPath, type LeafObjectTracker, } from './trackingProxy' -import { untrackResult } from './untrack' +import { beginUntrackEvaluation, untrackResult } from './untrack' const { useRef, useMemo, useEffect, useSyncExternalStore } = React @@ -87,6 +87,9 @@ export function useSignalSelector( leafTracker.accessedObjects.clear() leafTracker.traversedPaths.clear() + // Activate (or deactivate) the untrack finalization queue for this + // evaluation, per the current strategy. + beginUntrackEvaluation() const proxy = createTrackingProxy( state, diff --git a/test/benchmarks/untrack/runner.ts b/test/benchmarks/untrack/runner.ts index af1e84409..32c736945 100644 --- a/test/benchmarks/untrack/runner.ts +++ b/test/benchmarks/untrack/runner.ts @@ -15,6 +15,7 @@ import { alienEngine } from '../../../src/signals/engine' import { createPathSignalRegistry } from '../../../src/signals/pathSignalRegistry' import { createTrackingProxy } from '../../../src/signals/trackingProxy' import { + beginUntrackEvaluation, setUntrackStrategy, untrackResult, type UntrackStrategy, @@ -22,7 +23,8 @@ import { import { deepFreeze } from '../reconcile/stateBuilders' const WARMUP = 500 -const DEFAULT_ITERATIONS = 5000 +const DEFAULT_ITERATIONS = 2000 +const SAMPLES = 5 interface Entity { id: number @@ -104,6 +106,21 @@ const scenarios: UntrackScenario[] = [ entityCount: 1000, selector: (s) => s.entities.map((e) => ({ id: e.id, entity: e })), }, + { + name: 'slice-100-of-1k', + description: 'slice(0, 100) of 1k entities: library-built array, 100 proxies', + entityCount: 1000, + selector: (s) => s.entities.slice(0, 100), + }, + { + name: 'filter-embedded', + description: 'filter() result embedded in a user-built container object', + entityCount: 1000, + selector: (s) => ({ + matches: s.entities.filter((e) => e.status === 'inactive'), + total: s.meta.count, + }), + }, { name: 'deep-container', description: 'Nested derived object embedding proxies at several depths', @@ -123,8 +140,7 @@ interface BenchRow { name: string noneMs: number recursiveMs: number - overheadMs: number - overheadPct: number + registryMs: number } function timeStrategy( @@ -137,6 +153,8 @@ function timeStrategy( setUntrackStrategy(strategy) const run = () => { + // Mirrors the hook's evaluation: queue activation, proxy, selector, untrack + beginUntrackEvaluation() const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) const result = scenario.selector(proxy as State) return untrackResult(result) @@ -175,40 +193,53 @@ function main() { console.log(`\nUntrack Boundary Benchmark`) console.log(`Warmup: ${WARMUP} | Iterations: ${iterations} (full eval: proxy + selector + untrack)\n`) + const strategies: UntrackStrategy[] = ['none', 'recursive', 'registry'] const rows: BenchRow[] = [] for (const scenario of selected) { process.stdout.write(` Running ${scenario.name}...`) const state = buildState(scenario.entityCount) - const noneMs = timeStrategy(scenario, 'none', state, iterations) - const recursiveMs = timeStrategy(scenario, 'recursive', state, iterations) - const overheadMs = recursiveMs - noneMs - rows.push({ - name: scenario.name, - noneMs, - recursiveMs, - overheadMs, - overheadPct: (overheadMs / noneMs) * 100, - }) + // Interleave strategies across samples so JIT/GC drift spreads evenly, + // then take the median per strategy. + const samples = new Map( + strategies.map((s) => [s, []]), + ) + for (let i = 0; i < SAMPLES; i++) { + for (const s of strategies) { + samples.get(s)!.push(timeStrategy(scenario, s, state, iterations)) + } + } + const median = (xs: number[]) => + [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)] + const noneMs = median(samples.get('none')!) + const recursiveMs = median(samples.get('recursive')!) + const registryMs = median(samples.get('registry')!) + rows.push({ name: scenario.name, noneMs, recursiveMs, registryMs }) console.log( - ` none=${(noneMs / iterations).toFixed(4)} recursive=${(recursiveMs / iterations).toFixed(4)} ms/iter`, + ` none=${(noneMs / iterations).toFixed(4)} rec=${(recursiveMs / iterations).toFixed(4)} reg=${(registryMs / iterations).toFixed(4)} ms/iter (median of ${SAMPLES})`, ) } setUntrackStrategy('recursive') + const nameW = 20 + console.log( + `\n${'Scenario'.padEnd(nameW)} | ${'none µs/it'.padStart(11)} | ${'rec µs/it'.padStart(11)} | ${'reg µs/it'.padStart(11)} | ${'rec ovh %'.padStart(9)} | ${'reg ovh %'.padStart(9)}`, + ) console.log( - `\n${'Scenario'.padEnd(20)} | ${'none µs/it'.padStart(11)} | ${'rec µs/it'.padStart(11)} | ${'ovh µs/it'.padStart(11)} | ${'overhead %'.padStart(10)}`, + `${'-'.repeat(nameW)}-+-${'-'.repeat(11)}-+-${'-'.repeat(11)}-+-${'-'.repeat(11)}-+-${'-'.repeat(9)}-+-${'-'.repeat(9)}`, ) - console.log(`${'-'.repeat(20)}-+-${'-'.repeat(11)}-+-${'-'.repeat(11)}-+-${'-'.repeat(11)}-+-${'-'.repeat(10)}`) for (const r of rows) { const us = (ms: number) => ((ms / iterations) * 1000).toFixed(2).padStart(11) + const pct = (ms: number) => + (((ms - r.noneMs) / r.noneMs) * 100).toFixed(1).padStart(8) + '%' console.log( - `${r.name.padEnd(20)} | ${us(r.noneMs)} | ${us(r.recursiveMs)} | ${us(r.overheadMs)} | ${r.overheadPct.toFixed(1).padStart(9)}%`, + `${r.name.padEnd(nameW)} | ${us(r.noneMs)} | ${us(r.recursiveMs)} | ${us(r.registryMs)} | ${pct(r.recursiveMs)} | ${pct(r.registryMs)}`, ) } console.log('\nScenario descriptions:') for (const s of selected) console.log(` ${s.name}: ${s.description}`) console.log() + process.exit(0) } main() diff --git a/test/signals/untrack.spec.tsx b/test/signals/untrack.spec.tsx index 9fff42ba8..0b2e7d1c7 100644 --- a/test/signals/untrack.spec.tsx +++ b/test/signals/untrack.spec.tsx @@ -20,6 +20,7 @@ import { untrackResult, setUntrackStrategy, getUntrackStrategy, + beginUntrackEvaluation, } from '../../src/signals/untrack' const isTrackingProxy = (v: unknown) => @@ -246,3 +247,131 @@ describe('untrackResult unit behavior', () => { expect(untrackResult(container).child).toBe(proxy.a) }) }) + +describe("registry strategy (Immer-style finalization queue)", () => { + interface Row { + id: number + status: string + score: number + } + + function setupArrayState() { + const registry = createPathSignalRegistry(alienEngine) + const rows: Row[] = [] + for (let i = 0; i < 10; i++) { + rows.push({ id: i, status: i % 2 === 0 ? 'even' : 'odd', score: i * 10 }) + } + const state = { rows, extra: { x: 1 } } + for (const r of rows) Object.freeze(r) + Object.freeze(rows) + Object.freeze(state.extra) + Object.freeze(state) + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) as typeof state + return { state, proxy } + } + + /** Mimic one hook evaluation under the registry strategy. */ + function evaluate( + proxy: ReturnType['proxy'], + selector: (s: ReturnType['proxy']) => R, + ): R { + setUntrackStrategy('registry') + beginUntrackEvaluation() + const result = selector(proxy) + return untrackResult(result) + } + + it('finalizes filter() results to raw elements', () => { + const { state, proxy } = setupArrayState() + const result = evaluate(proxy, (s) => s.rows.filter((r) => r.status === 'even')) + expect(result).toHaveLength(5) + for (let i = 0; i < result.length; i++) { + expect(isTrackingProxy(result[i])).toBe(false) + } + expect(result[0]).toBe(state.rows[0]) + expect(result[4]).toBe(state.rows[8]) + }) + + it('handles filter().sort() — user mutation of a library-built array', () => { + const { state, proxy } = setupArrayState() + const result = evaluate(proxy, (s) => + s.rows.filter((r) => r.status === 'even').sort((a, b) => b.score - a.score), + ) + expect(result.map((r) => r.id)).toEqual([8, 6, 4, 2, 0]) + for (const r of result) { + expect(isTrackingProxy(r)).toBe(false) + } + expect(result[0]).toBe(state.rows[8]) + }) + + it('handles an element replaced with a user-built container', () => { + const { state, proxy } = setupArrayState() + const result = evaluate(proxy, (s) => { + const matches: unknown[] = s.rows.filter((r) => r.status === 'even') + matches[0] = { wrapped: s.extra } + return matches + }) + expect(isTrackingProxy((result[0] as { wrapped: object }).wrapped)).toBe(false) + expect((result[0] as { wrapped: object }).wrapped).toBe(state.extra) + expect(isTrackingProxy(result[1])).toBe(false) + }) + + it('finalizes slice() results', () => { + const { state, proxy } = setupArrayState() + const result = evaluate(proxy, (s) => s.rows.slice(2, 5)) + expect(result).toHaveLength(3) + for (const r of result) { + expect(isTrackingProxy(r)).toBe(false) + } + expect(result[0]).toBe(state.rows[2]) + }) + + it('finalizes library containers embedded in user containers', () => { + const { state, proxy } = setupArrayState() + const result = evaluate(proxy, (s) => ({ + matches: s.rows.filter((r) => r.score > 50), + first: s.rows[0], + })) + for (const r of result.matches) { + expect(isTrackingProxy(r)).toBe(false) + } + expect(isTrackingProxy(result.first)).toBe(false) + expect(result.first).toBe(state.rows[0]) + }) + + it('works end-to-end through the hook', () => { + setUntrackStrategy('registry') + const { store } = makeStore() + const captured = renderWithCapture(store, (s) => + s.todos.items.filter((t) => t.done), + ) + const result = captured[captured.length - 1] + expect(result).toHaveLength(1) + expect(isTrackingProxy(result[0])).toBe(false) + expect(result[0]).toBe(store.getState().todos.items[1]) + }) +}) + +describe('untrack strategy switching', () => { + it("strategy 'none' passes proxies through untouched (control)", () => { + const registry = createPathSignalRegistry(alienEngine) + const state = { a: { x: 1 } } + Object.freeze(state.a) + Object.freeze(state) + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) as typeof state + setUntrackStrategy('none') + expect(getUntrackStrategy()).toBe('none') + const container = { child: proxy.a } + expect(untrackResult(container).child).toBe(proxy.a) + }) +}) From d2410e4c080afae290adba71d5ccc017ab742adf Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Wed, 29 Jul 2026 22:02:19 -0400 Subject: [PATCH 35/95] Remove registry untrack strategy; ship recursive only - Delete untrackQueue.ts and the interceptor escape-candidate registration - untrack.ts: drop strategy switch, untrackResult is always the recursive walk - Remove strategy-comparison benchmark runner (results recorded in 2026-07-29-untrack-boundary-strategies.md) - Convert registry tests to plain recursive-walk coverage --- src/signals/arrayMethodOverrides.ts | 5 - src/signals/untrack.ts | 62 +------ src/signals/untrackQueue.ts | 42 ----- src/signals/useSignalSelector.ts | 5 +- test/benchmarks/untrack/runner.ts | 245 ---------------------------- test/signals/untrack.spec.tsx | 62 +------ 6 files changed, 13 insertions(+), 408 deletions(-) delete mode 100644 src/signals/untrackQueue.ts delete mode 100644 test/benchmarks/untrack/runner.ts diff --git a/src/signals/arrayMethodOverrides.ts b/src/signals/arrayMethodOverrides.ts index 1592a6fbb..b28a6ae5d 100644 --- a/src/signals/arrayMethodOverrides.ts +++ b/src/signals/arrayMethodOverrides.ts @@ -1,6 +1,5 @@ import type { PathSignalRegistry, StructureKind } from './pathSignalRegistry' import { registerRecordingHolder, unwrap } from './trackingProxy' -import { registerEscapeCandidate } from './untrackQueue' /** * Non-mutating array methods that we override on the tracking proxy. @@ -331,9 +330,6 @@ export function createArrayMethodInterceptor( } result = matches matched = matches.length > 0 - // Registry untrack strategy: we created this proxy-bearing array, - // so register it for direct finalization (Immer-callback analog). - registerEscapeCandidate(matches) } else if (FIND_METHODS.has(m)) { const isForward = m === 'find' const step = isForward ? 1 : -1 @@ -381,7 +377,6 @@ export function createArrayMethodInterceptor( for (let i = start; i < end; i++) { result.push((proxy as Record)[i]) } - registerEscapeCandidate(result) return result } diff --git a/src/signals/untrack.ts b/src/signals/untrack.ts index 4c66436ef..42c457f0f 100644 --- a/src/signals/untrack.ts +++ b/src/signals/untrack.ts @@ -1,50 +1,13 @@ import { getProxyTarget } from './trackingProxy' -import { - beginEvaluationQueue, - clearEvaluationQueue, - drainEvaluationQueue, -} from './untrackQueue' - -/** - * Strategy for stripping tracking proxies from selector results before - * they cross the hook boundary into React. - * - * - 'recursive': proxy-memoize style — walk the result, swap every - * tracking proxy for its raw target in place, stop descending at each - * proxy (everything below a raw target is raw). - * - 'registry': Immer/Mutative-style notification callbacks — proxies - * handed out during an evaluation register themselves; finalization - * uses those records instead of a blind recursive walk. - * - 'none': hand the tracking proxies to React unchanged (the old - * behavior). Kept for benchmarking the untrack overhead itself. - */ -export type UntrackStrategy = 'recursive' | 'registry' | 'none' - -let strategy: UntrackStrategy = 'recursive' - -export function setUntrackStrategy(next: UntrackStrategy): void { - strategy = next -} - -export function getUntrackStrategy(): UntrackStrategy { - return strategy -} - -/** - * Called at the start of each selector evaluation. For the 'registry' - * strategy this activates the finalization queue so array-method - * interceptors can register the containers they create; for the other - * strategies it deactivates the queue so registration is a no-op. - */ -export function beginUntrackEvaluation(): void { - if (strategy === 'registry') beginEvaluationQueue() - else clearEvaluationQueue() -} /** * Strip tracking proxies from a selector result so components, effects, * DevTools, and dispatch payloads only ever see raw (frozen) state. * + * proxy-memoize style: walk the result, swap every tracking proxy for its + * raw target in place, stop descending at each proxy (everything below a + * raw target is raw). + * * Derived containers built by the selector are mutated IN PLACE (proxy * values swapped for raw targets), preserving container identity across * evaluations — a memoized (reselect-cached) array keeps its identity, @@ -53,23 +16,6 @@ export function beginUntrackEvaluation(): void { * @returns The result with all tracking proxies replaced by raw state */ export function untrackResult(result: R): R { - if (strategy === 'none') return result - - if (strategy === 'registry') { - // Immer/Mutative-style finalization: first finalize the containers - // the library itself created during this evaluation (registered by - // the array-method interceptors), then sweep the result for - // user-built containers. The shared seen-set means the sweep skips - // anything the queue already finalized. - const containers = drainEvaluationQueue() - if (containers.length === 0) return untrackRecursive(result, null) - const seen = new WeakSet() - for (const container of containers) { - untrackRecursive(container, seen) - } - return untrackRecursive(result, seen) - } - return untrackRecursive(result, null) } diff --git a/src/signals/untrackQueue.ts b/src/signals/untrackQueue.ts deleted file mode 100644 index cc07d43dc..000000000 --- a/src/signals/untrackQueue.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Evaluation-scoped finalization queue for the 'registry' untrack strategy. - * - * Port of Immer/Mutative's finalization-callback idea: at the points where - * the LIBRARY creates proxy-bearing containers (filter/slice interceptor - * results), register the container so untrack can finalize it directly, - * instead of discovering it by walking the selector result. - * - * Standalone module with no imports: arrayMethodOverrides (registration - * side) and untrack (drain side) both import it, and trackingProxy <-> - * arrayMethodOverrides already form an import cycle we must not extend. - * - * The queue is null unless a registry-strategy evaluation is active, so - * registration is a no-op for the other strategies. - */ - -let queue: object[] | null = null - -export function beginEvaluationQueue(): void { - queue = [] -} - -export function clearEvaluationQueue(): void { - queue = null -} - -/** - * Record a library-created container holding tracking proxies (e.g. a - * filter() result array). No-op unless a registry-strategy evaluation - * is active. - */ -export function registerEscapeCandidate(container: object): void { - queue?.push(container) -} - -/** Take the current queue contents, leaving an empty active queue. */ -export function drainEvaluationQueue(): object[] { - if (queue === null || queue.length === 0) return [] - const drained = queue - queue = [] - return drained -} diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index 260fbeed2..c74f29d54 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -5,7 +5,7 @@ import { getProxyPath, type LeafObjectTracker, } from './trackingProxy' -import { beginUntrackEvaluation, untrackResult } from './untrack' +import { untrackResult } from './untrack' const { useRef, useMemo, useEffect, useSyncExternalStore } = React @@ -87,9 +87,6 @@ export function useSignalSelector( leafTracker.accessedObjects.clear() leafTracker.traversedPaths.clear() - // Activate (or deactivate) the untrack finalization queue for this - // evaluation, per the current strategy. - beginUntrackEvaluation() const proxy = createTrackingProxy( state, diff --git a/test/benchmarks/untrack/runner.ts b/test/benchmarks/untrack/runner.ts deleted file mode 100644 index 32c736945..000000000 --- a/test/benchmarks/untrack/runner.ts +++ /dev/null @@ -1,245 +0,0 @@ -/** - * Untrack-boundary micro-benchmark. - * - * Measures the cost of stripping tracking proxies from selector results - * (untrackResult) across result shapes, by running each selector through - * a tracking proxy with the untrack strategy toggled: - * - * overhead = (recursive) - (none) - * - * Usage: - * npx tsx test/benchmarks/untrack/runner.ts - * npx tsx test/benchmarks/untrack/runner.ts --iterations 5000 - */ -import { alienEngine } from '../../../src/signals/engine' -import { createPathSignalRegistry } from '../../../src/signals/pathSignalRegistry' -import { createTrackingProxy } from '../../../src/signals/trackingProxy' -import { - beginUntrackEvaluation, - setUntrackStrategy, - untrackResult, - type UntrackStrategy, -} from '../../../src/signals/untrack' -import { deepFreeze } from '../reconcile/stateBuilders' - -const WARMUP = 500 -const DEFAULT_ITERATIONS = 2000 -const SAMPLES = 5 - -interface Entity { - id: number - name: string - email: string - status: string - score: number -} - -function makeEntity(i: number): Entity { - return { - id: i, - name: `User ${i}`, - email: `user${i}@example.com`, - status: i % 3 === 0 ? 'active' : 'inactive', - score: i * 10, - } -} - -function buildState(entityCount: number) { - const entities: Entity[] = [] - for (let i = 0; i < entityCount; i++) entities.push(makeEntity(i)) - return deepFreeze({ - entities, - meta: { count: entityCount, updatedAt: 12345 }, - settings: { theme: 'dark', fontSize: 12 }, - }) -} - -type State = ReturnType - -interface UntrackScenario { - name: string - description: string - entityCount: number - selector: (s: State) => unknown -} - -const scenarios: UntrackScenario[] = [ - { - name: 'return-primitive', - description: 'Selector returns a number (untrack floor: typeof check)', - entityCount: 1000, - selector: (s) => s.meta.count, - }, - { - name: 'return-one-proxy', - description: 'Selector returns one state object (untrack: 1 WeakMap hit)', - entityCount: 1000, - selector: (s) => s.settings, - }, - { - name: 'return-big-subtree', - description: 'Selector returns the 10k-entity array proxy (walk stops at proxy)', - entityCount: 10000, - selector: (s) => s.entities, - }, - { - name: 'filter-half-1k', - description: 'filter() over 1k entities, ~500 proxies in result array', - entityCount: 1000, - selector: (s) => s.entities.filter((e) => e.status === 'inactive'), - }, - { - name: 'map-identity-1k', - description: 'map(e => e) over 1k entities: 1k proxies in a fresh array', - entityCount: 1000, - selector: (s) => s.entities.map((e) => e), - }, - { - name: 'map-identity-10k', - description: 'map(e => e) over 10k entities: 10k proxies in a fresh array', - entityCount: 10000, - selector: (s) => s.entities.map((e) => e), - }, - { - name: 'map-containers-1k', - description: 'map to fresh {id, entity} wrappers: 1k containers each holding a proxy', - entityCount: 1000, - selector: (s) => s.entities.map((e) => ({ id: e.id, entity: e })), - }, - { - name: 'slice-100-of-1k', - description: 'slice(0, 100) of 1k entities: library-built array, 100 proxies', - entityCount: 1000, - selector: (s) => s.entities.slice(0, 100), - }, - { - name: 'filter-embedded', - description: 'filter() result embedded in a user-built container object', - entityCount: 1000, - selector: (s) => ({ - matches: s.entities.filter((e) => e.status === 'inactive'), - total: s.meta.count, - }), - }, - { - name: 'deep-container', - description: 'Nested derived object embedding proxies at several depths', - entityCount: 1000, - selector: (s) => ({ - top: s.entities[0], - group: { - settings: s.settings, - inner: { meta: s.meta, first: s.entities[1] }, - }, - list: [s.entities[2], s.entities[3], s.entities[4]], - }), - }, -] - -interface BenchRow { - name: string - noneMs: number - recursiveMs: number - registryMs: number -} - -function timeStrategy( - scenario: UntrackScenario, - strategy: UntrackStrategy, - state: State, - iterations: number, -): number { - const registry = createPathSignalRegistry(alienEngine) - setUntrackStrategy(strategy) - - const run = () => { - // Mirrors the hook's evaluation: queue activation, proxy, selector, untrack - beginUntrackEvaluation() - const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) - const result = scenario.selector(proxy as State) - return untrackResult(result) - } - - for (let i = 0; i < WARMUP; i++) run() - - const start = performance.now() - for (let i = 0; i < iterations; i++) run() - return performance.now() - start -} - -function main() { - const args = process.argv.slice(2) - let iterations = DEFAULT_ITERATIONS - let filterName: string | undefined - for (let i = 0; i < args.length; i++) { - if (args[i] === '--iterations' || args[i] === '-n') { - iterations = parseInt(args[i + 1], 10) - i++ - } else if (!args[i].startsWith('-')) { - filterName = args[i] - } - } - - let selected = scenarios - if (filterName) { - selected = scenarios.filter((s) => s.name === filterName) - if (selected.length === 0) { - console.error(`Unknown scenario: ${filterName}`) - console.error(`Available: ${scenarios.map((s) => s.name).join(', ')}`) - process.exit(1) - } - } - - console.log(`\nUntrack Boundary Benchmark`) - console.log(`Warmup: ${WARMUP} | Iterations: ${iterations} (full eval: proxy + selector + untrack)\n`) - - const strategies: UntrackStrategy[] = ['none', 'recursive', 'registry'] - const rows: BenchRow[] = [] - for (const scenario of selected) { - process.stdout.write(` Running ${scenario.name}...`) - const state = buildState(scenario.entityCount) - // Interleave strategies across samples so JIT/GC drift spreads evenly, - // then take the median per strategy. - const samples = new Map( - strategies.map((s) => [s, []]), - ) - for (let i = 0; i < SAMPLES; i++) { - for (const s of strategies) { - samples.get(s)!.push(timeStrategy(scenario, s, state, iterations)) - } - } - const median = (xs: number[]) => - [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)] - const noneMs = median(samples.get('none')!) - const recursiveMs = median(samples.get('recursive')!) - const registryMs = median(samples.get('registry')!) - rows.push({ name: scenario.name, noneMs, recursiveMs, registryMs }) - console.log( - ` none=${(noneMs / iterations).toFixed(4)} rec=${(recursiveMs / iterations).toFixed(4)} reg=${(registryMs / iterations).toFixed(4)} ms/iter (median of ${SAMPLES})`, - ) - } - setUntrackStrategy('recursive') - - const nameW = 20 - console.log( - `\n${'Scenario'.padEnd(nameW)} | ${'none µs/it'.padStart(11)} | ${'rec µs/it'.padStart(11)} | ${'reg µs/it'.padStart(11)} | ${'rec ovh %'.padStart(9)} | ${'reg ovh %'.padStart(9)}`, - ) - console.log( - `${'-'.repeat(nameW)}-+-${'-'.repeat(11)}-+-${'-'.repeat(11)}-+-${'-'.repeat(11)}-+-${'-'.repeat(9)}-+-${'-'.repeat(9)}`, - ) - for (const r of rows) { - const us = (ms: number) => ((ms / iterations) * 1000).toFixed(2).padStart(11) - const pct = (ms: number) => - (((ms - r.noneMs) / r.noneMs) * 100).toFixed(1).padStart(8) + '%' - console.log( - `${r.name.padEnd(nameW)} | ${us(r.noneMs)} | ${us(r.recursiveMs)} | ${us(r.registryMs)} | ${pct(r.recursiveMs)} | ${pct(r.registryMs)}`, - ) - } - - console.log('\nScenario descriptions:') - for (const s of selected) console.log(` ${s.name}: ${s.description}`) - console.log() - process.exit(0) -} - -main() diff --git a/test/signals/untrack.spec.tsx b/test/signals/untrack.spec.tsx index 0b2e7d1c7..7ee26a9d3 100644 --- a/test/signals/untrack.spec.tsx +++ b/test/signals/untrack.spec.tsx @@ -16,19 +16,13 @@ import { useSignalSelector } from '../../src/signals/useSignalSelector' import { alienEngine } from '../../src/signals/engine' import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' import { createTrackingProxy, getProxyPath } from '../../src/signals/trackingProxy' -import { - untrackResult, - setUntrackStrategy, - getUntrackStrategy, - beginUntrackEvaluation, -} from '../../src/signals/untrack' +import { untrackResult } from '../../src/signals/untrack' const isTrackingProxy = (v: unknown) => v !== null && typeof v === 'object' && getProxyPath(v) !== undefined afterEach(() => { rtl.cleanup() - setUntrackStrategy('recursive') }) interface Todo { @@ -81,7 +75,7 @@ function renderWithCapture( return captured } -describe('untrack at the hook boundary (recursive strategy)', () => { +describe('untrack at the hook boundary', () => { it('a selector returning a state object yields the raw object, === to store state', () => { const { store } = makeStore() const captured = renderWithCapture(store, (s) => s.todos.items[0]) @@ -239,16 +233,9 @@ describe('untrackResult unit behavior', () => { expect(isTrackingProxy(result.child)).toBe(true) }) - it("strategy 'none' passes proxies through untouched", () => { - const { proxy } = setup() - setUntrackStrategy('none') - expect(getUntrackStrategy()).toBe('none') - const container = { child: proxy.a } - expect(untrackResult(container).child).toBe(proxy.a) - }) }) -describe("registry strategy (Immer-style finalization queue)", () => { +describe('untrackResult with derived arrays', () => { interface Row { id: number status: string @@ -275,18 +262,15 @@ describe("registry strategy (Immer-style finalization queue)", () => { return { state, proxy } } - /** Mimic one hook evaluation under the registry strategy. */ + /** Mimic one hook evaluation: run the selector, untrack the result. */ function evaluate( proxy: ReturnType['proxy'], selector: (s: ReturnType['proxy']) => R, ): R { - setUntrackStrategy('registry') - beginUntrackEvaluation() - const result = selector(proxy) - return untrackResult(result) + return untrackResult(selector(proxy)) } - it('finalizes filter() results to raw elements', () => { + it('untracks filter() results to raw elements', () => { const { state, proxy } = setupArrayState() const result = evaluate(proxy, (s) => s.rows.filter((r) => r.status === 'even')) expect(result).toHaveLength(5) @@ -321,7 +305,7 @@ describe("registry strategy (Immer-style finalization queue)", () => { expect(isTrackingProxy(result[1])).toBe(false) }) - it('finalizes slice() results', () => { + it('untracks slice() results', () => { const { state, proxy } = setupArrayState() const result = evaluate(proxy, (s) => s.rows.slice(2, 5)) expect(result).toHaveLength(3) @@ -331,7 +315,7 @@ describe("registry strategy (Immer-style finalization queue)", () => { expect(result[0]).toBe(state.rows[2]) }) - it('finalizes library containers embedded in user containers', () => { + it('untracks library-built arrays embedded in user containers', () => { const { state, proxy } = setupArrayState() const result = evaluate(proxy, (s) => ({ matches: s.rows.filter((r) => r.score > 50), @@ -344,34 +328,4 @@ describe("registry strategy (Immer-style finalization queue)", () => { expect(result.first).toBe(state.rows[0]) }) - it('works end-to-end through the hook', () => { - setUntrackStrategy('registry') - const { store } = makeStore() - const captured = renderWithCapture(store, (s) => - s.todos.items.filter((t) => t.done), - ) - const result = captured[captured.length - 1] - expect(result).toHaveLength(1) - expect(isTrackingProxy(result[0])).toBe(false) - expect(result[0]).toBe(store.getState().todos.items[1]) - }) -}) - -describe('untrack strategy switching', () => { - it("strategy 'none' passes proxies through untouched (control)", () => { - const registry = createPathSignalRegistry(alienEngine) - const state = { a: { x: 1 } } - Object.freeze(state.a) - Object.freeze(state) - const proxy = createTrackingProxy( - state, - '', - registry, - registry.proxyCache, - ) as typeof state - setUntrackStrategy('none') - expect(getUntrackStrategy()).toBe('none') - const container = { child: proxy.a } - expect(untrackResult(container).child).toBe(proxy.a) - }) }) From ac3041205c4079bac7b6d0c8fcb0bdbad551129f Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Wed, 29 Jul 2026 23:09:16 -0400 Subject: [PATCH 36/95] Fix zombie child crashes and prune signal misfires - prune() now sets a PRUNED sentinel so string/boolean/null leaf signals actually fire (same-value writes no-op under alien-signals) - selector computed catches throws, stores pendingError, and returns a sentinel so errors cannot escape dispatch or skip other effects - getSnapshot retries once against current state, then rethrows into render so error boundaries see it (matches stock useSelector) - 7 new zombie-children tests covering unmount-dissolves-error, error boundary surfacing, and effect isolation --- src/signals/pathSignalRegistry.ts | 15 +- src/signals/useSignalSelector.ts | 162 ++++++++++++----- test/signals/zombieChildren.spec.tsx | 248 +++++++++++++++++++++++++++ 3 files changed, 378 insertions(+), 47 deletions(-) create mode 100644 test/signals/zombieChildren.spec.tsx diff --git a/src/signals/pathSignalRegistry.ts b/src/signals/pathSignalRegistry.ts index 60524b714..ea5f076c0 100644 --- a/src/signals/pathSignalRegistry.ts +++ b/src/signals/pathSignalRegistry.ts @@ -114,6 +114,13 @@ function isObjectOrArray(v: unknown): v is object { return v !== null && typeof v === 'object' } +// Unique value written into a signal as it is pruned, guaranteed to be +// `!==` its current value so the signal always fires. Setting the same +// primitive back (the old behavior) silently no-ops for strings/booleans +// under alien-signals' `!==` equality, leaving dependent computeds +// permanently stale after an entity removal. +const PRUNED = Symbol('pruned') + // Increment prefix counters for all ancestor paths of a given pathKey. // e.g., "a.b.c" increments counters for "a.b.c", "a.b", "a" function incrementPrefixes( @@ -255,9 +262,11 @@ export function createPathSignalRegistry( // Fire the signal before removing it. This ensures any computed // that depended on this path re-evaluates with the new state. // Prune runs inside engine.batch() via reconcileState, so the - // computed won't re-run until the batch completes. - const current = sig.get() - sig.set(typeof current === 'number' ? current + 1 : current) + // computed won't re-run until the batch completes. The PRUNED + // sentinel guarantees the write is `!==` the current value — + // dependents never read the value itself, they re-run the + // selector against real state. + sig.set(PRUNED) signals.delete(key) decrementPrefixes(prefixCounts, key) } else if (prefixOnlyPaths.has(key)) { diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index c74f29d54..1633afa98 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -9,6 +9,12 @@ import { untrackResult } from './untrack' const { useRef, useMemo, useEffect, useSyncExternalStore } = React +// Returned by the computed when the selector threw. A unique sentinel +// (never a real result) so alien-signals sees the value as changed and +// keeps propagating to the effect — returning the previous result would +// cut off propagation and React would never be notified of the error. +const SELECTOR_THREW = Symbol('selector threw') as unknown + /** * A React hook that selects state from a Redux store using signal-based @@ -40,6 +46,10 @@ export function useSignalSelector( let version = 0 let notifyReact: (() => void) | null = null let suppressNotify = false + // Set when the selector threw during an effect re-evaluation (classic + // zombie child: an entity was removed while a component selecting it + // is still mounted). getSnapshot retries and rethrows if it persists. + let pendingError: unknown = null // Create a computed that runs the selector through a tracking proxy. // This establishes signal dependencies on the paths the selector reads. @@ -66,67 +76,105 @@ export function useSignalSelector( // closure's result until an unrelated store change fired a signal. const selectorVersionSignal = engine.signal(0) - const setSelector = (nextSelector: (state: S) => R): void => { - selectorRef.current = nextSelector - // Recompute in place WITHOUT notifying React: this runs during - // render, and getSnapshot picks up the fresh value in the same - // pass. Scheduling a re-render here would loop forever for - // selectors that return a new reference on every run. + // Force a fresh evaluation of the computed WITHOUT notifying React. + // Bumping the version signal dirties the computed; a plain .get() + // alone would return the cached value. On a selector error the + // computed records pendingError and yields the SELECTOR_THREW + // sentinel, which must never replace the last good result. + const recomputeInPlace = (): void => { suppressNotify = true try { selectorVersionSignal.set(selectorVersionSignal.get() + 1) - currentResult = selectorComputed.get() + const value = selectorComputed.get() + if (pendingError === null) { + currentResult = value + } } finally { suppressNotify = false } } + const setSelector = (nextSelector: (state: S) => R): void => { + selectorRef.current = nextSelector + // Recompute in place: this runs during render, and getSnapshot + // picks up the fresh value in the same pass. Scheduling a + // re-render here would loop forever for selectors that return a + // new reference on every run. + recomputeInPlace() + if (pendingError !== null) { + // The new closure threw — surface it in this render. + throw pendingError + } + } + const selectorComputed = engine.computed(() => { selectorVersionSignal.get() - const state = store.getState() as S & object - - leafTracker.accessedObjects.clear() - leafTracker.traversedPaths.clear() - - const proxy = createTrackingProxy( - state, - '', - registry, - registry.proxyCache, - leafTracker, - ) - const result = selectorRef.current(proxy as S) - - // If the selector returned a tracking proxy (object), explicitly - // read its signal to establish a reactive dependency on that path. - const proxyPath = getProxyPath(result) - if (proxyPath !== undefined) { - registry.getOrCreate(proxyPath, result).get() - } + // pendingError reflects the LAST evaluation: a successful re-run + // clears an earlier error. + pendingError = null + try { + const state = store.getState() as S & object + + leafTracker.accessedObjects.clear() + leafTracker.traversedPaths.clear() - // Read version signals for leaf objects — objects that were accessed - // but never had their properties read. These are used for identity - // comparison (===) and need their ref-change signals tracked. - for (const [objPath, rawValue] of leafTracker.accessedObjects) { - if (!leafTracker.traversedPaths.has(objPath)) { - // This object was read but never traversed — it's a leaf. - // Read its version signal to track identity changes. - // Skip root path since root changes every dispatch. - if (objPath !== '') { - registry.getOrCreate(objPath, rawValue).get() + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + leafTracker, + ) + const result = selectorRef.current(proxy as S) + + // If the selector returned a tracking proxy (object), explicitly + // read its signal to establish a reactive dependency on that path. + const proxyPath = getProxyPath(result) + if (proxyPath !== undefined) { + registry.getOrCreate(proxyPath, result).get() + } + + // Read version signals for leaf objects — objects that were accessed + // but never had their properties read. These are used for identity + // comparison (===) and need their ref-change signals tracked. + for (const [objPath, rawValue] of leafTracker.accessedObjects) { + if (!leafTracker.traversedPaths.has(objPath)) { + // This object was read but never traversed — it's a leaf. + // Read its version signal to track identity changes. + // Skip root path since root changes every dispatch. + if (objPath !== '') { + registry.getOrCreate(objPath, rawValue).get() + } } } - } - // Strip tracking proxies before the result crosses into React. - // Must run AFTER the dependency reads above (they need the proxies - // in hand). Everything downstream — equalityFn, getSnapshot, - // components, dispatch payloads — sees only raw state. - return untrackResult(result) + // Strip tracking proxies before the result crosses into React. + // Must run AFTER the dependency reads above (they need the proxies + // in hand). Everything downstream — equalityFn, getSnapshot, + // components, dispatch payloads — sees only raw state. + return untrackResult(result) + } catch (e) { + // The selector threw — typically a zombie child whose entity was + // removed in the same dispatch that will unmount it. The error + // CANNOT propagate from here: alien-signals evaluates dirty + // computeds during its flush (checkDirty), so an uncaught throw + // escapes store.dispatch and skips every remaining component's + // effect. Record it and yield the sentinel; the effect notifies + // React and getSnapshot re-evaluates — if a parent unmounts this + // component first the error dissolves, otherwise it rethrows + // into the render where an error boundary owns it (matching + // stock useSelector under React's useSyncExternalStore). + pendingError = e + return SELECTOR_THREW as R + } }) - // Initialize with current value + // Initialize with current value. A selector that throws on mount + // surfaces here, during render — same as stock useSelector. currentResult = selectorComputed.get() + if (pendingError !== null) { + throw pendingError + } return { subscribe(onStoreChange: () => void): () => void { @@ -139,6 +187,17 @@ export function useSignalSelector( const dispose = engine.effect(() => { const newValue = selectorComputed.get() + if (pendingError !== null) { + // The evaluation threw (newValue is the SELECTOR_THREW + // sentinel — never adopt it). Notify React so getSnapshot + // can retry and surface or dissolve the error. + isFirst = false + if (!suppressNotify) { + notifyReact?.() + } + return + } + if (isFirst) { // First effect run — just establish tracking, don't notify isFirst = false @@ -169,6 +228,21 @@ export function useSignalSelector( }, getSnapshot(): R { + if (pendingError !== null) { + // A previous evaluation threw. Retry against current state: + // the situation may have resolved (entity restored, or this is + // a fresh render pass after the parent re-rendered). Clear + // first — the effect's catch re-sets it if the selector still + // throws. On rethrow, LEAVE pendingError set so the next + // getSnapshot (React retries during render after a + // subscription-phase throw) re-evaluates instead of returning + // a stale value. + pendingError = null + recomputeInPlace() + if (pendingError !== null) { + throw pendingError + } + } return currentResult }, diff --git a/test/signals/zombieChildren.spec.tsx b/test/signals/zombieChildren.spec.tsx new file mode 100644 index 000000000..c6778032d --- /dev/null +++ b/test/signals/zombieChildren.spec.tsx @@ -0,0 +1,248 @@ +/** + * Zombie children and selector error semantics. + * + * React mounts bottom-up, so children subscribe before parents. When a + * dispatch removes an entity that a still-mounted child selects, the + * child's selector re-runs against the new state and may throw (the + * classic "zombie child" problem — same tradeoff stock useSelector + * accepted when it gave up connect's top-down subscription cascade). + * + * Required semantics (matching stock useSelector under React's + * useSyncExternalStore): + * - A throwing selector must NOT crash `store.dispatch`. + * - Other components' subscriptions must still be notified. + * - If a parent re-render unmounts the throwing component, the error + * dissolves — the UI just moves on. + * - If the component stays mounted, the error surfaces during render, + * where an error boundary owns it. + * + * Also covers the prune-signal bug this scenario exposed: prune() + * previously wrote a signal's own value back to it, which never fires + * for non-numeric primitives under alien-signals' `!==` equality — + * leaving selectors that only read string/boolean leaves of a removed + * entity permanently stale. + */ +import { configureStore, createSlice } from '@reduxjs/toolkit' +import * as rtl from '@testing-library/react' +import * as React from 'react' +import { alienEngine } from '../../src/signals/engine' +import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import { SignalProvider } from '../../src/signals/SignalProvider' +import { useSignalSelector } from '../../src/signals/useSignalSelector' + +interface Item { + id: number + name: string + starred: boolean +} + +const slice = createSlice({ + name: 'items', + initialState: { + list: [ + { id: 1, name: 'one', starred: false }, + { id: 2, name: 'two', starred: true }, + ] as Item[], + }, + reducers: { + removeSecond(state) { + state.list = state.list.filter((i) => i.id !== 2) + }, + restoreSecond(state) { + state.list.push({ id: 2, name: 'two', starred: true }) + }, + }, +}) + +const makeStore = () => configureStore({ reducer: { items: slice.reducer } }) +type RootState = ReturnType['getState']> + +class ErrorBoundary extends React.Component< + { children: React.ReactNode }, + { error: Error | null } +> { + state = { error: null as Error | null } + static getDerivedStateFromError(error: Error) { + return { error } + } + render() { + if (this.state.error) { + return
{this.state.error.message}
+ } + return this.props.children + } +} + +describe('prune fires non-numeric primitive leaf signals', () => { + it.each([ + ['string', 'two'], + ['boolean', true], + ['null', null], + ])('a computed depending on a pruned %s leaf re-runs', (_kind, value) => { + const registry = createPathSignalRegistry(alienEngine) + registry.getOrCreate('items.child', value) + + let runs = 0 + const c = alienEngine.computed(() => { + runs++ + return registry.has('items.child') + ? registry.getOrCreate('items.child', value).get() + : 'pruned' + }) + expect(c.get()).toBe(value) + expect(runs).toBe(1) + + registry.prune('items') + + expect(c.get()).toBe('pruned') + expect(runs).toBe(2) + }) + + it('a selector reading only a string leaf of a removed entity re-renders', () => { + const store = makeStore() + + function Watcher() { + // Reads ONLY items.list.{id:2}.name — a string leaf signal. + // Before the prune fix this stayed 'two' forever after removal. + const name = useSignalSelector( + (s: RootState) => s.items.list[1]?.name ?? 'gone', + ) + return
{name}
+ } + + rtl.render( + + + , + ) + expect(rtl.screen.getByTestId('watcher').textContent).toBe('two') + + rtl.act(() => { + store.dispatch(slice.actions.removeSecond()) + }) + expect(rtl.screen.getByTestId('watcher').textContent).toBe('gone') + }) +}) + +describe('zombie children', () => { + it('dispatch survives and the zombie child unmounts cleanly', () => { + const store = makeStore() + + function Child({ id }: { id: number }) { + // Classic zombie pattern: assumes the item exists. + const name = useSignalSelector( + (s: RootState) => s.items.list.find((i) => i.id === id)!.name, + ) + return
{name}
+ } + + function Parent() { + const ids = useSignalSelector((s: RootState) => + s.items.list.map((i) => i.id), + ) + return ( + <> + {ids.map((id) => ( + + ))} + + ) + } + + rtl.render( + + + + + , + ) + expect(rtl.screen.getByTestId('child-2').textContent).toBe('two') + + // Must not throw out of dispatch. + rtl.act(() => { + store.dispatch(slice.actions.removeSecond()) + }) + + // Parent re-rendered and unmounted the zombie; the error dissolved + // without reaching the boundary. + expect(rtl.screen.queryByTestId('child-2')).toBeNull() + expect(rtl.screen.getByTestId('child-1').textContent).toBe('one') + expect(rtl.screen.queryByTestId('boundary')).toBeNull() + }) + + it('a throwing selector on a component that stays mounted reaches an error boundary', () => { + const store = makeStore() + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + + function StubbornWatcher() { + // Not conditionally rendered by any parent — stays mounted after + // the entity is removed, so the selector throw must surface. + const name = useSignalSelector( + (s: RootState) => s.items.list.find((i) => i.id === 2)!.name, + ) + return
{name}
+ } + + rtl.render( + + + + + , + ) + expect(rtl.screen.getByTestId('stubborn').textContent).toBe('two') + + // Dispatch itself must survive; the error lands in the boundary. + rtl.act(() => { + store.dispatch(slice.actions.removeSecond()) + }) + + expect(rtl.screen.queryByTestId('stubborn')).toBeNull() + expect(rtl.screen.getByTestId('boundary').textContent).toMatch( + /Cannot read properties of undefined/, + ) + consoleSpy.mockRestore() + }) + + it('other components are still notified when one selector throws', () => { + const store = makeStore() + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + + function StubbornWatcher() { + const name = useSignalSelector( + (s: RootState) => s.items.list.find((i) => i.id === 2)!.name, + ) + return
{name}
+ } + + function CountWatcher() { + const count = useSignalSelector((s: RootState) => s.items.list.length) + return
{count}
+ } + + // StubbornWatcher mounts FIRST so its effect flushes before + // CountWatcher's during reconcile — the throw must not stop the + // rest of the flush. + rtl.render( + + + + + + , + ) + expect(rtl.screen.getByTestId('count').textContent).toBe('2') + + rtl.act(() => { + store.dispatch(slice.actions.removeSecond()) + }) + + expect(rtl.screen.getByTestId('count').textContent).toBe('1') + expect(rtl.screen.getByTestId('boundary')).toBeDefined() + consoleSpy.mockRestore() + }) +}) From 3fd2b1db6f567f81eaed6ad9e784689196cb07a6 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Wed, 29 Jul 2026 23:25:59 -0400 Subject: [PATCH 37/95] Add useSelector API parity: options arg, dev checks, withTypes - Second arg accepts equalityFn or { equalityFn, devModeChecks } - Dev-mode validation of selector/equalityFn arguments - identityFunctionCheck warns that state=>state will NEVER re-render - stabilityCheck double-invokes the selector inside the computed - Provider-level defaults + per-hook overrides (SignalProvider already forwarded stabilityCheck/identityFunctionCheck into context) - useSignalSelector.withTypes() and useDebugValue --- src/signals/index.ts | 1 + src/signals/useSignalSelector.ts | 155 +++++++++- test/signals/devModeChecks.spec.tsx | 437 ++++++++++++++++++++++++++++ 3 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 test/signals/devModeChecks.spec.tsx diff --git a/src/signals/index.ts b/src/signals/index.ts index 39c5ed04f..bbd5b8a98 100644 --- a/src/signals/index.ts +++ b/src/signals/index.ts @@ -2,6 +2,7 @@ export { SignalProvider } from './SignalProvider' export type { SignalProviderProps } from './SignalProvider' export { useSignalSelector } from './useSignalSelector' +export type { UseSignalSelector } from './useSignalSelector' export { useSignalContext } from './context' export type { SignalContextValue } from './context' diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index 1633afa98..ebdb2cb38 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -1,4 +1,9 @@ import { React } from '../utils/react' +import type { + DevModeChecks, + UseSelectorOptions, +} from '../hooks/useSelector' +import type { EqualityFn, NoInfer } from '../types' import { useSignalContext } from './context' import { createTrackingProxy, @@ -7,7 +12,10 @@ import { } from './trackingProxy' import { untrackResult } from './untrack' -const { useRef, useMemo, useEffect, useSyncExternalStore } = React +const { useRef, useMemo, useEffect, useSyncExternalStore, useDebugValue } = + React + +const refEquality: EqualityFn = Object.is // Returned by the computed when the selector threw. A unique sentinel // (never a real result) so alien-signals sees the value as changed and @@ -23,14 +31,38 @@ const SELECTOR_THREW = Symbol('selector threw') as unknown * * Must be used within a . * @param selector - Function that extracts a value from the store state - * @param equalityFn - Custom equality function for change detection + * @param equalityFnOrOptions - Equality function, or an options object + * with `equalityFn` and `devModeChecks` (same shape as stock + * `useSelector`) * @returns The selected value */ -export function useSignalSelector( +const useSignalSelectorImpl = ( selector: (state: S) => R, - equalityFn: (a: R, b: R) => boolean = Object.is, -): R { - const { store, registry, engine } = useSignalContext() + equalityFnOrOptions: EqualityFn | UseSelectorOptions = {}, +): R => { + const { equalityFn = refEquality as EqualityFn, devModeChecks = {} } = + typeof equalityFnOrOptions === 'function' + ? { equalityFn: equalityFnOrOptions, devModeChecks: {} } + : equalityFnOrOptions + + if (process.env.NODE_ENV !== 'production') { + if (!selector) { + throw new Error(`You must pass a selector to useSignalSelector`) + } + if (typeof selector !== 'function') { + throw new Error( + `You must pass a function as a selector to useSignalSelector`, + ) + } + if (typeof equalityFn !== 'function') { + throw new Error( + `You must pass a function as an equality function to useSignalSelector`, + ) + } + } + + const reduxContext = useSignalContext() + const { store, registry, engine } = reduxContext // Track latest selector/equalityFn via refs. Updated during render // (idempotent ref writes, same approach as React's @@ -40,6 +72,15 @@ export function useSignalSelector( const equalityFnRef = useRef(equalityFn) equalityFnRef.current = equalityFn + // Dev-mode check settings. Per-hook `devModeChecks` overrides merge over + // the Provider-level defaults, matching stock useSelector. Kept in refs + // (idempotent render-phase writes) so the bridge closure always reads + // the latest values. + const devModeChecksRef = useRef>(devModeChecks) + devModeChecksRef.current = devModeChecks + const contextRef = useRef(reduxContext) + contextRef.current = reduxContext + // Create the signal bridge once (stable across renders) const bridge = useMemo(() => { let currentResult: R @@ -107,6 +148,70 @@ export function useSignalSelector( } } + // Dev-mode selector checks (stability / identity function), matching + // stock useSelector. They run inside the computed because that is the + // only place the selector executes. `firstRun` is per hook instance: + // 'once' means literally once, on the first evaluation — a later + // render-phase selector swap does NOT re-arm it. + let firstRun = true + const runDevModeChecks = (selected: R, proxy: S): void => { + const { stabilityCheck = 'once', identityFunctionCheck = 'once' } = + contextRef.current + const finalChecks: DevModeChecks = { + stabilityCheck, + identityFunctionCheck, + ...devModeChecksRef.current, + } + + if ( + finalChecks.stabilityCheck === 'always' || + (finalChecks.stabilityCheck === 'once' && firstRun) + ) { + // Re-running the selector against the same proxy registers the + // same dependencies (idempotent) and returns proxy-consistent + // values, so === / shallowEqual comparisons behave correctly. + const toCompare = selectorRef.current(proxy) + if (!equalityFnRef.current(selected, toCompare)) { + let stack: string | undefined = undefined + try { + throw new Error() + } catch (e) { + ;({ stack } = e as Error) + } + console.warn( + 'Selector ' + + (selectorRef.current.name || 'unknown') + + ' returned a different result when called with the same parameters. This can lead to unnecessary rerenders.' + + '\nSelectors that return a new reference (such as an object or an array) should be memoized: https://redux.js.org/usage/deriving-data-selectors#optimizing-selectors-with-memoization', + { selected, selected2: toCompare, stack }, + ) + } + } + + if ( + finalChecks.identityFunctionCheck === 'always' || + (finalChecks.identityFunctionCheck === 'once' && firstRun) + ) { + if ((selected as unknown) === proxy) { + let stack: string | undefined = undefined + try { + throw new Error() + } catch (e) { + ;({ stack } = e as Error) + } + console.warn( + 'Selector ' + + (selectorRef.current.name || 'unknown') + + ' returned the root state when called. With useSignalSelector this component will NEVER re-render, because signal dependencies are only created for the specific paths a selector reads — and this selector read none.' + + '\nSelect the smallest values your component needs instead of the entire state object.', + { stack }, + ) + } + } + + firstRun = false + } + const selectorComputed = engine.computed(() => { selectorVersionSignal.get() // pendingError reflects the LAST evaluation: a successful re-run @@ -127,6 +232,10 @@ export function useSignalSelector( ) const result = selectorRef.current(proxy as S) + if (process.env.NODE_ENV !== 'production') { + runDevModeChecks(result, proxy as S) + } + // If the selector returned a tracking proxy (object), explicitly // read its signal to establish a reactive dependency on that path. const proxyPath = getProxyPath(result) @@ -267,9 +376,41 @@ export function useSignalSelector( // getSnapshot doubles as getServerSnapshot: the computed already ran // during useMemo, so SSR renders the current selected value. - return useSyncExternalStore( + const selectedState = useSyncExternalStore( bridge.subscribe, bridge.getSnapshot, bridge.getSnapshot, ) + + useDebugValue(selectedState) + + return selectedState } + +/** + * The signal-based `useSignalSelector` hook, including the `withTypes` + * helper for creating a pre-typed version: + * + * ```ts + * export const useAppSignalSelector = useSignalSelector.withTypes() + * ``` + */ +export interface UseSignalSelector { + ( + selector: (state: TState) => Selected, + equalityFnOrOptions?: + | EqualityFn> + | UseSelectorOptions>, + ): Selected + + withTypes: < + OverrideStateType extends StateType, + >() => UseSignalSelector +} + +export const useSignalSelector = /* @__PURE__ */ Object.assign( + useSignalSelectorImpl, + { + withTypes: () => useSignalSelector, + }, +) as UseSignalSelector diff --git a/test/signals/devModeChecks.spec.tsx b/test/signals/devModeChecks.spec.tsx new file mode 100644 index 000000000..ae5fe9c00 --- /dev/null +++ b/test/signals/devModeChecks.spec.tsx @@ -0,0 +1,437 @@ +/** + * Tests for useSignalSelector's stock-useSelector API parity: + * + * - second argument as equalityFn OR options object ({ equalityFn, + * devModeChecks }) + * - dev-mode argument validation + * - identityFunctionCheck (state => state) — for the signal hook this is + * a CORRECTNESS warning: the root path is never updated by the diff, + * so an identity selector never re-renders at all + * - stabilityCheck (selector returns a new reference for the same state) + * - Provider-level defaults + per-hook overrides + * - useSignalSelector.withTypes() + * + * "once" frequency is literally once per hook instance, on the first + * evaluation — matching stock useSelector. + */ +import { configureStore, createSlice } from '@reduxjs/toolkit' +import * as rtl from '@testing-library/react' +import React from 'react' +import type { MockInstance } from 'vitest' +import { SignalProvider, useSignalSelector } from '../../src/signals' + +const makeStore = () => { + const counterSlice = createSlice({ + name: 'counter', + initialState: { value: 0 }, + reducers: { + increment: (state) => { + state.value += 1 + }, + }, + }) + + const todosSlice = createSlice({ + name: 'todos', + initialState: { + list: [ + { id: 1, text: 'buy milk', done: false }, + { id: 2, text: 'walk dog', done: true }, + ], + }, + reducers: { + addTodo: (state) => { + state.list.push({ + id: state.list.length + 1, + text: 'new todo', + done: false, + }) + }, + }, + }) + + const store = configureStore({ + reducer: { + counter: counterSlice.reducer, + todos: todosSlice.reducer, + }, + }) + + return { + store, + increment: counterSlice.actions.increment, + addTodo: todosSlice.actions.addTodo, + } +} + +type AppStore = ReturnType['store'] +type RootState = ReturnType + +let warnSpy: MockInstance<[message?: any, ...optionalParams: any[]], void> + +beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + warnSpy.mockRestore() + rtl.cleanup() +}) + +const identityWarnings = () => + warnSpy.mock.calls.filter((call) => + String(call[0]).includes('returned the root state'), + ) + +const stabilityWarnings = () => + warnSpy.mock.calls.filter((call) => + String(call[0]).includes('returned a different result'), + ) + +describe('equalityFn argument forms', () => { + const firstElementEqual = (a: boolean[], b: boolean[]) => a[0] === b[0] + + const renderCounter = ( + store: AppStore, + useSelectorArgs: (state: RootState) => boolean[], + second?: any, + ) => { + let renders = 0 + function Comp() { + renders++ + const flags = useSignalSelector(useSelectorArgs, second) + return
{String(flags[0])}
+ } + const utils = rtl.render( + + + , + ) + return { utils, getRenders: () => renders } + } + + it('accepts a positional equality function', () => { + const { store, increment } = makeStore() + const { utils, getRenders } = renderCounter( + store, + (s) => [s.counter.value > 0], + firstElementEqual, + ) + + expect(utils.getByTestId('flag').textContent).toBe('false') + const initialRenders = getRenders() + + // 0 -> 1 crosses the threshold: new result, re-render + rtl.act(() => { + store.dispatch(increment()) + }) + expect(utils.getByTestId('flag').textContent).toBe('true') + expect(getRenders()).toBe(initialRenders + 1) + + // 1 -> 2: selector re-runs (fresh array) but equalityFn says equal + rtl.act(() => { + store.dispatch(increment()) + }) + expect(getRenders()).toBe(initialRenders + 1) + }) + + it('accepts an options object with equalityFn', () => { + const { store, increment } = makeStore() + const { utils, getRenders } = renderCounter( + store, + (s) => [s.counter.value > 0], + { equalityFn: firstElementEqual }, + ) + + expect(utils.getByTestId('flag').textContent).toBe('false') + const initialRenders = getRenders() + + rtl.act(() => { + store.dispatch(increment()) + }) + expect(utils.getByTestId('flag').textContent).toBe('true') + expect(getRenders()).toBe(initialRenders + 1) + + rtl.act(() => { + store.dispatch(increment()) + }) + expect(getRenders()).toBe(initialRenders + 1) + }) + + it('defaults to Object.is without a second argument', () => { + const { store, increment } = makeStore() + let renders = 0 + function Comp() { + renders++ + const value = useSignalSelector((s: RootState) => s.counter.value) + return
{value}
+ } + const utils = rtl.render( + + + , + ) + expect(utils.getByTestId('value').textContent).toBe('0') + rtl.act(() => { + store.dispatch(increment()) + }) + expect(utils.getByTestId('value').textContent).toBe('1') + expect(renders).toBe(2) + }) +}) + +describe('dev-mode argument validation', () => { + const renderWith = (hookCall: () => unknown) => { + const { store } = makeStore() + function Comp() { + hookCall() + return null + } + rtl.render( + + + , + ) + } + + it('throws if no selector is passed', () => { + expect(() => + renderWith(() => (useSignalSelector as any)()), + ).toThrow(/You must pass a selector to useSignalSelector/) + }) + + it('throws if the selector is not a function', () => { + expect(() => + renderWith(() => (useSignalSelector as any)('counter')), + ).toThrow(/You must pass a function as a selector/) + }) + + it('throws if the equality function is not a function', () => { + expect(() => + renderWith(() => + (useSignalSelector as any)((s: RootState) => s.counter.value, { + equalityFn: 'nope', + }), + ), + ).toThrow(/You must pass a function as an equality function/) + }) +}) + +describe('identityFunctionCheck', () => { + it('warns once on mount for an identity selector', () => { + const { store } = makeStore() + function Comp() { + useSignalSelector((s: RootState) => s) + return null + } + rtl.render( + + + , + ) + expect(identityWarnings()).toHaveLength(1) + expect(String(warnSpy.mock.calls[0][0])).toContain('NEVER re-render') + }) + + it('does not warn for a selector that reads specific paths', () => { + const { store } = makeStore() + function Comp() { + useSignalSelector((s: RootState) => s.counter.value) + return null + } + rtl.render( + + + , + ) + expect(identityWarnings()).toHaveLength(0) + }) + + it('is silenced by devModeChecks: { identityFunctionCheck: "never" }', () => { + const { store } = makeStore() + function Comp() { + useSignalSelector((s: RootState) => s, { + devModeChecks: { identityFunctionCheck: 'never' }, + }) + return null + } + rtl.render( + + + , + ) + expect(identityWarnings()).toHaveLength(0) + }) + + it('respects the Provider-level default', () => { + const { store } = makeStore() + function Comp() { + useSignalSelector((s: RootState) => s) + return null + } + rtl.render( + + + , + ) + expect(identityWarnings()).toHaveLength(0) + }) + + it('per-hook override beats the Provider-level default', () => { + const { store } = makeStore() + function Comp() { + useSignalSelector((s: RootState) => s, { + devModeChecks: { identityFunctionCheck: 'once' }, + }) + return null + } + rtl.render( + + + , + ) + expect(identityWarnings()).toHaveLength(1) + }) +}) + +describe('stabilityCheck', () => { + it('warns once on mount for an unstable selector', () => { + const { store } = makeStore() + function Comp() { + useSignalSelector((s: RootState) => s.todos.list.map((t) => t.id)) + return null + } + rtl.render( + + + , + ) + expect(stabilityWarnings()).toHaveLength(1) + }) + + it('does not warn for a stable selector', () => { + const { store } = makeStore() + function Comp() { + useSignalSelector((s: RootState) => s.todos.list) + return null + } + rtl.render( + + + , + ) + expect(stabilityWarnings()).toHaveLength(0) + }) + + it('uses the provided equalityFn for the comparison', () => { + const { store } = makeStore() + const shallowArrayEqual = (a: number[], b: number[]) => + a.length === b.length && a.every((v, i) => v === b[i]) + function Comp() { + useSignalSelector((s: RootState) => s.todos.list.map((t) => t.id), { + equalityFn: shallowArrayEqual, + }) + return null + } + rtl.render( + + + , + ) + expect(stabilityWarnings()).toHaveLength(0) + }) + + it('is silenced by devModeChecks: { stabilityCheck: "never" }', () => { + const { store } = makeStore() + function Comp() { + useSignalSelector((s: RootState) => s.todos.list.map((t) => t.id), { + devModeChecks: { stabilityCheck: 'never' }, + }) + return null + } + rtl.render( + + + , + ) + expect(stabilityWarnings()).toHaveLength(0) + }) + + it('is silenced by a Provider-level stabilityCheck="never"', () => { + const { store } = makeStore() + function Comp() { + useSignalSelector((s: RootState) => s.todos.list.map((t) => t.id)) + return null + } + rtl.render( + + + , + ) + expect(stabilityWarnings()).toHaveLength(0) + }) + + it('"always" re-checks on store-driven re-evaluations', () => { + const { store, addTodo } = makeStore() + function Comp() { + useSignalSelector((s: RootState) => s.todos.list.map((t) => t.id), { + devModeChecks: { stabilityCheck: 'always' }, + }) + return null + } + rtl.render( + + + , + ) + expect(stabilityWarnings()).toHaveLength(1) + + rtl.act(() => { + store.dispatch(addTodo()) + }) + expect(stabilityWarnings().length).toBeGreaterThanOrEqual(2) + }) + + it('"once" does not re-check on later evaluations', () => { + const { store, addTodo } = makeStore() + function Comp() { + useSignalSelector((s: RootState) => s.todos.list.map((t) => t.id)) + return null + } + rtl.render( + + + , + ) + expect(stabilityWarnings()).toHaveLength(1) + + rtl.act(() => { + store.dispatch(addTodo()) + }) + expect(stabilityWarnings()).toHaveLength(1) + }) +}) + +describe('withTypes', () => { + it('returns the same hook, pre-typed', () => { + const useAppSignalSelector = useSignalSelector.withTypes() + expect(useAppSignalSelector).toBe(useSignalSelector) + + const { store, increment } = makeStore() + function Comp() { + // No explicit state type needed — inferred from withTypes + const value = useAppSignalSelector((s) => s.counter.value) + return
{value}
+ } + const utils = rtl.render( + + + , + ) + expect(utils.getByTestId('value').textContent).toBe('0') + rtl.act(() => { + store.dispatch(increment()) + }) + expect(utils.getByTestId('value').textContent).toBe('1') + }) +}) From 0cc9858b5db2e4907c85c902a41e137e46cb76a7 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Wed, 29 Jul 2026 23:32:02 -0400 Subject: [PATCH 38/95] Add createSignalSelectorHook for custom-context parity --- src/signals/context.ts | 51 ++-- src/signals/index.ts | 7 +- src/signals/useSignalSelector.ts | 53 +++- .../signals/createSignalSelectorHook.spec.tsx | 245 ++++++++++++++++++ 4 files changed, 330 insertions(+), 26 deletions(-) create mode 100644 test/signals/createSignalSelectorHook.spec.tsx diff --git a/src/signals/context.ts b/src/signals/context.ts index 05c1fc77c..f2d79a34e 100644 --- a/src/signals/context.ts +++ b/src/signals/context.ts @@ -15,23 +15,42 @@ export interface SignalContextValue< export { ReactReduxContext } -export function useSignalContext(): SignalContextValue { - const contextValue = React.useContext( - ReactReduxContext as React.Context | null>, - ) - - if (!contextValue) { - throw new Error( - 'useSignalSelector must be used within a ', +/** + * Hook factory, which creates a `useSignalContext` hook bound to a given + * context. Mirrors stock `createReduxContextHook`, but validates that the + * context value came from a `` (registry + engine present). + * + * @param context - Context passed to your ``. + */ +export function createSignalContextHook( + context: React.Context | null> = ReactReduxContext, +) { + return function useSignalContext(): SignalContextValue { + const contextValue = React.useContext( + context as unknown as React.Context | null>, ) - } - // Verify this is actually a signal context (has registry + engine) - if (!('registry' in contextValue) || !('engine' in contextValue)) { - throw new Error( - 'useSignalSelector must be used within a , not a regular ', - ) - } + if (!contextValue) { + throw new Error( + 'useSignalSelector must be used within a ', + ) + } - return contextValue + // Verify this is actually a signal context (has registry + engine) + if (!('registry' in contextValue) || !('engine' in contextValue)) { + throw new Error( + 'useSignalSelector must be used within a , not a regular ', + ) + } + + return contextValue + } } + +/** + * Reads the signal context bound to the default `ReactReduxContext`. + */ +export const useSignalContext = /* @__PURE__ */ createSignalContextHook() diff --git a/src/signals/index.ts b/src/signals/index.ts index bbd5b8a98..b4629a0a4 100644 --- a/src/signals/index.ts +++ b/src/signals/index.ts @@ -1,10 +1,13 @@ export { SignalProvider } from './SignalProvider' export type { SignalProviderProps } from './SignalProvider' -export { useSignalSelector } from './useSignalSelector' +export { + createSignalSelectorHook, + useSignalSelector, +} from './useSignalSelector' export type { UseSignalSelector } from './useSignalSelector' -export { useSignalContext } from './context' +export { createSignalContextHook, useSignalContext } from './context' export type { SignalContextValue } from './context' export { alienEngine } from './engine' diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index ebdb2cb38..127a36e4a 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -1,10 +1,17 @@ +import type { Context } from 'react' import { React } from '../utils/react' import type { DevModeChecks, UseSelectorOptions, } from '../hooks/useSelector' +import type { ReactReduxContextValue } from '../components/Context' import type { EqualityFn, NoInfer } from '../types' -import { useSignalContext } from './context' +import type { SignalContextValue } from './context' +import { + createSignalContextHook, + ReactReduxContext, + useSignalContext, +} from './context' import { createTrackingProxy, getProxyPath, @@ -39,6 +46,7 @@ const SELECTOR_THREW = Symbol('selector threw') as unknown const useSignalSelectorImpl = ( selector: (state: S) => R, equalityFnOrOptions: EqualityFn | UseSelectorOptions = {}, + useBoundSignalContext: () => SignalContextValue = useSignalContext, ): R => { const { equalityFn = refEquality as EqualityFn, devModeChecks = {} } = typeof equalityFnOrOptions === 'function' @@ -61,7 +69,7 @@ const useSignalSelectorImpl = ( } } - const reduxContext = useSignalContext() + const reduxContext = useBoundSignalContext() const { store, registry, engine } = reduxContext // Track latest selector/equalityFn via refs. Updated during render @@ -408,9 +416,38 @@ export interface UseSignalSelector { >() => UseSignalSelector } -export const useSignalSelector = /* @__PURE__ */ Object.assign( - useSignalSelectorImpl, - { - withTypes: () => useSignalSelector, - }, -) as UseSignalSelector +/** + * Hook factory, which creates a `useSignalSelector` hook bound to a given + * context. Mirrors stock `createSelectorHook`. + * + * The context must be provided by a `` — + * the returned hook validates that the context value carries the signal + * registry and engine. + * + * @param context - Context passed to your ``. + */ +export function createSignalSelectorHook( + context: Context | null> = ReactReduxContext, +): UseSignalSelector { + const useBoundSignalContext = + context === ReactReduxContext + ? useSignalContext + : createSignalContextHook(context) + + const useSignalSelectorBound = ( + selector: (state: S) => R, + equalityFnOrOptions: EqualityFn | UseSelectorOptions = {}, + ): R => + useSignalSelectorImpl(selector, equalityFnOrOptions, useBoundSignalContext) + + const boundHook = Object.assign(useSignalSelectorBound, { + withTypes: () => boundHook, + }) as UseSignalSelector + + return boundHook +} + +export const useSignalSelector = /* @__PURE__ */ createSignalSelectorHook() diff --git a/test/signals/createSignalSelectorHook.spec.tsx b/test/signals/createSignalSelectorHook.spec.tsx new file mode 100644 index 000000000..cca5eb7a4 --- /dev/null +++ b/test/signals/createSignalSelectorHook.spec.tsx @@ -0,0 +1,245 @@ +/** + * Tests for createSignalSelectorHook — custom-context parity with stock + * createSelectorHook. SignalProvider already accepts a `context` prop; + * these tests verify the hook side binds to that context correctly. + */ +import { configureStore, createSlice } from '@reduxjs/toolkit' +import * as rtl from '@testing-library/react' +import { createContext } from 'react' +import { describe, expect, it, afterEach } from 'vitest' +import type { ReactReduxContextValue } from '../../src/components/Context' +// Import the stock Provider directly from source — the package root +// currently carries the bench override (Provider = SignalProvider). +import Provider from '../../src/components/Provider' +import { + createSignalSelectorHook, + SignalProvider, + useSignalSelector, +} from '../../src/signals' + +const makeStore = (initialValue = 0) => { + const counterSlice = createSlice({ + name: 'counter', + initialState: { value: initialValue }, + reducers: { + increment: (state) => { + state.value += 1 + }, + incrementBy: (state, action: { payload: number }) => { + state.value += action.payload + }, + }, + }) + + const store = configureStore({ + reducer: { counter: counterSlice.reducer }, + }) + + return { + store, + increment: counterSlice.actions.increment, + incrementBy: counterSlice.actions.incrementBy, + } +} + +type RootState = { counter: { value: number } } + +const customContext = createContext | null>(null) + +const useCustomSelector = createSignalSelectorHook(customContext) + +afterEach(() => { + rtl.cleanup() +}) + +describe('createSignalSelectorHook', () => { + it('reads from the store provided through the custom context', () => { + const { store } = makeStore(7) + + function Display() { + const value = useCustomSelector((s: RootState) => s.counter.value) + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('value').textContent).toBe('7') + }) + + it('bound and default hooks read their own stores when providers nest', () => { + const defaultStore = makeStore(1) + const customStore = makeStore(100) + + function Display() { + const defaultValue = useSignalSelector( + (s: RootState) => s.counter.value, + ) + const customValue = useCustomSelector((s: RootState) => s.counter.value) + return ( +
+
{defaultValue}
+
{customValue}
+
+ ) + } + + const { getByTestId } = rtl.render( + + + + + , + ) + + expect(getByTestId('default').textContent).toBe('1') + expect(getByTestId('custom').textContent).toBe('100') + + rtl.act(() => { + customStore.store.dispatch(customStore.increment()) + }) + expect(getByTestId('default').textContent).toBe('1') + expect(getByTestId('custom').textContent).toBe('101') + + rtl.act(() => { + defaultStore.store.dispatch(defaultStore.increment()) + }) + expect(getByTestId('default').textContent).toBe('2') + expect(getByTestId('custom').textContent).toBe('101') + }) + + it('dispatches to the other store do not re-render a bound-hook component', () => { + const defaultStore = makeStore(1) + const customStore = makeStore(100) + let renders = 0 + + function CustomOnly() { + renders++ + const value = useCustomSelector((s: RootState) => s.counter.value) + return
{value}
+ } + + rtl.render( + + + + + , + ) + + expect(renders).toBe(1) + + rtl.act(() => { + defaultStore.store.dispatch(defaultStore.increment()) + }) + expect(renders).toBe(1) + + rtl.act(() => { + customStore.store.dispatch(customStore.increment()) + }) + expect(renders).toBe(2) + }) + + it('throws when the bound context has no provider', () => { + const { store } = makeStore() + + function Display() { + const value = useCustomSelector((s: RootState) => s.counter.value) + return
{value}
+ } + + // Default SignalProvider present, but the custom context is empty + expect(() => + rtl.render( + + + , + ), + ).toThrow(/must be used within a /) + }) + + it('throws when the custom context was filled by a regular ', () => { + const { store } = makeStore() + + function Display() { + const value = useCustomSelector((s: RootState) => s.counter.value) + return
{value}
+ } + + expect(() => + rtl.render( + + + , + ), + ).toThrow(/not a regular /) + }) + + it('factory with no arguments creates an independent hook bound to the default context', () => { + const freshHook = createSignalSelectorHook() + expect(freshHook).not.toBe(useSignalSelector) + + const { store } = makeStore(3) + + function Display() { + const value = freshHook((s: RootState) => s.counter.value) + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('value').textContent).toBe('3') + }) + + it('withTypes on a bound hook returns the same hook', () => { + const typed = useCustomSelector.withTypes() + expect(typed).toBe(useCustomSelector) + }) + + it('supports the options-object second argument on bound hooks', () => { + const { store, increment, incrementBy } = makeStore(0) + let renders = 0 + + function Display() { + renders++ + const parity = useCustomSelector( + (s: RootState) => [s.counter.value % 2] as const, + { equalityFn: (a, b) => a[0] === b[0] }, + ) + return
{String(parity[0])}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('parity').textContent).toBe('0') + expect(renders).toBe(1) + + rtl.act(() => { + store.dispatch(increment()) + }) + expect(getByTestId('parity').textContent).toBe('1') + expect(renders).toBe(2) + + rtl.act(() => { + store.dispatch(incrementBy(2)) + }) + // 1 -> 3 in a single dispatch: parity unchanged, equalityFn + // suppresses the re-render + expect(getByTestId('parity').textContent).toBe('1') + expect(renders).toBe(2) + }) +}) From a608d883688ce302a1fc09853931363d50a5db63 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Wed, 29 Jul 2026 23:44:50 -0400 Subject: [PATCH 39/95] Add render-phase swap tests documenting eval counts and batching constraint --- test/signals/renderPhaseSwap.spec.tsx | 229 ++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 test/signals/renderPhaseSwap.spec.tsx diff --git a/test/signals/renderPhaseSwap.spec.tsx b/test/signals/renderPhaseSwap.spec.tsx new file mode 100644 index 000000000..8a5e329bc --- /dev/null +++ b/test/signals/renderPhaseSwap.spec.tsx @@ -0,0 +1,229 @@ +/** + * Tests for the render-phase selector swap in useSignalSelector. + * + * When a render presents a selector with a new function identity (the + * common case: an inline selector), the hook swaps it in and re-evaluates + * synchronously during render so closures over changed props/state are + * applied in THAT render. + * + * Cost profile documented here: a store-driven update to a component with + * an inline selector evaluates the selector TWICE — once in the signal + * effect (old closure, decides whether to notify React) and once in the + * render-phase swap (new closure). This matches stock useSelector's + * evaluation count for inline selectors. Components that pass a stable + * selector reference (module-level fn or useCallback) skip the swap + * entirely and evaluate once. + * + * The batched-update test is the reason the swap CANNOT be skipped via + * "this render was caused by our own notify" origin tracking: React + * auto-batching merges a setState and a store dispatch from the same + * event handler into ONE render, so a notify-triggered render can still + * carry a changed closure. + */ +import { configureStore, createSlice, type PayloadAction } from '@reduxjs/toolkit' +import * as rtl from '@testing-library/react' +import React, { useCallback, useState } from 'react' +import { SignalProvider, useSignalSelector } from '../../src/signals' + +interface Item { + id: number + name: string +} + +const makeStore = () => { + const itemsSlice = createSlice({ + name: 'items', + initialState: { + list: [ + { id: 1, name: 'one' }, + { id: 2, name: 'two' }, + ] as Item[], + }, + reducers: { + renameFirst(state, action: PayloadAction) { + state.list[0].name = action.payload + }, + renameSecond(state, action: PayloadAction) { + state.list[1].name = action.payload + }, + }, + }) + + const store = configureStore({ + reducer: { items: itemsSlice.reducer }, + }) + + return { store, actions: itemsSlice.actions } +} + +type AppState = ReturnType['store']['getState']> + +afterEach(() => { + rtl.cleanup() +}) + +describe('render-phase selector swap', () => { + it('applies a closure over local state changed in the same batch as a dispatch', () => { + // The origin-tracking counterexample. setIdx(1) and the dispatch are + // auto-batched into a single render. The dispatch changes the OLD + // closure's tracked dep (items[0].name), so the signal effect + // notifies React. That notify-triggered render carries idx=1 — the + // swap must re-evaluate with the new closure or the component shows + // items[0]'s new name instead of items[1]'s. + const { store, actions } = makeStore() + + function Watcher() { + const [idx, setIdx] = useState(0) + const name = useSignalSelector( + (s: AppState) => s.items.list[idx].name, + { devModeChecks: { stabilityCheck: 'never' } }, + ) + return ( +
+
{name}
+ +
+ ) + } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('name').textContent).toBe('one') + + rtl.fireEvent.click(getByTestId('both')) + + // Correct: the new closure (idx=1) selects items[1].name. + // A swap skipped via notify-origin tracking would show 'changed' + // (items[0]'s renamed value from the old closure's evaluation). + expect(getByTestId('name').textContent).toBe('two') + }) + + it('evaluates an inline selector twice per store-driven update (documented cost)', () => { + const { store, actions } = makeStore() + let selectorCalls = 0 + + function Watcher() { + const name = useSignalSelector( + (s: AppState) => { + selectorCalls++ + return s.items.list[0].name + }, + { devModeChecks: { stabilityCheck: 'never' } }, + ) + return
{name}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + const mountCalls = selectorCalls + + for (let i = 1; i <= 5; i++) { + rtl.act(() => { + store.dispatch(actions.renameFirst(`rename-${i}`)) + }) + } + + expect(getByTestId('name').textContent).toBe('rename-5') + // Two evaluations per update: effect (old closure, decides notify) + + // render-phase swap (new inline closure). + expect(selectorCalls - mountCalls).toBe(10) + }) + + it('evaluates a stable selector once per store-driven update', () => { + const { store, actions } = makeStore() + let selectorCalls = 0 + + const selectFirstName = (s: AppState) => { + selectorCalls++ + return s.items.list[0].name + } + + function Watcher() { + const name = useSignalSelector(selectFirstName, { + devModeChecks: { stabilityCheck: 'never' }, + }) + return
{name}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + const mountCalls = selectorCalls + + for (let i = 1; i <= 5; i++) { + rtl.act(() => { + store.dispatch(actions.renameFirst(`rename-${i}`)) + }) + } + + expect(getByTestId('name').textContent).toBe('rename-5') + // Stable reference: no swap, one effect evaluation per update. + expect(selectorCalls - mountCalls).toBe(5) + }) + + it('useCallback selector with closure deps evaluates once per update, twice when deps change', () => { + const { store, actions } = makeStore() + let selectorCalls = 0 + + function Watcher() { + const [idx, setIdx] = useState(0) + const selectName = useCallback( + (s: AppState) => { + selectorCalls++ + return s.items.list[idx].name + }, + [idx], + ) + const name = useSignalSelector(selectName, { + devModeChecks: { stabilityCheck: 'never' }, + }) + return ( +
+
{name}
+ +
+ ) + } + + const { getByTestId } = rtl.render( + + + , + ) + + const mountCalls = selectorCalls + + // Store update with stable selector: one evaluation. + rtl.act(() => { + store.dispatch(actions.renameFirst('renamed')) + }) + expect(selectorCalls - mountCalls).toBe(1) + expect(getByTestId('name').textContent).toBe('renamed') + + // Dep change: new useCallback identity -> swap evaluates once. + rtl.fireEvent.click(getByTestId('switch')) + expect(selectorCalls - mountCalls).toBe(2) + expect(getByTestId('name').textContent).toBe('two') + }) +}) From 21f8325e120d65010317a62a93c9e771aa8b68f0 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 30 Jul 2026 00:51:04 -0400 Subject: [PATCH 40/95] Fix lint errors and use built-in NoInfer for upstream compat --- src/signals/context.ts | 1 + src/signals/useSignalSelector.ts | 9 ++++++--- test/signals/devModeChecks.spec.tsx | 2 +- test/signals/untrack.spec.tsx | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/signals/context.ts b/src/signals/context.ts index f2d79a34e..b3fecc183 100644 --- a/src/signals/context.ts +++ b/src/signals/context.ts @@ -21,6 +21,7 @@ export { ReactReduxContext } * context value came from a `` (registry + engine present). * * @param context - Context passed to your ``. + * @returns A `useSignalContext` hook bound to the given context. */ export function createSignalContextHook( context: React.Context( @@ -184,7 +186,7 @@ const useSignalSelectorImpl = ( try { throw new Error() } catch (e) { - ;({ stack } = e as Error) + stack = (e as Error).stack } console.warn( 'Selector ' + @@ -205,7 +207,7 @@ const useSignalSelectorImpl = ( try { throw new Error() } catch (e) { - ;({ stack } = e as Error) + stack = (e as Error).stack } console.warn( 'Selector ' + @@ -425,6 +427,7 @@ export interface UseSignalSelector { * registry and engine. * * @param context - Context passed to your ``. + * @returns A `useSignalSelector` hook bound to the given context. */ export function createSignalSelectorHook( context: Context { type AppStore = ReturnType['store'] type RootState = ReturnType -let warnSpy: MockInstance<[message?: any, ...optionalParams: any[]], void> +let warnSpy: MockInstance<(message?: any, ...optionalParams: any[]) => void> beforeEach(() => { warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/test/signals/untrack.spec.tsx b/test/signals/untrack.spec.tsx index 7ee26a9d3..3a9787e9e 100644 --- a/test/signals/untrack.spec.tsx +++ b/test/signals/untrack.spec.tsx @@ -262,7 +262,7 @@ describe('untrackResult with derived arrays', () => { return { state, proxy } } - /** Mimic one hook evaluation: run the selector, untrack the result. */ + // Mimic one hook evaluation: run the selector, untrack the result. function evaluate( proxy: ReturnType['proxy'], selector: (s: ReturnType['proxy']) => R, From ee006cff36b42c922cc94cd69cf321842fa85c9e Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 30 Jul 2026 01:20:29 -0400 Subject: [PATCH 41/95] Use inferred spy type instead of MockInstance generics --- test/signals/devModeChecks.spec.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/signals/devModeChecks.spec.tsx b/test/signals/devModeChecks.spec.tsx index bceba46a4..cfcd09c39 100644 --- a/test/signals/devModeChecks.spec.tsx +++ b/test/signals/devModeChecks.spec.tsx @@ -17,7 +17,6 @@ import { configureStore, createSlice } from '@reduxjs/toolkit' import * as rtl from '@testing-library/react' import React from 'react' -import type { MockInstance } from 'vitest' import { SignalProvider, useSignalSelector } from '../../src/signals' const makeStore = () => { @@ -67,10 +66,13 @@ const makeStore = () => { type AppStore = ReturnType['store'] type RootState = ReturnType -let warnSpy: MockInstance<(message?: any, ...optionalParams: any[]) => void> +const spyOnConsoleWarn = () => + vi.spyOn(console, 'warn').mockImplementation(() => {}) + +let warnSpy: ReturnType beforeEach(() => { - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + warnSpy = spyOnConsoleWarn() }) afterEach(() => { From 6f932dc4d4efb0252177ac0f4c7a064547b58699 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 30 Jul 2026 22:03:46 -0400 Subject: [PATCH 42/95] Add coarse handling to improve initial mount --- src/signals/SignalProvider.tsx | 15 +- src/signals/coarseSegments.ts | 186 ++++++++++ src/signals/diff.ts | 48 ++- src/signals/pathSignalRegistry.ts | 9 + src/signals/trackingProxy.ts | 12 + src/signals/useSignalSelector.ts | 298 ++++++++++++--- test/signals/coarseSegments.spec.ts | 289 +++++++++++++++ test/signals/coarseTier.spec.tsx | 552 ++++++++++++++++++++++++++++ 8 files changed, 1356 insertions(+), 53 deletions(-) create mode 100644 src/signals/coarseSegments.ts create mode 100644 test/signals/coarseSegments.spec.ts create mode 100644 test/signals/coarseTier.spec.tsx diff --git a/src/signals/SignalProvider.tsx b/src/signals/SignalProvider.tsx index d20b8967f..c4764b314 100644 --- a/src/signals/SignalProvider.tsx +++ b/src/signals/SignalProvider.tsx @@ -75,7 +75,20 @@ export function SignalProvider< const prev = prevStateRef.current const next = store.getState() prevStateRef.current = next - reconcileState(prev, next, registry, engine) + const changedRootKeys = reconcileState(prev, next, registry, engine) + + // Coarse tier: wake deferred subscribers whose top-level segments + // changed (null = root wasn't diffable, every subscriber is a + // candidate). Runs before notifyNestedSubs so a promoted hook's + // deep graph and current result are ready when React reads its + // snapshot. collect() returns a snapshot array — subscribers + // unregister themselves from the index inside onCoarseHit. + if (prev !== next && registry.segmentIndex.size() > 0) { + const hits = registry.segmentIndex.collect(changedRootKeys) + for (let i = 0; i < hits.length; i++) { + hits[i].onCoarseHit() + } + } subscription.notifyNestedSubs() } diff --git a/src/signals/coarseSegments.ts b/src/signals/coarseSegments.ts new file mode 100644 index 000000000..b039e2450 --- /dev/null +++ b/src/signals/coarseSegments.ts @@ -0,0 +1,186 @@ +import { registerProxyTarget } from './trackingProxy' + +/** + * A deferred (not-yet-built) useSignalSelector subscriber, registered in + * the coarse tier. The hook stays in this tier — no tracking proxies, no + * path signals, no alien-signals effect — until a dispatch changes one of + * the top-level state keys its selector read at probe time. Then + * `onCoarseHit` promotes it to the deep (path-signal) tier. + */ +export interface CoarseSub { + /** + * Top-level state keys the selector read when probed, recorded as raw + * (unencoded) key names. `null` means wildcard: the footprint can't be + * gated (e.g. the selector returned the root state), so the subscriber + * is hit on every dispatch that changes the root reference. + */ + segments: Set | null + /** Promote this subscriber to the deep tier. Called at most once. */ + onCoarseHit(): void +} + +/** + * Inverted index from top-level state key ("segment") to the coarse + * subscribers whose probed footprint includes that key. Lets the + * SignalProvider wake only the deferred subscribers that could possibly + * be affected by a dispatch, using the changed root keys the state diff + * already computes. + */ +export interface SegmentIndex { + register(sub: CoarseSub): void + /** Idempotent: unregistering a sub that isn't registered is a no-op. */ + unregister(sub: CoarseSub): void + /** + * Collect subscribers whose footprint intersects the changed root keys. + * `null` means the root wasn't diffable (non-plain-object root): every + * subscriber is a candidate. An empty array still returns wildcard + * subscribers — the root reference changed even if no key did. + * Returns a snapshot array: subscribers unregister themselves during + * `onCoarseHit`, so the caller must not iterate live index structures. + */ + collect(changedRootKeys: readonly string[] | null): readonly CoarseSub[] + size(): number +} + +const EMPTY: readonly CoarseSub[] = [] + +export function createSegmentIndex(): SegmentIndex { + const all = new Set() + const bySegment = new Map>() + const wildcards = new Set() + + return { + register(sub: CoarseSub): void { + if (all.has(sub)) return + all.add(sub) + if (sub.segments === null) { + wildcards.add(sub) + return + } + for (const seg of sub.segments) { + let bucket = bySegment.get(seg) + if (bucket === undefined) { + bucket = new Set() + bySegment.set(seg, bucket) + } + bucket.add(sub) + } + }, + + unregister(sub: CoarseSub): void { + if (!all.delete(sub)) return + if (sub.segments === null) { + wildcards.delete(sub) + return + } + for (const seg of sub.segments) { + const bucket = bySegment.get(seg) + if (bucket !== undefined) { + bucket.delete(sub) + if (bucket.size === 0) bySegment.delete(seg) + } + } + }, + + collect(changedRootKeys: readonly string[] | null): readonly CoarseSub[] { + if (all.size === 0) return EMPTY + if (changedRootKeys === null) return Array.from(all) + if (changedRootKeys.length === 0) { + return wildcards.size > 0 ? Array.from(wildcards) : EMPTY + } + const hits = new Set(wildcards) + for (let i = 0; i < changedRootKeys.length; i++) { + const bucket = bySegment.get(changedRootKeys[i]) + if (bucket !== undefined) { + for (const sub of bucket) hits.add(sub) + } + } + return hits.size > 0 ? Array.from(hits) : EMPTY + }, + + size(): number { + return all.size + }, + } +} + +/** + * Mutable record filled in while a selector runs against a probe proxy. + */ +export interface ProbeRecord { + /** Raw top-level state keys the selector touched (get / has / + * getOwnPropertyDescriptor). */ + segments: Set + /** The selector enumerated the root's keys (Object.keys, spread, + * for...in). Key-set changes can't be gated by the segments alone, + * so an enumerating selector must build the deep tier eagerly. */ + enumerated: boolean +} + +/** + * Create a one-level-shallow probe proxy over the root state object. + * + * Unlike the deep tracking proxy, the probe: + * - records only WHICH top-level keys the selector reads (no signals, + * no path strings, no nested proxies) + * - returns raw nested values, so the selector runs at native speed + * below the first level + * + * The proxy target is the real (frozen) state object and every trap + * returns the target's own values, so no ES Proxy invariants are + * violated and no shell object is needed. + * + * The proxy is registered in the proxy→target map so `untrackResult` + * and `unwrap` strip it from selector results (`s => s`, + * `s => ({ root: s })`). + * + * Only plain-object roots may be probed: for class instances / Map / + * Set roots, method calls through the proxy would run with the proxy as + * `this` and break internal-slot access. Callers must check first. + * @param state - The root state object (plain object) + * @returns The probe proxy and the record it fills in + */ +export function createProbeProxy( + state: T, +): { proxy: T; record: ProbeRecord } { + const record: ProbeRecord = { segments: new Set(), enumerated: false } + + const proxy = new Proxy(state, { + get(target, prop) { + if (typeof prop === 'symbol') return Reflect.get(target, prop) + record.segments.add(prop) + return (target as Record)[prop] + }, + + has(target, prop) { + if (typeof prop !== 'symbol') record.segments.add(prop) + return Reflect.has(target, prop) + }, + + ownKeys(target) { + record.enumerated = true + return Reflect.ownKeys(target) + }, + + // Fires for Object.getOwnPropertyDescriptor and hasOwnProperty, and + // per-key during enumeration (where `enumerated` is already set by + // ownKeys). A single descriptor read reveals one key's existence and + // value — record it as a segment read, like get/has. + getOwnPropertyDescriptor(target, prop) { + if (typeof prop !== 'symbol') record.segments.add(prop) + return Reflect.getOwnPropertyDescriptor(target, prop) + }, + + set() { + return false + }, + + deleteProperty() { + return false + }, + }) as T + + registerProxyTarget(proxy as object, state) + + return { proxy, record } +} diff --git a/src/signals/diff.ts b/src/signals/diff.ts index d622ae56d..4b3bf7b64 100644 --- a/src/signals/diff.ts +++ b/src/signals/diff.ts @@ -21,6 +21,10 @@ function isPlainObject(v: unknown): v is Record { * @param next - Next object state * @param parentPath - Dot-separated path to this object in the state tree * @param registry - Signal registry to update + * @param changedRootKeys - Root call only: collects the raw (unencoded) + * keys whose values changed, were added, or were removed. Feeds the + * coarse segment index — the ref-compare over root keys happens here + * anyway, so collection is a push per *changed* key, not a second pass. * @returns void */ function diffObject( @@ -28,6 +32,7 @@ function diffObject( next: Record, parentPath: string, registry: PathSignalRegistry, + changedRootKeys?: string[], ): void { const nextKeys = Object.keys(next) const prevKeys = Object.keys(prev) @@ -50,8 +55,24 @@ function diffObject( // descend and re-fire the leaf signal on every dispatch). const prevVal = prev[key] const nextVal = next[key] - if (prevVal === nextVal || (prevVal !== prevVal && nextVal !== nextVal)) + if (prevVal === nextVal || (prevVal !== prevVal && nextVal !== nextVal)) { + // Added key whose value is `undefined`: ref-equal to the missing + // read (undefined === undefined), but `in` / hasOwnProperty gates + // on this key still flipped — the root collector must see it. The + // extra `in` check only runs for undefined-valued keys. + if ( + changedRootKeys !== undefined && + nextVal === undefined && + !(key in prev) + ) { + changedRootKeys.push(key) + } continue + } + + if (changedRootKeys !== undefined) { + changedRootKeys.push(key) + } // Keys are encoded to match the proxy's pathKey construction — // reserved path characters in state keys (dots, braces, '@') are @@ -69,6 +90,9 @@ function diffObject( for (let i = 0; i < prevKeys.length; i++) { if (!(prevKeys[i] in next)) { + if (changedRootKeys !== undefined) { + changedRootKeys.push(prevKeys[i]) + } const segment = encodePathSegment(prevKeys[i]) const childPath = parentPath ? parentPath + '.' + segment : segment registry.prune(childPath) @@ -572,21 +596,39 @@ function diffArrayByIndex( } } +const NO_CHANGED_KEYS: string[] = [] + /** * Wrapper that batches all signal updates into a single propagation pass. + * + * Returns the raw root keys whose values changed (added / removed / + * ref-changed) so the SignalProvider can wake coarse-tier subscribers, + * or `null` when the root wasn't a diffable pair of plain objects (the + * caller must treat every coarse subscriber as a candidate). * @param prev - Previous state * @param next - Next state * @param registry - Signal registry to update * @param engine - Signal engine for batching - * @returns void + * @returns Changed root keys, or null if the root couldn't be diffed */ export function reconcileState( prev: unknown, next: unknown, registry: PathSignalRegistry, engine: SignalEngine, -): void { +): string[] | null { + if (prev === next) return NO_CHANGED_KEYS + + if (isPlainObject(prev) && isPlainObject(next)) { + const changedRootKeys: string[] = [] + engine.batch(() => { + diffObject(prev, next, '', registry, changedRootKeys) + }) + return changedRootKeys + } + engine.batch(() => { diffAndUpdateSignals(prev, next, '', registry) }) + return null } diff --git a/src/signals/pathSignalRegistry.ts b/src/signals/pathSignalRegistry.ts index ea5f076c0..41dae7472 100644 --- a/src/signals/pathSignalRegistry.ts +++ b/src/signals/pathSignalRegistry.ts @@ -1,5 +1,7 @@ import { encodePathSegment } from './arrayKeys' import type { ArrayMeta } from './arrayKeys' +import { createSegmentIndex } from './coarseSegments' +import type { SegmentIndex } from './coarseSegments' import type { LeafObjectTracker, ProxyCache } from './trackingProxy' import type { PathKey, ReactiveSignal, SignalEngine } from './types' @@ -46,6 +48,11 @@ export interface PathSignalRegistry { /** Return all registered signal paths (for testing/debugging). */ debugPaths(): string[] + /** Coarse tier: inverted index of top-level state keys → deferred + * subscribers. Driven by SignalProvider with the changed root keys + * reconcileState reports each dispatch. */ + segmentIndex: SegmentIndex + /** Proxy cache for reusing proxies across evaluations (keyed by object identity). */ proxyCache: ProxyCache @@ -313,6 +320,8 @@ export function createPathSignalRegistry( return Array.from(signals.keys()).sort() }, + segmentIndex: createSegmentIndex(), + proxyCache: proxyWeakMap, leafTrackerHolder: { current: undefined }, diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index 60c86eb19..f1075917a 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -142,6 +142,18 @@ export function getProxyTarget(value: object): object | undefined { return proxyTargetMap.get(value) } +/** + * Register a proxy→target mapping for a proxy created outside this + * module (the coarse-tier probe proxy). Makes `unwrap` and + * `untrackResult` treat it like any tracking proxy. + * @param proxy - The proxy object + * @param target - The raw object it wraps + * @returns void + */ +export function registerProxyTarget(proxy: object, target: object): void { + proxyTargetMap.set(proxy, target) +} + export function unwrap(value: T): T { if (value !== null && typeof value === 'object') { const target = proxyTargetMap.get(value as object) diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index eb4027857..72f1ffc65 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -6,6 +6,8 @@ import type { } from '../hooks/useSelector' import type { ReactReduxContextValue } from '../components/Context' import type { EqualityFn } from '../types' +import { createProbeProxy } from './coarseSegments' +import type { CoarseSub } from './coarseSegments' import type { SignalContextValue } from './context' import { createSignalContextHook, @@ -30,6 +32,15 @@ const refEquality: EqualityFn = Object.is // cut off propagation and React would never be notified of the error. const SELECTOR_THREW = Symbol('selector threw') as unknown +// Only plain-object roots can be probed by the coarse tier: method calls +// through a proxy over a Map/Set/class-instance root would run with the +// proxy as `this` and break internal-slot access. +function isPlainObjectState(v: unknown): v is object { + if (v === null || typeof v !== 'object') return false + const proto = Object.getPrototypeOf(v) + return proto === Object.prototype || proto === null +} + /** * A React hook that selects state from a Redux store using signal-based @@ -102,6 +113,25 @@ const useSignalSelectorImpl = ( // is still mounted). getSnapshot retries and rethrows if it persists. let pendingError: unknown = null + // --- Coarse tier state --- + // The hook starts "unbuilt": no tracking proxies, no path signals, + // no alien-signals effect. At mount, one shallow probe records which + // top-level state keys the selector reads; the hook then waits in + // the segment index and is promoted to the deep tier on the first + // dispatch that changes one of those keys. + let built = false + let disposeEffect: (() => void) | null = null + let coarseSub: CoarseSub | null = null + let probeSegments = new Set() + // Footprint can't be gated: the selector read no top-level keys, + // returned the root, or enumerated root keys — or the root isn't a + // plain object. Such hooks build the deep tier eagerly. + let ungateable = false + // State ref that probeSegments/currentResult were probed against. + let probedState: unknown = null + // Freshness cache for unbuilt getSnapshot recomputes. + let lastSnapshotState: unknown = null + // Create a computed that runs the selector through a tracking proxy. // This establishes signal dependencies on the paths the selector reads. // @@ -147,14 +177,52 @@ const useSignalSelectorImpl = ( const setSelector = (nextSelector: (state: S) => R): void => { selectorRef.current = nextSelector - // Recompute in place: this runs during render, and getSnapshot - // picks up the fresh value in the same pass. Scheduling a - // re-render here would loop forever for selectors that return a - // new reference on every run. - recomputeInPlace() - if (pendingError !== null) { - // The new closure threw — surface it in this render. - throw pendingError + + if (built) { + // Recompute in place: this runs during render, and getSnapshot + // picks up the fresh value in the same pass. Scheduling a + // re-render here would loop forever for selectors that return a + // new reference on every run. + recomputeInPlace() + if (pendingError !== null) { + // The new closure threw — surface it in this render. + throw pendingError + } + return + } + + // Still in the coarse tier: the registered footprint reflects the + // OLD closure. Re-probe with the new one and swap the registration + // — `s => flag ? s.a : s.b` left registered under {a} alone would + // silently miss every change to b. + const state = store.getState() + if (isPlainObjectState(state)) { + // A probe throw propagates into this render (stock parity for + // selector swaps); the old registration stays until a working + // closure lands. + probe(state as S & object) + if (coarseSub !== null) { + registry.segmentIndex.unregister(coarseSub) + coarseSub = { + // Ungateable after the swap (returns root / enumerates keys): + // the deep effect can't be attached during render, so + // register a wildcard — the next root change promotes it. + segments: ungateable ? null : probeSegments, + onCoarseHit, + } + registry.segmentIndex.register(coarseSub) + } + // Not subscribed yet: subscribe() handles registration (and the + // eager build for ungateable footprints). + } else { + // Root became non-plain while never subscribed (a dispatch in + // the render→subscribe gap) — can't probe it. Promote now; the + // effect attaches in subscribe(). + built = true + recomputeInPlace() + if (pendingError !== null) { + throw pendingError + } } } @@ -288,65 +356,197 @@ const useSignalSelectorImpl = ( } }) - // Initialize with current value. A selector that throws on mount - // surfaces here, during render — same as stock useSelector. - currentResult = selectorComputed.get() - if (pendingError !== null) { - throw pendingError + // Create the alien-signals effect that drives the deep tier: fires + // when the computed value changes, applies the user's equalityFn, + // and notifies React. Extracted from subscribe() because promotion + // from the coarse tier (first coarse hit) also attaches it. + const attachEffect = (): void => { + let isFirst = true + disposeEffect = engine.effect(() => { + const newValue = selectorComputed.get() + + if (pendingError !== null) { + // The evaluation threw (newValue is the SELECTOR_THREW + // sentinel — never adopt it). Notify React so getSnapshot + // can retry and surface or dissolve the error. + isFirst = false + if (!suppressNotify) { + notifyReact?.() + } + return + } + + if (isFirst) { + // First effect run — just establish tracking, don't notify + isFirst = false + return + } + + if (suppressNotify) { + // Render-phase selector swap (setSelector): adopt the value + // so the equality baseline is current, but let the ongoing + // render pick it up via getSnapshot instead of scheduling + // another render. + currentResult = newValue + return + } + + // Apply user's equality function + if (!equalityFnRef.current(currentResult, newValue)) { + currentResult = newValue + version++ + notifyReact?.() + } + }) + } + + // Run the selector once through a one-level shallow probe proxy: + // seeds currentResult and records the coarse footprint (which + // top-level keys it read) without building tracking proxies or path + // signals. Throws propagate to the caller — mount render / selector + // swap, stock parity. + const probe = (state: S & object): void => { + const { proxy, record } = createProbeProxy(state) + const value = selectorRef.current(proxy as S) + if (process.env.NODE_ENV !== 'production') { + runDevModeChecks(value, proxy as S) + } + currentResult = untrackResult(value) + probeSegments = record.segments + ungateable = record.segments.size === 0 || record.enumerated + probedState = state + lastSnapshotState = state + } + + // Promotion: a dispatch changed one of this hook's root segments. + // Build the deep tier — evaluate the computed (creating tracking + // proxies and path signals), attach the driving effect, and notify + // React if the value actually changed. Runs synchronously inside + // the Provider's onStateChange, before notifyNestedSubs. + const onCoarseHit = (): void => { + if (built || notifyReact === null) return + if (coarseSub !== null) { + registry.segmentIndex.unregister(coarseSub) + coarseSub = null + } + built = true + attachEffect() + // The effect's first run evaluated the computed (isFirst skip); + // this read returns the cached value. + const newValue = selectorComputed.get() + if (pendingError !== null) { + // Zombie-child window: the dispatch that woke us also removed + // the entity. getSnapshot retries and surfaces or dissolves it. + notifyReact?.() + return + } + if (!equalityFnRef.current(currentResult, newValue)) { + currentResult = newValue + version++ + notifyReact?.() + } + } + + // --- Mount seeding --- + // Probe the selector's coarse footprint and seed the initial value. + // Footprints that can't be gated (and non-plain-object roots, which + // can't be probed) fall back to building the deep tier eagerly — the + // pre-coarse-tier behavior. A selector that throws on mount surfaces + // here, during render — same as stock useSelector. + const initialState = store.getState() + if (isPlainObjectState(initialState)) { + probe(initialState as S & object) + } else { + ungateable = true + } + if (ungateable) { + built = true + currentResult = selectorComputed.get() + if (pendingError !== null) { + throw pendingError + } } return { subscribe(onStoreChange: () => void): () => void { notifyReact = onStoreChange - // Create an effect that fires when the computed value changes. - // We apply the user's equalityFn here since alien-signals - // doesn't support custom equality per-computed. - let isFirst = true - const dispose = engine.effect(() => { - const newValue = selectorComputed.get() - - if (pendingError !== null) { - // The evaluation threw (newValue is the SELECTOR_THREW - // sentinel — never adopt it). Notify React so getSnapshot - // can retry and surface or dissolve the error. - isFirst = false - if (!suppressNotify) { - notifyReact?.() + if (!built) { + // A dispatch may have landed between render (probe) and + // subscribe. Re-probe so the registered footprint and seeded + // value reflect the state we're subscribing against. + const state = store.getState() + if (state !== probedState) { + if (isPlainObjectState(state)) { + try { + probe(state as S & object) + } catch { + // The selector threw against the newer state (zombie + // window). Fall back to the deep tier — its pendingError + // machinery owns surfacing (effect notify → getSnapshot + // rethrow into render). + built = true + recomputeInPlace() + } + } else { + built = true + recomputeInPlace() } - return } - if (isFirst) { - // First effect run — just establish tracking, don't notify - isFirst = false - return - } - - if (suppressNotify) { - // Render-phase selector swap (setSelector): adopt the value - // so the equality baseline is current, but let the ongoing - // render pick it up via getSnapshot instead of scheduling - // another render. - currentResult = newValue - return + if (!built) { + if (ungateable) { + // Can't gate this footprint — build eagerly. On error the + // computed set pendingError; the effect's first run + // notifies and getSnapshot rethrows into render. + built = true + const value = selectorComputed.get() + if (pendingError === null) { + currentResult = value + } + } else { + coarseSub = { segments: probeSegments, onCoarseHit } + registry.segmentIndex.register(coarseSub) + } } + } - // Apply user's equality function - if (!equalityFnRef.current(currentResult, newValue)) { - currentResult = newValue - version++ - notifyReact?.() - } - }) + if (built && disposeEffect === null) { + attachEffect() + } return () => { notifyReact = null - dispose() + if (coarseSub !== null) { + registry.segmentIndex.unregister(coarseSub) + coarseSub = null + } + if (disposeEffect !== null) { + disposeEffect() + disposeEffect = null + } } }, getSnapshot(): R { + if (!built) { + // Deferred: no effect is watching yet. Serve fresh values for + // dispatches in the render→subscribe gap (and wildcard waits) + // by recomputing against RAW state — no proxies, no signals, + // no untracking needed. Cached per state ref; equalityFn + // preserves the previous result's identity for equal-but-new + // references. A throw propagates into render — stock parity. + const state = store.getState() + if (state !== lastSnapshotState) { + lastSnapshotState = state + const fresh = selectorRef.current(state as S) + if (!equalityFnRef.current(currentResult, fresh)) { + currentResult = fresh + } + } + return currentResult + } + if (pendingError !== null) { // A previous evaluation threw. Retry against current state: // the situation may have resolved (entity restored, or this is diff --git a/test/signals/coarseSegments.spec.ts b/test/signals/coarseSegments.spec.ts new file mode 100644 index 000000000..d861f7179 --- /dev/null +++ b/test/signals/coarseSegments.spec.ts @@ -0,0 +1,289 @@ +import { describe, it, expect } from 'vitest' +import type { CoarseSub } from '../../src/signals/coarseSegments' +import { + createProbeProxy, + createSegmentIndex, +} from '../../src/signals/coarseSegments' +import { alienEngine } from '../../src/signals/engine' +import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import { reconcileState } from '../../src/signals/diff' +import { unwrap } from '../../src/signals/trackingProxy' + +function makeSub(segments: Set | null): CoarseSub & { hits: number } { + const sub: CoarseSub & { hits: number } = { + segments, + hits: 0, + onCoarseHit() { + sub.hits++ + }, + } + return sub +} + +describe('SegmentIndex', () => { + it('collects subs whose segments intersect the changed root keys', () => { + const index = createSegmentIndex() + const subA = makeSub(new Set(['a'])) + const subB = makeSub(new Set(['b'])) + const subAB = makeSub(new Set(['a', 'b'])) + index.register(subA) + index.register(subB) + index.register(subAB) + + expect(index.collect(['a'])).toEqual( + expect.arrayContaining([subA, subAB]), + ) + expect(index.collect(['a'])).toHaveLength(2) + expect(index.collect(['c'])).toHaveLength(0) + }) + + it('dedupes a sub matched by multiple changed keys', () => { + const index = createSegmentIndex() + const subAB = makeSub(new Set(['a', 'b'])) + index.register(subAB) + + expect(index.collect(['a', 'b'])).toHaveLength(1) + }) + + it('collect(null) returns every registered sub (non-diffable root)', () => { + const index = createSegmentIndex() + const subA = makeSub(new Set(['a'])) + const wildcard = makeSub(null) + index.register(subA) + index.register(wildcard) + + expect(index.collect(null)).toHaveLength(2) + }) + + it('collect([]) returns only wildcard subs', () => { + const index = createSegmentIndex() + const subA = makeSub(new Set(['a'])) + const wildcard = makeSub(null) + index.register(subA) + index.register(wildcard) + + const hits = index.collect([]) + expect(hits).toHaveLength(1) + expect(hits[0]).toBe(wildcard) + }) + + it('wildcard subs are collected for any changed key', () => { + const index = createSegmentIndex() + const wildcard = makeSub(null) + index.register(wildcard) + + expect(index.collect(['whatever'])).toHaveLength(1) + }) + + it('unregister removes a sub and is idempotent', () => { + const index = createSegmentIndex() + const subA = makeSub(new Set(['a'])) + index.register(subA) + expect(index.size()).toBe(1) + + index.unregister(subA) + expect(index.size()).toBe(0) + expect(index.collect(['a'])).toHaveLength(0) + + // Second unregister is a no-op, not an error. + index.unregister(subA) + expect(index.size()).toBe(0) + }) + + it('a sub can unregister itself during onCoarseHit without breaking iteration', () => { + const index = createSegmentIndex() + const order: string[] = [] + const subs: CoarseSub[] = [] + for (const name of ['one', 'two', 'three']) { + const sub: CoarseSub = { + segments: new Set(['a']), + onCoarseHit() { + order.push(name) + index.unregister(sub) + }, + } + subs.push(sub) + index.register(sub) + } + + const hits = index.collect(['a']) + for (const hit of hits) { + hit.onCoarseHit() + } + + expect(order).toEqual(['one', 'two', 'three']) + expect(index.size()).toBe(0) + }) + + it('re-registering a sub with different segments replaces the old registration', () => { + const index = createSegmentIndex() + const sub = makeSub(new Set(['a'])) + index.register(sub) + index.unregister(sub) + + const swapped = makeSub(new Set(['b'])) + index.register(swapped) + + expect(index.collect(['a'])).toHaveLength(0) + expect(index.collect(['b'])).toHaveLength(1) + expect(index.size()).toBe(1) + }) +}) + +describe('createProbeProxy', () => { + const state = Object.freeze({ + a: Object.freeze({ value: 1 }), + b: Object.freeze({ value: 2 }), + list: Object.freeze([1, 2, 3]), + }) + + it('records top-level keys read through the proxy and returns raw values', () => { + const { proxy, record } = createProbeProxy(state) + + const a = proxy.a + expect(a).toBe(state.a) // raw value, not a nested proxy + expect(a.value).toBe(1) + + expect(record.segments).toEqual(new Set(['a'])) + expect(record.enumerated).toBe(false) + }) + + it('records only the keys actually read', () => { + const { proxy, record } = createProbeProxy(state) + + void proxy.a + void proxy.list + + expect(record.segments).toEqual(new Set(['a', 'list'])) + }) + + it("records segments for 'in' checks", () => { + const { proxy, record } = createProbeProxy(state) + + expect('a' in proxy).toBe(true) + expect('missing' in proxy).toBe(false) + + expect(record.segments).toEqual(new Set(['a', 'missing'])) + }) + + it('records segments for hasOwnProperty checks', () => { + const { proxy, record } = createProbeProxy(state) + + expect(Object.prototype.hasOwnProperty.call(proxy, 'b')).toBe(true) + + expect(record.segments.has('b')).toBe(true) + }) + + it('flags enumeration (Object.keys)', () => { + const { proxy, record } = createProbeProxy(state) + + expect(Object.keys(proxy)).toEqual(['a', 'b', 'list']) + expect(record.enumerated).toBe(true) + }) + + it('flags enumeration (spread)', () => { + const { proxy, record } = createProbeProxy(state) + + const copy = { ...proxy } + expect(copy.a).toBe(state.a) + expect(record.enumerated).toBe(true) + }) + + it('does not record symbol reads', () => { + const { proxy, record } = createProbeProxy(state) + + void (proxy as any)[Symbol.toStringTag] + + expect(record.segments.size).toBe(0) + }) + + it('captures the partial footprint when the selector throws', () => { + const { proxy, record } = createProbeProxy(state) + + expect(() => { + void proxy.a + throw new Error('selector blew up') + }).toThrow('selector blew up') + + // Anything read before the throw is in the footprint — this is what + // makes gating on a partial footprint sound: the segment whose value + // controls the throwing path was read before the throw. + expect(record.segments).toEqual(new Set(['a'])) + }) + + it('unwrap() resolves the probe proxy back to the raw state', () => { + const { proxy } = createProbeProxy(state) + + expect(unwrap(proxy)).toBe(state) + }) + + it('rejects writes', () => { + const { proxy } = createProbeProxy(state) + + expect(() => { + ;(proxy as any).a = 42 + }).toThrow(TypeError) + expect(() => { + delete (proxy as any).a + }).toThrow(TypeError) + }) +}) + +describe('reconcileState changed-root-keys return value', () => { + function makeRegistry() { + return createPathSignalRegistry(alienEngine) + } + + it('returns [] when prev === next', () => { + const registry = makeRegistry() + const state = { a: 1 } + + expect(reconcileState(state, state, registry, alienEngine)).toEqual([]) + }) + + it('returns the top-level keys whose references changed', () => { + const registry = makeRegistry() + const prev = { a: { v: 1 }, b: { v: 2 }, c: { v: 3 } } + const next = { a: { v: 9 }, b: prev.b, c: prev.c } + + expect(reconcileState(prev, next, registry, alienEngine)).toEqual(['a']) + }) + + it('includes added keys', () => { + const registry = makeRegistry() + const prev: Record = { a: 1 } + const next = { a: 1, added: 2 } + + expect(reconcileState(prev, next, registry, alienEngine)).toEqual([ + 'added', + ]) + }) + + it('includes keys added with an undefined value', () => { + const registry = makeRegistry() + const prev: Record = { a: 1 } + const next = { a: 1, added: undefined } + + expect(reconcileState(prev, next, registry, alienEngine)).toEqual([ + 'added', + ]) + }) + + it('includes removed keys', () => { + const registry = makeRegistry() + const prev = { a: 1, removed: 2 } + const next = { a: 1 } + + expect(reconcileState(prev, next, registry, alienEngine)).toEqual([ + 'removed', + ]) + }) + + it('returns null when the root is not a plain object', () => { + const registry = makeRegistry() + const prev = new Map([['v', 1]]) + const next = new Map([['v', 2]]) + + expect(reconcileState(prev, next, registry, alienEngine)).toBeNull() + }) +}) diff --git a/test/signals/coarseTier.spec.tsx b/test/signals/coarseTier.spec.tsx new file mode 100644 index 000000000..b144f4301 --- /dev/null +++ b/test/signals/coarseTier.spec.tsx @@ -0,0 +1,552 @@ +/** + * Integration tests for the coarse tier: hooks defer building their deep + * signal graph at mount. The initial render runs the selector once through + * a cheap top-level probe proxy, registers the touched root segments in + * the registry's SegmentIndex, and only builds the full tracking-proxy + * graph when a dispatch actually changes one of those segments. + * + * Key invariants under test: + * - No signals are materialized for a deferred hook (registry.size() === 0) + * - Irrelevant dispatches neither re-render nor promote a deferred hook + * - A relevant dispatch promotes exactly the affected hooks, which then + * behave identically to eagerly-built hooks + * - Swapping the selector while unbuilt re-probes and re-registers (the + * missed-update bug in react-redux PR #2355's version of this design) + * - Selectors the probe can't gate (root enumeration, empty footprint, + * non-plain-object root state) build eagerly at mount + */ +import type { UnknownAction } from '@reduxjs/toolkit' +import { configureStore, createSlice } from '@reduxjs/toolkit' +import * as rtl from '@testing-library/react' +import React, { useCallback, useState } from 'react' +import { legacy_createStore as createStore } from 'redux' +import type { PathSignalRegistry } from '../../src/signals' +import { + SignalProvider, + useSignalContext, + useSignalSelector, +} from '../../src/signals' + +const makeStore = () => { + const aSlice = createSlice({ + name: 'a', + initialState: { value: 0 }, + reducers: { + bumpA(state) { + state.value++ + }, + }, + }) + const bSlice = createSlice({ + name: 'b', + initialState: { value: 0 }, + reducers: { + bumpB(state) { + state.value++ + }, + }, + }) + + const store = configureStore({ + reducer: { a: aSlice.reducer, b: bSlice.reducer }, + }) + + return { + store, + bumpA: aSlice.actions.bumpA, + bumpB: bSlice.actions.bumpB, + } +} + +type AppState = ReturnType['store']['getState']> + +const noStabilityCheck = { + devModeChecks: { stabilityCheck: 'never' as const }, +} + +function makeRegistryCapture() { + const captured: { registry: PathSignalRegistry | null } = { registry: null } + function Capture() { + captured.registry = useSignalContext().registry + return null + } + return { captured, Capture } +} + +afterEach(() => { + rtl.cleanup() +}) + +describe('coarse tier: deferred deep-graph build', () => { + it('defers building the deep graph until a dispatch touches a probed segment', () => { + const { store, bumpA, bumpB } = makeStore() + const { captured, Capture } = makeRegistryCapture() + let selectorCalls = 0 + let renders = 0 + + const selectAValue = (s: AppState) => { + selectorCalls++ + return s.a.value + } + + function Watcher() { + renders++ + const value = useSignalSelector(selectAValue, noStabilityCheck) + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + + , + ) + + const registry = captured.registry! + expect(getByTestId('value').textContent).toBe('0') + expect(selectorCalls).toBe(1) // one probe run, nothing else + expect(renders).toBe(1) + expect(registry.size()).toBe(0) // no signals materialized + expect(registry.segmentIndex.size()).toBe(1) + + // Irrelevant dispatch: doesn't re-render, doesn't run the selector, + // doesn't promote. + rtl.act(() => { + store.dispatch(bumpB()) + }) + expect(renders).toBe(1) + expect(selectorCalls).toBe(1) + expect(registry.size()).toBe(0) + expect(registry.segmentIndex.size()).toBe(1) + + // Relevant dispatch: promotes, builds the deep graph, re-renders. + rtl.act(() => { + store.dispatch(bumpA()) + }) + expect(getByTestId('value').textContent).toBe('1') + expect(registry.segmentIndex.size()).toBe(0) + expect(registry.size()).toBeGreaterThan(0) + + // Post-promotion precision: the deep graph ignores b-only dispatches. + const rendersAfterPromotion = renders + rtl.act(() => { + store.dispatch(bumpB()) + }) + expect(renders).toBe(rendersAfterPromotion) + + // And keeps tracking a. + rtl.act(() => { + store.dispatch(bumpA()) + }) + expect(getByTestId('value').textContent).toBe('2') + }) + + it('re-probes when the selector is swapped while unbuilt (missed-update bug in PR #2355)', () => { + const { store, bumpA, bumpB } = makeStore() + let renders = 0 + + function Watcher() { + renders++ + const [useB, setUseB] = useState(false) + const value = useSignalSelector( + useCallback( + (s: AppState) => (useB ? s.b.value : s.a.value), + [useB], + ), + noStabilityCheck, + ) + return ( +
+
{value}
+ +
+ ) + } + + const { getByTestId } = rtl.render( + + + , + ) + expect(getByTestId('value').textContent).toBe('0') + + // Swap the selector's footprint from {a} to {b} while still unbuilt. + rtl.fireEvent.click(getByTestId('toggle')) + expect(getByTestId('value').textContent).toBe('0') // b.value is still 0 + + // A dispatch that changes b MUST reach this hook. A stale {a} + // registration (the #2355 bug) would miss this update forever. + rtl.act(() => { + store.dispatch(bumpB()) + }) + expect(getByTestId('value').textContent).toBe('1') + + // After promotion the deep graph tracks only b. + const rendersAfter = renders + rtl.act(() => { + store.dispatch(bumpA()) + }) + expect(renders).toBe(rendersAfter) + }) + + it("gates on segments probed via 'in' checks and promotes when the key appears", () => { + interface RootState { + a: { v: number } + dynamicKey?: number + } + const reducer = ( + state: RootState = { a: { v: 0 } }, + action: UnknownAction, + ): RootState => { + switch (action.type) { + case 'addKey': + return { ...state, dynamicKey: 1 } + case 'bumpA': + return { ...state, a: { v: state.a.v + 1 } } + default: + return state + } + } + const store = configureStore({ reducer }) + let renders = 0 + + const selectHasKey = (s: RootState) => 'dynamicKey' in s + + function Watcher() { + renders++ + const has = useSignalSelector(selectHasKey, noStabilityCheck) + return
{String(has)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + expect(getByTestId('has').textContent).toBe('false') + + // Changing an unrelated segment doesn't wake the hook. + rtl.act(() => { + store.dispatch({ type: 'bumpA' }) + }) + expect(renders).toBe(1) + + // Adding the probed key does. + rtl.act(() => { + store.dispatch({ type: 'addKey' }) + }) + expect(getByTestId('has').textContent).toBe('true') + }) + + it('builds eagerly when the selector enumerates the root (Object.keys)', () => { + interface RootState { + a: { v: number } + dynamicKey?: number + } + const reducer = ( + state: RootState = { a: { v: 0 } }, + action: UnknownAction, + ): RootState => { + switch (action.type) { + case 'addKey': + return { ...state, dynamicKey: 1 } + default: + return state + } + } + const store = configureStore({ reducer }) + const { captured, Capture } = makeRegistryCapture() + + const selectKeyCount = (s: RootState) => Object.keys(s).length + + function Watcher() { + const count = useSignalSelector(selectKeyCount, noStabilityCheck) + return
{count}
+ } + + const { getByTestId } = rtl.render( + + + + , + ) + + const registry = captured.registry! + expect(getByTestId('count').textContent).toBe('1') + // Enumeration can't be gated by top-level segments — built at mount. + expect(registry.segmentIndex.size()).toBe(0) + expect(registry.size()).toBeGreaterThan(0) + + rtl.act(() => { + store.dispatch({ type: 'addKey' }) + }) + expect(getByTestId('count').textContent).toBe('2') + }) + + it('builds eagerly for an identity selector and returns the raw state', () => { + const { store, bumpA } = makeStore() + const { captured, Capture } = makeRegistryCapture() + let selected: AppState | null = null + + const identity = (s: AppState) => s + + function Watcher() { + selected = useSignalSelector(identity, { + devModeChecks: { + stabilityCheck: 'never', + identityFunctionCheck: 'never', + }, + }) + return null + } + + rtl.render( + + + + , + ) + + const registry = captured.registry! + // Empty probe footprint (returned the root without reading keys): + // can't be gated, so the deep graph builds at mount. + expect(registry.segmentIndex.size()).toBe(0) + // The hook hands back the RAW state object, not a proxy. + expect(selected).toBe(store.getState()) + + // NOTE: `s => s` does NOT re-render on dispatch — the reconcile pass + // never bumps a signal for the root path, so an identity selector has + // no dependencies. This is a PRE-EXISTING limitation of the deep + // tracking system (verified against the pre-coarse-tier + // implementation), not a coarse-tier behavior, so it is not asserted + // here. Dev mode already warns about identity selectors. + }) + + it('serves a fresh snapshot when a dispatch lands between render and subscribe', () => { + const { store, bumpA } = makeStore() + const { captured, Capture } = makeRegistryCapture() + + const selectAValue = (s: AppState) => s.a.value + + function Watcher() { + const value = useSignalSelector(selectAValue, noStabilityCheck) + return
{value}
+ } + + // Layout effects run before useSyncExternalStore's passive subscribe + // effect, so this dispatch lands in the render->subscribe gap. + function LayoutDispatcher() { + React.useLayoutEffect(() => { + store.dispatch(bumpA()) + }, []) + return null + } + + const { getByTestId } = rtl.render( + + + + + , + ) + + // The probe saw a.value === 0; the snapshot must reflect the dispatch. + expect(getByTestId('value').textContent).toBe('1') + + // And the hook keeps working afterward. + rtl.act(() => { + store.dispatch(bumpA()) + }) + expect(getByTestId('value').textContent).toBe('2') + }) + + it('registers exactly one coarse sub under StrictMode double-subscribe', () => { + const { store, bumpA, bumpB } = makeStore() + const { captured, Capture } = makeRegistryCapture() + + const selectAValue = (s: AppState) => s.a.value + + function Watcher() { + const value = useSignalSelector(selectAValue, noStabilityCheck) + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + + + + , + ) + + const registry = captured.registry! + expect(getByTestId('value').textContent).toBe('0') + // subscribe -> cleanup -> resubscribe must not leak a registration. + expect(registry.segmentIndex.size()).toBe(1) + expect(registry.size()).toBe(0) + + rtl.act(() => { + store.dispatch(bumpB()) + }) + expect(registry.segmentIndex.size()).toBe(1) + + rtl.act(() => { + store.dispatch(bumpA()) + }) + expect(getByTestId('value').textContent).toBe('1') + expect(registry.segmentIndex.size()).toBe(0) + }) + + it('cleans up the coarse registration when unmounted while unbuilt', () => { + const { store, bumpA } = makeStore() + const { captured, Capture } = makeRegistryCapture() + + const selectAValue = (s: AppState) => s.a.value + + function Watcher() { + const value = useSignalSelector(selectAValue, noStabilityCheck) + return
{value}
+ } + + function App() { + const [show, setShow] = useState(true) + return ( + + {show ? : null} + + + + ) + } + + const { getByTestId } = rtl.render() + const registry = captured.registry! + expect(registry.segmentIndex.size()).toBe(1) + + rtl.fireEvent.click(getByTestId('hide')) + expect(registry.segmentIndex.size()).toBe(0) + expect(registry.size()).toBe(0) + + // A now-relevant dispatch finds no subscriber and breaks nothing. + expect(() => { + rtl.act(() => { + store.dispatch(bumpA()) + }) + }).not.toThrow() + expect(registry.size()).toBe(0) + }) + + it('promotes hooks independently: only the hook whose segment changed wakes up', () => { + const { store, bumpA, bumpB } = makeStore() + const { captured, Capture } = makeRegistryCapture() + let rendersA = 0 + let rendersB = 0 + + const selectAValue = (s: AppState) => s.a.value + const selectBValue = (s: AppState) => s.b.value + + function WatcherA() { + rendersA++ + const value = useSignalSelector(selectAValue, noStabilityCheck) + return
{value}
+ } + function WatcherB() { + rendersB++ + const value = useSignalSelector(selectBValue, noStabilityCheck) + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + + + , + ) + + const registry = captured.registry! + expect(registry.segmentIndex.size()).toBe(2) + expect(registry.size()).toBe(0) + + rtl.act(() => { + store.dispatch(bumpA()) + }) + expect(getByTestId('a').textContent).toBe('1') + expect(rendersB).toBe(1) // B never re-rendered + expect(registry.segmentIndex.size()).toBe(1) // B is still deferred + expect(registry.size()).toBeGreaterThan(0) // A's graph exists + + rtl.act(() => { + store.dispatch(bumpB()) + }) + expect(getByTestId('b').textContent).toBe('1') + expect(registry.segmentIndex.size()).toBe(0) + }) + + it('propagates a selector throw during the mount probe', () => { + const { store } = makeStore() + + const throwing = (s: AppState) => + (s.a as unknown as { missing: { value: number } }).missing.value + + function Watcher() { + const value = useSignalSelector(throwing, noStabilityCheck) + return
{value}
+ } + + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect(() => { + rtl.render( + + + , + ) + }).toThrow() + } finally { + spy.mockRestore() + } + }) + + it('declines to probe a non-plain root (Map roots are unsupported, pre-existing)', () => { + // The deep tracking system does not support Map root state: the + // tracking proxy's shell breaks Map method receivers, so mount throws + // "Method Map.prototype.get called on incompatible receiver". That + // failure predates the coarse tier (verified against the + // pre-coarse-tier implementation). What the coarse tier guarantees is + // that a non-plain root is never PROBED — the hook goes straight to + // the eager-build path and fails the same way it always did, instead + // of failing differently inside the probe proxy. + const store = createStore( + (state: Map = new Map([['v', 0]]), action) => + action.type === 'bump' + ? new Map([['v', (state.get('v') ?? 0) + 1]]) + : state, + ) + + const selectV = (s: Map) => s.get('v') + + function Watcher() { + const value = useSignalSelector(selectV, noStabilityCheck) + return
{value}
+ } + + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect(() => { + rtl.render( + + + , + ) + }).toThrow(/incompatible receiver/) + } finally { + spy.mockRestore() + } + }) +}) From f7b49a4256a40317c74853da0f023467f764ae16 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 1 Aug 2026 13:28:18 -0400 Subject: [PATCH 43/95] Improve perf with new map/set instances instead of clearing --- src/signals/useSignalSelector.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index 72f1ffc65..9344153e9 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -142,9 +142,9 @@ const useSignalSelectorImpl = ( // Track leaf object accesses for identity comparison support. // Objects read by the selector but never traversed deeper are // "leaf objects" — their identity matters (e.g., `a === b`). - // Reused across evaluations (cleared at the start of each) — a - // computed never re-enters its own evaluation, so this is safe and - // avoids two allocations per eval. + // The holder object is stable; its containers are swapped for fresh + // ones at the start of each evaluation (a computed never re-enters + // its own evaluation, so this is safe). const leafTracker: LeafObjectTracker = { accessedObjects: new Map(), traversedPaths: new Set(), @@ -298,8 +298,8 @@ const useSignalSelectorImpl = ( try { const state = store.getState() as S & object - leafTracker.accessedObjects.clear() - leafTracker.traversedPaths.clear() + leafTracker.accessedObjects = new Map() + leafTracker.traversedPaths = new Set() const proxy = createTrackingProxy( state, From 9abe6805bfdff3e395a4b21fc079145f8be9c895 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 7 Aug 2026 22:56:56 -0400 Subject: [PATCH 44/95] Add interop tests --- src/signals/pathSignalRegistry.ts | 27 ++ src/signals/useSignalSelector.ts | 96 ++++-- test/signals/coarseSegments.spec.ts | 14 +- test/signals/interop.spec.tsx | 501 ++++++++++++++++++++++++++++ test/signals/registryLeak.spec.tsx | 493 +++++++++++++++++++++++++++ test/signals/ssr.spec.tsx | 415 +++++++++++++++++++++++ 6 files changed, 1500 insertions(+), 46 deletions(-) create mode 100644 test/signals/interop.spec.tsx create mode 100644 test/signals/registryLeak.spec.tsx create mode 100644 test/signals/ssr.spec.tsx diff --git a/src/signals/pathSignalRegistry.ts b/src/signals/pathSignalRegistry.ts index 41dae7472..1d36efb21 100644 --- a/src/signals/pathSignalRegistry.ts +++ b/src/signals/pathSignalRegistry.ts @@ -14,6 +14,17 @@ import type { PathKey, ReactiveSignal, SignalEngine } from './types' */ export type StructureKind = 'append' | 'insertOrReorder' | 'remove' +export interface RegistryStats { + signals: number + prefixCounts: number + prefixOnlyPaths: number + childIndex: number + arrayMetas: number + columnsByArray: number + structuresByArray: number + segmentSubs: number +} + export interface PathSignalRegistry { /** Get or create a signal for a path. Object/array values use version counters. */ getOrCreate(pathKey: PathKey, currentValue: unknown): ReactiveSignal @@ -48,6 +59,9 @@ export interface PathSignalRegistry { /** Return all registered signal paths (for testing/debugging). */ debugPaths(): string[] + /** Entry counts for every internal structure (for testing/debugging). */ + debugStats(): RegistryStats + /** Coarse tier: inverted index of top-level state keys → deferred * subscribers. Driven by SignalProvider with the changed root keys * reconcileState reports each dispatch. */ @@ -320,6 +334,19 @@ export function createPathSignalRegistry( return Array.from(signals.keys()).sort() }, + debugStats(): RegistryStats { + return { + signals: signals.size, + prefixCounts: prefixCounts.size, + prefixOnlyPaths: prefixOnlyPaths.size, + childIndex: childIndex.size, + arrayMetas: arrayMetas.size, + columnsByArray: columnsByArray.size, + structuresByArray: structuresByArray.size, + segmentSubs: registry.segmentIndex.size(), + } + }, + segmentIndex: createSegmentIndex(), proxyCache: proxyWeakMap, diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index 9344153e9..7408dd2cf 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -131,6 +131,9 @@ const useSignalSelectorImpl = ( let probedState: unknown = null // Freshness cache for unbuilt getSnapshot recomputes. let lastSnapshotState: unknown = null + // Freshness cache for getServerSnapshot, keyed on the serverState ref. + let lastServerState: unknown = null + let serverResult: R // Create a computed that runs the selector through a tracking proxy. // This establishes signal dependencies on the paths the selector reads. @@ -467,6 +470,43 @@ const useSignalSelectorImpl = ( } } + const getSnapshot = (): R => { + if (!built) { + // Deferred: no effect is watching yet. Serve fresh values for + // dispatches in the render→subscribe gap (and wildcard waits) + // by recomputing against RAW state — no proxies, no signals, + // no untracking needed. Cached per state ref; equalityFn + // preserves the previous result's identity for equal-but-new + // references. A throw propagates into render — stock parity. + const state = store.getState() + if (state !== lastSnapshotState) { + lastSnapshotState = state + const fresh = selectorRef.current(state as S) + if (!equalityFnRef.current(currentResult, fresh)) { + currentResult = fresh + } + } + return currentResult + } + + if (pendingError !== null) { + // A previous evaluation threw. Retry against current state: + // the situation may have resolved (entity restored, or this is + // a fresh render pass after the parent re-rendered). Clear + // first — the effect's catch re-sets it if the selector still + // throws. On rethrow, LEAVE pendingError set so the next + // getSnapshot (React retries during render after a + // subscription-phase throw) re-evaluates instead of returning + // a stale value. + pendingError = null + recomputeInPlace() + if (pendingError !== null) { + throw pendingError + } + } + return currentResult + } + return { subscribe(onStoreChange: () => void): () => void { notifyReact = onStoreChange @@ -528,47 +568,33 @@ const useSignalSelectorImpl = ( } }, - getSnapshot(): R { - if (!built) { - // Deferred: no effect is watching yet. Serve fresh values for - // dispatches in the render→subscribe gap (and wildcard waits) - // by recomputing against RAW state — no proxies, no signals, - // no untracking needed. Cached per state ref; equalityFn - // preserves the previous result's identity for equal-but-new - // references. A throw propagates into render — stock parity. - const state = store.getState() - if (state !== lastSnapshotState) { - lastSnapshotState = state - const fresh = selectorRef.current(state as S) - if (!equalityFnRef.current(currentResult, fresh)) { - currentResult = fresh - } - } - return currentResult + getSnapshot, + + // Hydration snapshot. `` records the + // state the server rendered against; the client store may already + // have moved on, and returning its current value would produce a + // hydration mismatch. Runs the raw selector against the server + // state — no proxies, no signals, nothing to register — and + // caches per server-state reference so repeated calls in one + // hydration pass return an identical result. + getServerSnapshot(): R { + const { getServerState } = contextRef.current + if (getServerState === undefined) { + return getSnapshot() } - - if (pendingError !== null) { - // A previous evaluation threw. Retry against current state: - // the situation may have resolved (entity restored, or this is - // a fresh render pass after the parent re-rendered). Clear - // first — the effect's catch re-sets it if the selector still - // throws. On rethrow, LEAVE pendingError set so the next - // getSnapshot (React retries during render after a - // subscription-phase throw) re-evaluates instead of returning - // a stale value. - pendingError = null - recomputeInPlace() - if (pendingError !== null) { - throw pendingError - } + const state = getServerState() + if (state !== lastServerState) { + lastServerState = state + serverResult = selectorRef.current(state as S) } - return currentResult + return serverResult as R }, setSelector, } }, [store, registry, engine]) + // Render-phase selector swap: if this render brought a different // selector function (inline selector closing over changed props), // re-evaluate now so getSnapshot returns the new closure's value in @@ -584,12 +610,10 @@ const useSignalSelectorImpl = ( } }, [bridge]) - // getSnapshot doubles as getServerSnapshot: the computed already ran - // during useMemo, so SSR renders the current selected value. const selectedState = useSyncExternalStore( bridge.subscribe, bridge.getSnapshot, - bridge.getSnapshot, + bridge.getServerSnapshot, ) useDebugValue(selectedState) diff --git a/test/signals/coarseSegments.spec.ts b/test/signals/coarseSegments.spec.ts index d861f7179..94efda358 100644 --- a/test/signals/coarseSegments.spec.ts +++ b/test/signals/coarseSegments.spec.ts @@ -30,9 +30,7 @@ describe('SegmentIndex', () => { index.register(subB) index.register(subAB) - expect(index.collect(['a'])).toEqual( - expect.arrayContaining([subA, subAB]), - ) + expect(index.collect(['a'])).toEqual(expect.arrayContaining([subA, subAB])) expect(index.collect(['a'])).toHaveLength(2) expect(index.collect(['c'])).toHaveLength(0) }) @@ -221,7 +219,7 @@ describe('createProbeProxy', () => { const { proxy } = createProbeProxy(state) expect(() => { - ;(proxy as any).a = 42 + (proxy as any).a = 42 }).toThrow(TypeError) expect(() => { delete (proxy as any).a @@ -254,9 +252,7 @@ describe('reconcileState changed-root-keys return value', () => { const prev: Record = { a: 1 } const next = { a: 1, added: 2 } - expect(reconcileState(prev, next, registry, alienEngine)).toEqual([ - 'added', - ]) + expect(reconcileState(prev, next, registry, alienEngine)).toEqual(['added']) }) it('includes keys added with an undefined value', () => { @@ -264,9 +260,7 @@ describe('reconcileState changed-root-keys return value', () => { const prev: Record = { a: 1 } const next = { a: 1, added: undefined } - expect(reconcileState(prev, next, registry, alienEngine)).toEqual([ - 'added', - ]) + expect(reconcileState(prev, next, registry, alienEngine)).toEqual(['added']) }) it('includes removed keys', () => { diff --git a/test/signals/interop.spec.tsx b/test/signals/interop.spec.tsx new file mode 100644 index 000000000..37c50ce24 --- /dev/null +++ b/test/signals/interop.spec.tsx @@ -0,0 +1,501 @@ +/** + * Interop between the signal hooks and the stock React-Redux API: + * `connect()`, `useSelector`, custom contexts, and nested providers. + * + * These import `Provider`/`useSelector`/`connect` from their source + * modules rather than `src/index`, so they are unaffected by the local + * benchmark override in `src/exports.ts`. + */ +import { configureStore, createSlice } from '@reduxjs/toolkit' +import type { PayloadAction } from '@reduxjs/toolkit' +import * as rtl from '@testing-library/react' +import React, { createContext } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { connect } from '../../src/components/connect' +import type { ReactReduxContextValue } from '../../src/components/Context' +import Provider from '../../src/components/Provider' +import { createSelectorHook, useSelector } from '../../src/hooks/useSelector' +import { SignalProvider } from '../../src/signals/SignalProvider' +import { + createSignalSelectorHook, + useSignalSelector, +} from '../../src/signals/useSignalSelector' + +interface CounterState { + a: number + b: number +} + +const counterSlice = createSlice({ + name: 'counter', + initialState: { a: 0, b: 0 } as CounterState, + reducers: { + incA(state) { + state.a += 1 + }, + incB(state) { + state.b += 1 + }, + setA(state, action: PayloadAction) { + state.a = action.payload + }, + }, +}) + +const { incA, incB } = counterSlice.actions + +function makeStore() { + return configureStore({ + reducer: counterSlice.reducer, + // The tracking proxies hand frozen state to selectors already, and + // the immutability check walks the whole tree on every dispatch. + middleware: (getDefault) => + getDefault({ immutableCheck: false, serializableCheck: false }), + }) +} + +type Store = ReturnType + +describe('connect() under SignalProvider', () => { + it('renders and receives mapped state', () => { + const store = makeStore() + const mapState = (state: CounterState) => ({ a: state.a }) + const Display = connect(mapState)(({ a }: { a: number }) => ( +
{a}
+ )) + + rtl.render( + + + , + ) + + expect(rtl.screen.getByTestId('a').textContent).toBe('0') + }) + + it('updates when its mapped slice changes', () => { + const store = makeStore() + const renders = vi.fn() + const mapState = (state: CounterState) => ({ a: state.a }) + const Display = connect(mapState)(({ a }: { a: number }) => { + renders() + return
{a}
+ }) + + rtl.render( + + + , + ) + + rtl.act(() => { + store.dispatch(incA()) + }) + + expect(rtl.screen.getByTestId('a').textContent).toBe('1') + }) + + it('does not re-render when an unmapped slice changes', () => { + const store = makeStore() + const renders = vi.fn() + const mapState = (state: CounterState) => ({ a: state.a }) + const Display = connect(mapState)(({ a }: { a: number }) => { + renders() + return
{a}
+ }) + + rtl.render( + + + , + ) + const initial = renders.mock.calls.length + + rtl.act(() => { + store.dispatch(incB()) + }) + + expect(renders.mock.calls.length).toBe(initial) + }) + + it('sees raw state, not a tracking proxy, in mapStateToProps', () => { + const store = makeStore() + let seen: CounterState | undefined + const mapState = (state: CounterState) => { + seen = state + return { a: state.a } + } + const Display = connect(mapState)(({ a }: { a: number }) =>
{a}
) + + rtl.render( + + + , + ) + + expect(seen).toBe(store.getState()) + }) + + it('dispatches from a connected component and updates a sibling signal hook', () => { + const store = makeStore() + const Button = connect()(({ dispatch }: { dispatch: Store['dispatch'] }) => ( + + )) + function SignalDisplay() { + const a = useSignalSelector((state: CounterState) => state.a) + return
{a}
+ } + + rtl.render( + + + + ) + } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('name').textContent).toBe('one') + + rtl.fireEvent.click(getByTestId('switch')) + expect(getByTestId('name').textContent).toBe('two') + }) +}) From 966262fef9ec1da076c24560e8d36935fe479135 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 9 Aug 2026 23:05:57 -0400 Subject: [PATCH 48/95] Allow running standard tests with signal selectors overridden --- package.json | 2 ++ test/entries/signals.ts | 21 +++++++++++++++++++++ test/entries/stock.ts | 13 +++++++++++++ test/hooks/useSelector.spec.tsx | 15 +++++++++++++-- tsconfig.base.json | 7 ++++++- vitest.config.mts | 13 ++++++++++++- 6 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 test/entries/signals.ts create mode 100644 test/entries/stock.ts diff --git a/package.json b/package.json index b44a608a1..9d7cfc6ce 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,8 @@ "lint:fix": "oxlint src test --fix", "prepack": "pnpm build", "test": "vitest --run --typecheck", + "test:signals": "cross-env TEST_IMPL=signals vitest --run", + "test:both": "yarn test && yarn test:signals", "test:watch": "vitest --watch", "type-tests": "tsc --noEmit -p tsconfig.test.json" }, diff --git a/test/entries/signals.ts b/test/entries/signals.ts new file mode 100644 index 000000000..9f4825a2e --- /dev/null +++ b/test/entries/signals.ts @@ -0,0 +1,21 @@ +/** + * Test entry point that presents the signal implementation under the + * stock public names. + * + * `vitest.config.mts` aliases the bare specifier `react-redux` to one of + * these entries. Test files import `{ Provider, useSelector }` from + * `'react-redux'` and never learn which implementation they got, so the + * SAME spec file validates both — no copy-paste, and no need to edit + * `src/exports.ts` to point the library at the signal versions. + * + * Explicit local exports shadow `export *`, so this file is correct + * regardless of what `src/exports.ts` currently exports under these + * names. + */ +export * from '../../src/index' + +export { + SignalProvider as Provider, + useSignalSelector as useSelector, + createSignalSelectorHook as createSelectorHook, +} from '../../src/signals' diff --git a/test/entries/stock.ts b/test/entries/stock.ts new file mode 100644 index 000000000..2a9f589a1 --- /dev/null +++ b/test/entries/stock.ts @@ -0,0 +1,13 @@ +/** + * Test entry point that presents the stock implementation under its own + * names. See `./signals.ts` for how the two are selected. + * + * This exists so the stock run is pinned to the real stock hooks even + * while `src/exports.ts` carries the benchmark override that points + * `Provider`/`useSelector` at the signal versions. Explicit local + * exports shadow `export *`. + */ +export * from '../../src/index' + +export { default as Provider } from '../../src/components/Provider' +export { useSelector, createSelectorHook } from '../../src/hooks/useSelector' diff --git a/test/hooks/useSelector.spec.tsx b/test/hooks/useSelector.spec.tsx index b55da0370..ea8a97c46 100644 --- a/test/hooks/useSelector.spec.tsx +++ b/test/hooks/useSelector.spec.tsx @@ -48,6 +48,17 @@ function ProviderMock = AnyAction, S = unknown>({ const IS_REACT_18 = React.version.startsWith('18') +/** + * Which implementation `react-redux` was aliased to (see + * `vitest.config.mts` and `test/entries/`). Only a couple of tests here + * need to know: the ones that assert on stock's `Subscription` chain, + * which the signal implementation does not use at all — it propagates + * through the Provider's single store subscription and the signal graph + * instead, so no per-component listener ever joins that chain. Those + * tests assert a mechanism, not observable behavior. + */ +const IS_SIGNALS = process.env.TEST_IMPL === 'signals' + describe('React', () => { describe('hooks', () => { describe('useSelector', () => { @@ -151,7 +162,7 @@ describe('React', () => { expect(renderedItems).toEqual([1, 2]) }) - it('subscribes to the store synchronously', () => { + it.skipIf(IS_SIGNALS)('subscribes to the store synchronously', () => { let appSubscription: Subscription | null = null const Child = () => { @@ -182,7 +193,7 @@ describe('React', () => { expect(appSubscription!.getListeners().get().length).toBe(2) }) - it('unsubscribes when the component is unmounted', () => { + it.skipIf(IS_SIGNALS)('unsubscribes when the component is unmounted', () => { let appSubscription: Subscription | null = null const Parent = () => { diff --git a/tsconfig.base.json b/tsconfig.base.json index f68a4c470..293bb1b6f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -16,7 +16,12 @@ "noUnusedParameters": false, "paths": { "@internal/*": ["./src/*"], - "react-redux": ["./src/index.ts"], // @remap-prod-remove-line + // Type-level counterpart to the `react-redux` alias in + // `vitest.config.mts`. The type tests resolve through tsconfig + // paths rather than the Vite alias, so they must point at the same + // stock entry — otherwise a benchmark override in `src/exports.ts` + // silently changes what the type tests are checking. + "react-redux": ["./test/entries/stock.ts"], // @remap-prod-remove-line // `moduleResolution: "Node"` predates `exports` maps, so it cannot // find the types for alien-signals' `./system` subpath export. // Vite and tsup both resolve it correctly at runtime; this only diff --git a/vitest.config.mts b/vitest.config.mts index 4487cfe62..09372baa5 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -6,8 +6,19 @@ export default defineConfig({ setupFiles: ['test/setup.ts'], globals: true, alias: { + // Which implementation the shared spec files run against. + // TEST_DIST=1 -> the built package + // TEST_IMPL=signals -> useSignalSelector / SignalProvider + // default -> stock hooks + // The signal and stock entries live in test/entries and present + // their implementation under the stock public names, so one spec + // file validates both without being copied. 'react-redux': new URL( - process.env.TEST_DIST ? 'node_modules/react-redux' : 'src/index.ts', + process.env.TEST_DIST + ? 'node_modules/react-redux' + : process.env.TEST_IMPL === 'signals' + ? 'test/entries/signals.ts' + : 'test/entries/stock.ts', import.meta.url, ).pathname, // this mapping is disabled as we want `dist` imports in the tests only to be used for "type-only" imports which don't play a role for jest From c22b0875301973528f788fcd6fce98b847cb60ef Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 14 Aug 2026 23:58:00 -0400 Subject: [PATCH 49/95] Add failing tests for Suspense/transition/StrictMode uncommitted-hook leaks Uncommitted components never get a useSyncExternalStore subscribe call, so nothing disposes the render-phase eager deep build for ungateable hooks. Tests assert the desired no-leak behavior and currently fail: - suspend-forever (with and without unmount) - suspend-then-commit (first render attempt's bridge is orphaned) - abandoned/suspended transitions - StrictMode mount (useMemo factory double-invoke orphans one bridge) Gateable coarse-tier hooks, prune-based recovery, and StrictMode suspend documentation cases included. 7 fail / 3 pass as of this commit. --- test/signals/suspenseUncommitted.spec.tsx | 505 ++++++++++++++++++++++ 1 file changed, 505 insertions(+) create mode 100644 test/signals/suspenseUncommitted.spec.tsx diff --git a/test/signals/suspenseUncommitted.spec.tsx b/test/signals/suspenseUncommitted.spec.tsx new file mode 100644 index 000000000..713b6c7f6 --- /dev/null +++ b/test/signals/suspenseUncommitted.spec.tsx @@ -0,0 +1,505 @@ +/** + * Suspense, transitions, and uncommitted components. + * + * React only calls a `useSyncExternalStore` subscribe function for + * components that COMMIT. A component that renders but never commits — + * it suspended, or its render was thrown away when a transition was + * abandoned — never subscribes, so it never unsubscribes either. + * Whatever the hook created during render must therefore be safe to + * abandon: nothing render-phase may anchor itself in the provider-level + * `PathSignalRegistry`, because no cleanup path will ever run for it. + * + * Today that invariant does not hold for UNGATEABLE hooks (selectors the + * coarse probe can't gate: root enumeration, empty footprint, non-plain + * root state). The mount-seeding path calls `selectorComputed.get()` + * during render, which creates path signals in `registry.signals` and + * links the computed to them. If the component never commits, those + * entries stay in the registry until the state paths are pruned. + * + * These tests assert the DESIRED behavior: an uncommitted component + * leaves the registry exactly as it found it. The ungateable cases fail + * against the current implementation — that failure is the leak. + * + * Two of the failures go beyond the obvious suspend-forever case: + * - StrictMode double-invokes useMemo factories in dev, so a plain + * StrictMode mount creates TWO bridges, each of which eagerly builds; + * the discarded one is orphaned. Every ungateable mount leaks in dev. + * - A component that suspends and LATER commits still leaks: React + * retries with a fresh build after showing the fallback, and the + * first render attempt's bridge is orphaned. + * + * Modeled on mobx-react-lite's strictAndConcurrentMode tests + * (mobx-react-lite PRs #121 and #332), but where MobX must force GC and + * observe FinalizationRegistry callbacks, our leak is anchored in the + * registry Map, so `registry.debugStats()` observes it deterministically. + */ +import { configureStore, createSlice } from '@reduxjs/toolkit' +import * as rtl from '@testing-library/react' +import React, { StrictMode, Suspense, startTransition, useState } from 'react' +import { afterEach, describe, expect, it } from 'vitest' +import type { RegistryStats } from '../../src/signals/pathSignalRegistry' +import type { PathSignalRegistry } from '../../src/signals' +import { + SignalProvider, + useSignalContext, + useSignalSelector, +} from '../../src/signals' + +const makeStore = () => { + const aSlice = createSlice({ + name: 'a', + initialState: { value: 0, nested: { n: 0 } }, + reducers: { + bumpA(state) { + state.value++ + state.nested.n += 10 + }, + }, + }) + const dynSlice = createSlice({ + name: 'dyn', + initialState: {} as Record, + reducers: { + addDyn(state, action: { payload: string }) { + state[action.payload] = { v: 1 } + }, + removeDyn(state, action: { payload: string }) { + delete state[action.payload] + }, + }, + }) + + const store = configureStore({ + reducer: { a: aSlice.reducer, dyn: dynSlice.reducer }, + }) + + return { + store, + bumpA: aSlice.actions.bumpA, + addDyn: dynSlice.actions.addDyn, + removeDyn: dynSlice.actions.removeDyn, + } +} + +type AppState = ReturnType['store']['getState']> + +const noStabilityCheck = { + devModeChecks: { stabilityCheck: 'never' as const }, +} + +// Enumerating the root makes the probe mark the hook ungateable, so the +// mount path builds the deep graph eagerly, during render. The nested +// read guarantees real path signals get created by that build. +const ungateableSelector = (s: AppState) => { + Object.keys(s) + return s.a.nested.n +} + +// Reads one root key without enumerating: stays in the coarse tier, +// creates no signals at mount. +const gateableSelector = (s: AppState) => s.a.nested.n + +const EMPTY_STATS: RegistryStats = { + signals: 0, + prefixCounts: 0, + prefixOnlyPaths: 0, + childIndex: 0, + arrayMetas: 0, + columnsByArray: 0, + structuresByArray: 0, + segmentSubs: 0, +} + +function makeRegistryCapture() { + const captured: { registry: PathSignalRegistry | null } = { registry: null } + function Capture() { + captured.registry = useSignalContext().registry + return null + } + const registry = () => { + if (captured.registry === null) throw new Error('registry not captured') + return captured.registry + } + return { registry, Capture } +} + +/** A manually-controlled Suspense gate. */ +function makeGate() { + let resolve!: () => void + let resolved = false + const promise = new Promise((r) => { + resolve = r + }).then(() => { + resolved = true + }) + return { + isResolved: () => resolved, + suspendIfPending() { + if (!resolved) throw promise + }, + async open() { + resolve() + await promise + }, + } +} + +afterEach(() => { + rtl.cleanup() +}) + +describe('baseline: committed components already clean up', () => { + it('ungateable hook builds path signals at mount and releases them all on unmount', () => { + const { store } = makeStore() + const { registry, Capture } = makeRegistryCapture() + + function Watcher() { + const n = useSignalSelector(ungateableSelector, noStabilityCheck) + return
{n}
+ } + + const { unmount, getByTestId } = rtl.render( + + + + , + ) + + expect(getByTestId('n').textContent).toBe('0') + // Eager render-phase build: the deep graph exists before any dispatch. + expect(registry().debugStats().signals).toBeGreaterThan(0) + + unmount() + expect(registry().debugStats()).toEqual(EMPTY_STATS) + }) +}) + +describe('component suspends before commit, promise never resolves', () => { + it('gateable hook: registers nothing while suspended and nothing after unmount', () => { + const { store } = makeStore() + const { registry, Capture } = makeRegistryCapture() + const gate = makeGate() + + function SuspendingWatcher() { + const n = useSignalSelector(gateableSelector, noStabilityCheck) + gate.suspendIfPending() + return
{n}
+ } + + const { unmount, getByTestId } = rtl.render( + + + loading}> + + + , + ) + + expect(getByTestId('fallback')).toBeTruthy() + // The coarse sub is only registered inside subscribe, which never + // ran; the probe itself creates no signals. Nothing to leak. + expect(registry().debugStats()).toEqual(EMPTY_STATS) + + unmount() + expect(registry().debugStats()).toEqual(EMPTY_STATS) + }) + + it('ungateable hook: leaves no path signals behind while suspended', () => { + const { store } = makeStore() + const { registry, Capture } = makeRegistryCapture() + const gate = makeGate() + + function SuspendingWatcher() { + const n = useSignalSelector(ungateableSelector, noStabilityCheck) + gate.suspendIfPending() + return
{n}
+ } + + const { getByTestId } = rtl.render( + + + loading}> + + + , + ) + + expect(getByTestId('fallback')).toBeTruthy() + // The component rendered but never committed: subscribe never ran, + // no effect exists, and no cleanup path will ever fire for this + // hook. Anything it left in the registry is unreachable garbage. + expect(registry().debugStats().signals).toBe(0) + }) + + it('ungateable hook: registry is empty after the suspended tree unmounts', () => { + const { store } = makeStore() + const { registry, Capture } = makeRegistryCapture() + const gate = makeGate() + + function SuspendingWatcher() { + const n = useSignalSelector(ungateableSelector, noStabilityCheck) + gate.suspendIfPending() + return
{n}
+ } + + const { unmount } = rtl.render( + + + loading}> + + + , + ) + + // Unmounting the whole tree cannot help: React never subscribed + // this hook, so it has no unsubscribe to call during unmount. + unmount() + expect(registry().debugStats()).toEqual(EMPTY_STATS) + }) +}) + +describe('component suspends, then commits when the promise resolves', () => { + it('recovers fully: subscribes on commit, updates on dispatch, releases on unmount', async () => { + const { store, bumpA } = makeStore() + const { registry, Capture } = makeRegistryCapture() + const gate = makeGate() + + function SuspendingWatcher() { + const n = useSignalSelector(ungateableSelector, noStabilityCheck) + gate.suspendIfPending() + return
{n}
+ } + + const { unmount, getByTestId, queryByTestId } = rtl.render( + + + loading}> + + + , + ) + + expect(getByTestId('fallback')).toBeTruthy() + + await rtl.act(async () => { + await gate.open() + }) + + // Committed: fallback gone, content up. + expect(queryByTestId('fallback')).toBeNull() + expect(getByTestId('n').textContent).toBe('0') + + // The subscription is live: dispatches flow through the deep graph. + rtl.act(() => { + store.dispatch(bumpA()) + }) + expect(getByTestId('n').textContent).toBe('10') + + // Commit gave the hook a real unsubscribe path; unmount uses it. + unmount() + expect(registry().debugStats()).toEqual(EMPTY_STATS) + }) +}) + +describe("today's only recovery path: pruning when state paths disappear", () => { + it('a never-committed hook\'s leaked leaf signal is removed when its state key is deleted', async () => { + // This test documents CURRENT behavior, not desired behavior: the + // leaked path signal sits in the registry until the state key it + // tracks is deleted, and prune() removes it as part of normal diff + // processing. It passes today and should keep passing after the + // fix (at which point there is simply nothing to prune). + const { store, addDyn, removeDyn } = makeStore() + const { registry, Capture } = makeRegistryCapture() + const gate = makeGate() + + rtl.act(() => { + store.dispatch(addDyn('x')) + }) + + const readsDynX = (s: AppState) => { + Object.keys(s) + return s.dyn.x?.v ?? -1 + } + + function SuspendingWatcher() { + const v = useSignalSelector(readsDynX, noStabilityCheck) + gate.suspendIfPending() + return
{v}
+ } + + rtl.render( + + + loading}> + + + , + ) + + // Today the eager render-phase build creates the leaf signal even + // though the component never commits. Deleting the key must prune + // it — the registry must not keep entries for paths that no longer + // exist in state, regardless of who reads them. (Post-fix, nothing + // leaks in the first place and this holds trivially.) + rtl.act(() => { + store.dispatch(removeDyn('x')) + }) + + expect(registry().debugPaths()).not.toContain('dyn.x.v') + }) +}) + +describe('transitions', () => { + it('a suspended transition that never finishes leaves the registry empty after unmount', async () => { + const { store } = makeStore() + const { registry, Capture } = makeRegistryCapture() + const gate = makeGate() + let setShowOuter: ((show: boolean) => void) | null = null + + function SuspendingWatcher() { + const n = useSignalSelector(ungateableSelector, noStabilityCheck) + gate.suspendIfPending() + return
{n}
+ } + + function App() { + const [show, setShow] = useState(false) + setShowOuter = setShow + return ( + + + loading}> + {show ? :
idle
} +
+
+ ) + } + + const { unmount, getByTestId } = rtl.render() + expect(getByTestId('idle')).toBeTruthy() + + // Mount the suspending component inside a transition. The transition + // render runs the hook (and, today, the eager deep build), then + // suspends; React holds the old UI, so nothing ever commits. + rtl.act(() => { + startTransition(() => { + setShowOuter!(true) + }) + }) + expect(getByTestId('idle')).toBeTruthy() + + unmount() + expect(registry().debugStats()).toEqual(EMPTY_STATS) + }) + + it('an abandoned transition render leaves the registry empty while the app keeps running', () => { + const { store } = makeStore() + const { registry, Capture } = makeRegistryCapture() + const gate = makeGate() + let setShowOuter: ((show: boolean) => void) | null = null + + function SuspendingWatcher() { + const n = useSignalSelector(ungateableSelector, noStabilityCheck) + gate.suspendIfPending() + return
{n}
+ } + + function App() { + const [show, setShow] = useState(false) + setShowOuter = setShow + return ( + + + loading}> + {show ? :
idle
} +
+
+ ) + } + + const { getByTestId } = rtl.render() + + // Start showing the suspending component in a transition, then + // change course before it resolves. React throws the transition + // render away; the component never commits, and the tree it was + // going to join is still mounted — so no unmount will ever come to + // the rescue. The registry must not have grown. + rtl.act(() => { + startTransition(() => { + setShowOuter!(true) + }) + }) + rtl.act(() => { + setShowOuter!(false) + }) + + expect(getByTestId('idle')).toBeTruthy() + expect(registry().debugStats()).toEqual(EMPTY_STATS) + }) +}) + +describe('StrictMode', () => { + it('double-invoked render of an ungateable hook does not double-count, and unmount is clean', () => { + const { store, bumpA } = makeStore() + const { registry, Capture } = makeRegistryCapture() + + function Watcher() { + const n = useSignalSelector(ungateableSelector, noStabilityCheck) + return
{n}
+ } + + const { unmount, getByTestId } = rtl.render( + + + + + + , + ) + + expect(getByTestId('n').textContent).toBe('0') + const mounted = registry().debugStats() + + // StrictMode double-invokes the useMemo factory in dev, so TWO + // bridges were created at mount and each ran the eager deep build; + // only one of them ever gets a subscribe/unsubscribe pair. The + // committed one must keep working; the discarded one must not + // leave anything in the registry (today it does — that is the + // failure this test pins). + rtl.act(() => { + store.dispatch(bumpA()) + }) + expect(getByTestId('n').textContent).toBe('10') + expect(registry().debugStats().segmentSubs).toBe(mounted.segmentSubs) + + unmount() + expect(registry().debugStats()).toEqual(EMPTY_STATS) + }) + + it('StrictMode + suspended-forever ungateable hook leaves the registry empty after unmount', () => { + const { store } = makeStore() + const { registry, Capture } = makeRegistryCapture() + const gate = makeGate() + + function SuspendingWatcher() { + const n = useSignalSelector(ungateableSelector, noStabilityCheck) + gate.suspendIfPending() + return
{n}
+ } + + const { unmount, getByTestId } = rtl.render( + + + + loading}> + + + + , + ) + + expect(getByTestId('fallback')).toBeTruthy() + unmount() + expect(registry().debugStats()).toEqual(EMPTY_STATS) + }) +}) From 722af349e1d18fa9925ea707425e93b6825c66c5 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 15 Aug 2026 00:39:52 -0400 Subject: [PATCH 50/95] Restructure deep tier to avoid subscriptions while rendering --- src/signals/useSignalSelector.ts | 68 ++++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index 0425c2894..10184629f 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -105,6 +105,10 @@ const useSignalSelectorImpl = ( // Create the signal bridge once (stable across renders) const bridge = useMemo(() => { let currentResult: R + // Whether currentResult has ever been seeded. User equality + // functions must never be called with the unseeded value — stock + // useSelector never compares against a nonexistent previous result. + let hasResult = false let version = 0 let notifyReact: (() => void) | null = null let suppressNotify = false @@ -225,13 +229,13 @@ const useSignalSelectorImpl = ( // eager build for ungateable footprints). } else { // Root became non-plain while never subscribed (a dispatch in - // the render→subscribe gap) — can't probe it. Promote now; the - // effect attaches in subscribe(). - built = true - recomputeInPlace() - if (pendingError !== null) { - throw pendingError - } + // the render→subscribe gap) — can't probe it. Don't build during + // render either (an uncommitted component would orphan the + // build). Invalidate the snapshot cache so getSnapshot re-runs + // the NEW closure in this render; subscribe() builds the deep + // tier on commit. + ungateable = true + lastSnapshotState = null } } @@ -429,7 +433,16 @@ const useSignalSelectorImpl = ( if (process.env.NODE_ENV !== 'production') { runDevModeChecks(value, proxy as S, state as S) } - currentResult = untrackResult(value) + // Apply the equality function before adopting (except on the very + // first probe, when there is no previous result). A re-probe after + // a render-phase selector swap must keep the PREVIOUS reference + // for equal-but-new results, or the identity handed to React + // changes on every swap and defeats the equality check. + const fresh = untrackResult(value) + if (!hasResult || !equalityFnRef.current(currentResult, fresh)) { + currentResult = fresh + hasResult = true + } probeSegments = record.segments ungateable = record.segments.size === 0 || record.enumerated probedState = state @@ -467,23 +480,26 @@ const useSignalSelectorImpl = ( // --- Mount seeding --- // Probe the selector's coarse footprint and seed the initial value. - // Footprints that can't be gated (and non-plain-object roots, which - // can't be probed) fall back to building the deep tier eagerly — the - // pre-coarse-tier behavior. A selector that throws on mount surfaces - // here, during render — same as stock useSelector. + // A selector that throws on mount surfaces here, during render — + // same as stock useSelector. + // + // The deep tier is NEVER built during render, even for ungateable + // footprints. React only calls subscribe for components that commit, + // so a render-phase build has no owner until then: a component that + // suspends, a transition render that gets abandoned, or the extra + // bridge from StrictMode's useMemo double-invoke would orphan the + // computed and permanently anchor path signals in the registry. + // Until subscribe builds the deep tier, getSnapshot serves values by + // running the raw selector (see the unbuilt branch below). const initialState = store.getState() if (isPlainObjectState(initialState)) { probe(initialState as S & object) } else { + // Non-plain root: can't probe it. getSnapshot's unbuilt path runs + // the raw selector during render (throw-on-mount parity); + // subscribe() builds the deep tier on commit. ungateable = true } - if (ungateable) { - built = true - currentResult = selectorComputed.get() - if (pendingError !== null) { - throw pendingError - } - } const getSnapshot = (): R => { if (!built) { @@ -497,8 +513,9 @@ const useSignalSelectorImpl = ( if (state !== lastSnapshotState) { lastSnapshotState = state const fresh = selectorRef.current(state as S) - if (!equalityFnRef.current(currentResult, fresh)) { + if (!hasResult || !equalityFnRef.current(currentResult, fresh)) { currentResult = fresh + hasResult = true } } return currentResult @@ -554,10 +571,19 @@ const useSignalSelectorImpl = ( // Can't gate this footprint — build eagerly. On error the // computed set pendingError; the effect's first run // notifies and getSnapshot rethrows into render. + // Equality applies before adopting: the probe already + // seeded currentResult, and a selector that returns a + // new-but-equal reference must not change the identity + // React saw during render (that would force an immediate + // extra re-render at subscribe time). built = true const value = selectorComputed.get() - if (pendingError === null) { + if ( + pendingError === null && + (!hasResult || !equalityFnRef.current(currentResult, value)) + ) { currentResult = value + hasResult = true } } else { coarseSub = { segments: probeSegments, onCoarseHit } From b946fb6a94844e99e93a6d56b4a2a7f11f635677 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 16 Aug 2026 00:47:17 -0400 Subject: [PATCH 51/95] Remove engine from the public signals API alien-signals is the engine; the facade's only real consumer of injectability is the reconcile benchmark stub. Keep SignalEngine as an internal DI seam on createPathSignalRegistry/reconcileState, drop the SignalProvider engine prop, the context field and its validation check, and the alienEngine/SignalEngine/SignalScope public exports. The hook now imports signal/computed/effect from reactiveSystem directly. --- src/signals/SignalProvider.tsx | 27 +++++++-------------------- src/signals/context.ts | 8 +++----- src/signals/engine.ts | 10 ++++++++-- src/signals/index.ts | 9 +-------- src/signals/useSignalSelector.ts | 23 ++++++++--------------- 5 files changed, 27 insertions(+), 50 deletions(-) diff --git a/src/signals/SignalProvider.tsx b/src/signals/SignalProvider.tsx index c4764b314..133ed434c 100644 --- a/src/signals/SignalProvider.tsx +++ b/src/signals/SignalProvider.tsx @@ -10,30 +10,19 @@ import type { SignalContextValue } from './context' import { createPathSignalRegistry } from './pathSignalRegistry' import { reconcileState } from './diff' import { alienEngine } from './engine' -import type { SignalEngine } from './types' export interface SignalProviderProps< A extends Action = UnknownAction, S = unknown, -> extends ProviderProps { - engine?: SignalEngine -} +> extends ProviderProps {} export function SignalProvider< S extends object, A extends Action = UnknownAction, >(providerProps: SignalProviderProps) { - const { - children, - context, - serverState, - store, - engine = alienEngine, - } = providerProps + const { children, context, serverState, store } = providerProps - // Create signal registry once (lazy init via ref) - const registryRef = React.useRef(createPathSignalRegistry(engine)) - const registry = registryRef.current + const [registry] = React.useState(() => createPathSignalRegistry(alienEngine)) // Track previous state for signal diffing const prevStateRef = React.useRef(store.getState()) @@ -47,7 +36,6 @@ export function SignalProvider< subscription, getServerState: serverState ? () => serverState : undefined, registry, - engine, } if (process.env.NODE_ENV === 'production') { @@ -61,7 +49,7 @@ export function SignalProvider< identityFunctionCheck, }) } - }, [store, serverState, registry, engine]) + }, [store, serverState, registry]) const previousState = React.useMemo(() => store.getState(), [store]) @@ -75,7 +63,7 @@ export function SignalProvider< const prev = prevStateRef.current const next = store.getState() prevStateRef.current = next - const changedRootKeys = reconcileState(prev, next, registry, engine) + const changedRootKeys = reconcileState(prev, next, registry, alienEngine) // Coarse tier: wake deferred subscribers whose top-level segments // changed (null = root wasn't diffable, every subscriber is a @@ -103,9 +91,8 @@ export function SignalProvider< } }, [contextValue, previousState]) - const Context = (context || ReactReduxContext) as Context< - ReactReduxContextValue | null - > + const Context = (context || + ReactReduxContext) as Context | null> return ( }> diff --git a/src/signals/context.ts b/src/signals/context.ts index b3fecc183..3b2024f96 100644 --- a/src/signals/context.ts +++ b/src/signals/context.ts @@ -3,14 +3,12 @@ import { React } from '../utils/react' import type { ReactReduxContextValue } from '../components/Context' import { ReactReduxContext } from '../components/Context' import type { PathSignalRegistry } from './pathSignalRegistry' -import type { SignalEngine } from './types' export interface SignalContextValue< SS = any, A extends Action = UnknownAction, > extends ReactReduxContextValue { registry: PathSignalRegistry - engine: SignalEngine } export { ReactReduxContext } @@ -18,7 +16,7 @@ export { ReactReduxContext } /** * Hook factory, which creates a `useSignalContext` hook bound to a given * context. Mirrors stock `createReduxContextHook`, but validates that the - * context value came from a `` (registry + engine present). + * context value came from a `` (registry present). * * @param context - Context passed to your ``. * @returns A `useSignalContext` hook bound to the given context. @@ -40,8 +38,8 @@ export function createSignalContextHook( ) } - // Verify this is actually a signal context (has registry + engine) - if (!('registry' in contextValue) || !('engine' in contextValue)) { + // Verify this is actually a signal context (has registry) + if (!('registry' in contextValue)) { throw new Error( 'useSignalSelector must be used within a , not a regular ', ) diff --git a/src/signals/engine.ts b/src/signals/engine.ts index e305c16b8..46d8bba7c 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -15,8 +15,14 @@ import type { } from './types' /** - * The default engine, backed by the vendored reactive layer in - * `./reactiveSystem`. + * The engine backing `pathSignalRegistry` and `reconcileState`, built on + * the vendored reactive layer in `./reactiveSystem`. + * + * This is internal-only and is not part of the public API. It exists as + * an injection seam: the reconcile benchmark substitutes a + * no-propagation stub to measure pure diff cost, and unit tests use + * `createScope` to contain effects. Application code always gets this + * implementation. * * `signal` and `computed` return the graph nodes directly. The nodes * implement `get`/`set` themselves, so unlike the previous wrapper over diff --git a/src/signals/index.ts b/src/signals/index.ts index b4629a0a4..847ce26ed 100644 --- a/src/signals/index.ts +++ b/src/signals/index.ts @@ -10,14 +10,7 @@ export type { UseSignalSelector } from './useSignalSelector' export { createSignalContextHook, useSignalContext } from './context' export type { SignalContextValue } from './context' -export { alienEngine } from './engine' -export type { - PathKey, - ReactiveSignal, - ReactiveComputed, - SignalEngine, - SignalScope, -} from './types' +export type { PathKey, ReactiveSignal, ReactiveComputed } from './types' export { createPathSignalRegistry } from './pathSignalRegistry' export type { PathSignalRegistry } from './pathSignalRegistry' diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index 10184629f..e728e7986 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -1,9 +1,6 @@ import type { Context } from 'react' import { React } from '../utils/react' -import type { - DevModeChecks, - UseSelectorOptions, -} from '../hooks/useSelector' +import type { DevModeChecks, UseSelectorOptions } from '../hooks/useSelector' import type { ReactReduxContextValue } from '../components/Context' import type { EqualityFn } from '../types' import { createProbeProxy } from './coarseSegments' @@ -19,6 +16,7 @@ import { getProxyPath, type LeafObjectTracker, } from './trackingProxy' +import { computed, effect, signal } from './reactiveSystem' import { untrackResult } from './untrack' const { useRef, useMemo, useEffect, useSyncExternalStore, useDebugValue } = @@ -41,7 +39,6 @@ function isPlainObjectState(v: unknown): v is object { return proto === Object.prototype || proto === null } - /** * A React hook that selects state from a Redux store using signal-based * dependency tracking. Only re-renders when the selected value actually @@ -83,7 +80,7 @@ const useSignalSelectorImpl = ( } const reduxContext = useBoundSignalContext() - const { store, registry, engine } = reduxContext + const { store, registry } = reduxContext // Track latest selector/equalityFn via refs. Updated during render // (idempotent ref writes, same approach as React's @@ -162,7 +159,7 @@ const useSignalSelectorImpl = ( // The computed reads it, so bumping forces a re-evaluation with the // new closure — otherwise the selector would keep returning the old // closure's result until an unrelated store change fired a signal. - const selectorVersionSignal = engine.signal(0) + const selectorVersionSignal = signal(0) // Force a fresh evaluation of the computed WITHOUT notifying React. // Bumping the version signal dirties the computed; a plain .get() @@ -307,7 +304,7 @@ const useSignalSelectorImpl = ( firstRun = false } - const selectorComputed = engine.computed(() => { + const selectorComputed = computed(() => { selectorVersionSignal.get() // pendingError reflects the LAST evaluation: a successful re-run // clears an earlier error. @@ -379,7 +376,7 @@ const useSignalSelectorImpl = ( // from the coarse tier (first coarse hit) also attaches it. const attachEffect = (): void => { let isFirst = true - disposeEffect = engine.effect(() => { + disposeEffect = effect(() => { const newValue = selectorComputed.get() if (pendingError !== null) { @@ -633,8 +630,7 @@ const useSignalSelectorImpl = ( setSelector, } - }, [store, registry, engine]) - + }, [store, registry]) // Render-phase selector swap: if this render brought a different // selector function (inline selector closing over changed props), @@ -695,10 +691,7 @@ export interface UseSignalSelector { * @returns A `useSignalSelector` hook bound to the given context. */ export function createSignalSelectorHook( - context: Context | null> = ReactReduxContext, + context: Context | null> = ReactReduxContext, ): UseSignalSelector { const useBoundSignalContext = context === ReactReduxContext From 9a2ec0d02a31cd2fdfb9db5a49598723beff8bf7 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 16 Aug 2026 00:52:06 -0400 Subject: [PATCH 52/95] Clean up dead code, stale comments, and duplicated path building - Drop the unread 'version' counter and the empty cleanup useEffect in useSignalSelector - Move the selector-computed comment block onto selectorComputed; it had been welded to the leafTracker declaration - Move unwrap()'s jsdoc off getProxyTarget and onto unwrap - Fix createTrackingProxy's jsdoc: the proxy cache persists across evaluations (that persistence is the point), it is not per-evaluation - Extract bumpVersion() in pathSignalRegistry (was triplicated) - Add joinPath()/keysMetaPath() in arrayKeys and use them in trackingProxy and diff, replacing seven inline constructions of the same path shapes --- src/signals/arrayKeys.ts | 34 +++++++++++- src/signals/arrayMethodOverrides.ts | 4 +- src/signals/diff.ts | 41 +++++++-------- src/signals/pathSignalRegistry.ts | 32 ++++++++---- src/signals/trackingProxy.ts | 81 +++++++++++++++++------------ src/signals/useSignalSelector.ts | 27 +++------- 6 files changed, 132 insertions(+), 87 deletions(-) diff --git a/src/signals/arrayKeys.ts b/src/signals/arrayKeys.ts index 6d01affd1..e11343ffa 100644 --- a/src/signals/arrayKeys.ts +++ b/src/signals/arrayKeys.ts @@ -18,7 +18,8 @@ const KEY_CANDIDATES = ['id', 'key', '_id', '__id'] as const * @returns The key field name, or undefined if none found */ export function findKeyField(obj: unknown): string | undefined { - if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return undefined + if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) + return undefined for (let i = 0; i < KEY_CANDIDATES.length; i++) { const candidate = KEY_CANDIDATES[i] if (candidate in (obj as Record)) { @@ -75,6 +76,31 @@ export function encodePathSegment(s: string): string { .replace(/@/g, '%40') } +/** + * Join an ALREADY-ENCODED segment (or an array index) onto a parent + * path. The root object's path is the empty string, so a root-level + * segment is the path by itself. Callers must run object keys through + * `encodePathSegment` first; array indexes need no encoding. + * @param parentPath - Path of the containing object ('' for the root) + * @param segment - Encoded segment or array index + * @returns The joined path key + */ +export function joinPath(parentPath: string, segment: string | number): string { + return parentPath ? parentPath + '.' + segment : String(segment) +} + +/** + * Path of the `@@keys` meta signal for an object/array. Built raw (NOT + * via encodePathSegment): '@@keys' is a meta segment, and encoding + * would turn it into an ordinary (escaped) property path. A state key + * literally named '@@keys' encodes to '%40%40keys' and cannot collide. + * @param parentPath - Path of the object whose key set is tracked + * @returns The meta path key + */ +export function keysMetaPath(parentPath: string): string { + return parentPath ? parentPath + '.@@keys' : '@@keys' +} + /** * Render a key value as a path segment fragment. * @@ -128,7 +154,11 @@ export function getKeyValue( element: unknown, keyField: string, ): string | number | undefined { - if (element === null || typeof element !== 'object' || Array.isArray(element)) { + if ( + element === null || + typeof element !== 'object' || + Array.isArray(element) + ) { return undefined } const value = (element as Record)[keyField] diff --git a/src/signals/arrayMethodOverrides.ts b/src/signals/arrayMethodOverrides.ts index b28a6ae5d..ed36ba75a 100644 --- a/src/signals/arrayMethodOverrides.ts +++ b/src/signals/arrayMethodOverrides.ts @@ -386,9 +386,7 @@ export function createArrayMethodInterceptor( // If the search argument is a tracking proxy, unwrap it to the raw target // so the comparison works against raw array elements. if (m === 'includes' || m === 'indexOf' || m === 'lastIndexOf') { - const unwrappedArgs = args.map((arg, i) => - i === 0 ? unwrap(arg) : arg, - ) + const unwrappedArgs = args.map((arg, i) => (i === 0 ? unwrap(arg) : arg)) return (target as any)[m](...unwrappedArgs) } diff --git a/src/signals/diff.ts b/src/signals/diff.ts index 4b3bf7b64..6f61fd7c8 100644 --- a/src/signals/diff.ts +++ b/src/signals/diff.ts @@ -3,6 +3,8 @@ import { encodePathSegment, findKeyField, getKeyValue, + joinPath, + keysMetaPath, } from './arrayKeys' import type { ArrayMeta } from './arrayKeys' import type { PathSignalRegistry } from './pathSignalRegistry' @@ -77,25 +79,21 @@ function diffObject( // Keys are encoded to match the proxy's pathKey construction — // reserved path characters in state keys (dots, braces, '@') are // %-escaped on both sides. - const segment = encodePathSegment(key) - const childPath = parentPath ? parentPath + '.' + segment : segment + const childPath = joinPath(parentPath, encodePathSegment(key)) if (registry.hasPrefix(childPath)) { diffAndUpdateSignals(prevVal, nextVal, childPath, registry) } } if (keysChanged) { - const keysPath = parentPath ? parentPath + '.@@keys' : '@@keys' - registry.update(keysPath, nextKeys) + registry.update(keysMetaPath(parentPath), nextKeys) for (let i = 0; i < prevKeys.length; i++) { if (!(prevKeys[i] in next)) { if (changedRootKeys !== undefined) { changedRootKeys.push(prevKeys[i]) } - const segment = encodePathSegment(prevKeys[i]) - const childPath = parentPath ? parentPath + '.' + segment : segment - registry.prune(childPath) + registry.prune(joinPath(parentPath, encodePathSegment(prevKeys[i]))) } } } @@ -121,10 +119,8 @@ function diffArray( } if (prev.length !== next.length) { - const keysPath = parentPath ? parentPath + '.@@keys' : '@@keys' - registry.update(keysPath, next.length) - const lengthPath = parentPath ? parentPath + '.length' : 'length' - registry.update(lengthPath, next.length) + registry.update(keysMetaPath(parentPath), next.length) + registry.update(joinPath(parentPath, 'length'), next.length) } let meta = registry.getArrayMeta(parentPath) @@ -326,7 +322,14 @@ function diffArrayByKey( diffAndUpdateSignals(prev[i], next[i], identityPath, registry) } if (fired && cols) { - bumpColumnsForElement(prev[i], next[i], parentPath, registry, cols, fired) + bumpColumnsForElement( + prev[i], + next[i], + parentPath, + registry, + cols, + fired, + ) } prevEntityMap.set(nextKv, next[i]) } @@ -428,7 +431,7 @@ function diffArrayByKey( if (kv === undefined) { sawUnkeyed = true - const childPath = parentPath ? parentPath + '.' + i : String(i) + const childPath = joinPath(parentPath, i) const prevItem = i < prev.length ? prev[i] : undefined if (prevItem !== nextItem) { if (registry.hasPrefix(childPath)) { @@ -459,10 +462,7 @@ function diffArrayByKey( // Subsequence check: survivors must appear in prev in the same // relative order. Advance pointer to this key; if we run off the // end, order changed. - while ( - pi < prev.length && - getKeyValue(prev[pi], keyField) !== kv - ) { + while (pi < prev.length && getKeyValue(prev[pi], keyField) !== kv) { pi++ } if (pi >= prev.length) { @@ -566,7 +566,7 @@ function diffArrayByIndex( const minLen = Math.min(prev.length, next.length) for (let i = 0; i < minLen; i++) { if (prev[i] !== next[i]) { - const childPath = parentPath ? parentPath + '.' + i : String(i) + const childPath = joinPath(parentPath, i) if (registry.hasPrefix(childPath)) { diffAndUpdateSignals(prev[i], next[i], childPath, registry) } @@ -585,14 +585,13 @@ function diffArrayByIndex( } } for (let i = minLen; i < next.length; i++) { - const childPath = parentPath ? parentPath + '.' + i : String(i) + const childPath = joinPath(parentPath, i) if (registry.hasPrefix(childPath)) { diffAndUpdateSignals(undefined, next[i], childPath, registry) } } for (let i = next.length; i < prev.length; i++) { - const childPath = parentPath ? parentPath + '.' + i : String(i) - registry.prune(childPath) + registry.prune(joinPath(parentPath, i)) } } diff --git a/src/signals/pathSignalRegistry.ts b/src/signals/pathSignalRegistry.ts index 4181820de..b67e2f920 100644 --- a/src/signals/pathSignalRegistry.ts +++ b/src/signals/pathSignalRegistry.ts @@ -115,7 +115,10 @@ export interface PathSignalRegistry { /** Register a structural signal for an array: a dependency on a specific * kind of membership/order change (append, insertOrReorder, remove). * Fired selectively by diff based on how the array actually changed. */ - trackStructure(arrayPath: string, kind: StructureKind): ReactiveSignal + trackStructure( + arrayPath: string, + kind: StructureKind, + ): ReactiveSignal /** Get the set of tracked structure kinds for an array path. */ getTrackedStructures(arrayPath: string): Set | undefined @@ -151,6 +154,17 @@ function buildStructurePath(arrayPath: string, kind: StructureKind): string { return arrayPath + '.@@' + kind } +/** + * Object/array/column/structure signals hold version counters, not + * values. Bump forces every subscribed computed to re-evaluate. The + * non-number fallback resets a signal whose last value was a PRUNED + * sentinel or a real leaf value before the path's shape changed. + */ +function bumpVersion(sig: ReactiveSignal): void { + const current = sig.get() + sig.set(typeof current === 'number' ? current + 1 : 0) +} + function isObjectOrArray(v: unknown): v is object { return v !== null && typeof v === 'object' } @@ -374,7 +388,10 @@ export function createPathSignalRegistry( } const registry: PathSignalRegistry = { - getOrCreate(pathKey: PathKey, currentValue: unknown): ReactiveSignal { + getOrCreate( + pathKey: PathKey, + currentValue: unknown, + ): ReactiveSignal { let sig = signals.get(pathKey) if (!sig) { if (pendingReleases.size !== 0) flushReleases() @@ -410,8 +427,7 @@ export function createPathSignalRegistry( if (!sig) return if (isObjectOrArray(newValue)) { - const current = sig.get() - sig.set(typeof current === 'number' ? current + 1 : 0) + bumpVersion(sig) } else { sig.set(newValue) } @@ -596,9 +612,7 @@ export function createPathSignalRegistry( bumpColumn(arrayPath: string, prop: string): void { const sig = signals.get(buildColumnPath(arrayPath, prop)) - if (!sig) return - const current = sig.get() - sig.set(typeof current === 'number' ? current + 1 : 0) + if (sig) bumpVersion(sig) }, trackStructure( @@ -620,9 +634,7 @@ export function createPathSignalRegistry( bumpStructure(arrayPath: string, kind: StructureKind): void { const sig = signals.get(buildStructurePath(arrayPath, kind)) - if (!sig) return - const current = sig.get() - sig.set(typeof current === 'number' ? current + 1 : 0) + if (sig) bumpVersion(sig) }, } diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index f1075917a..8ff8b91d3 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -3,6 +3,8 @@ import { encodePathSegment, findKeyField, getKeyValue, + joinPath, + keysMetaPath, } from './arrayKeys' import { isOverriddenArrayMethod, @@ -109,27 +111,6 @@ export function getProxyPath(value: unknown): string | undefined { return undefined } -/** - * Get the raw target object from a tracking proxy, or the value itself if not a proxy. - * - * Use this when you need identity comparison between values that may be - * tracking proxies. Since `proxy === rawObject` is always `false` in JS, - * unwrapping both sides allows correct identity checks: - * - * ```ts - * import { unwrap } from 'react-redux/signals' - * - * const selector = (state) => { - * const current = unwrap(state.current) - * return state.items.find(item => item === current) - * } - * ``` - * - * Safe to call on non-proxy values — returns them unchanged. - * - * @param value - The value to unwrap (proxy or raw) - * @returns The raw target object, or the original value if not a tracking proxy - */ /** * Get the raw target of a tracking proxy, or undefined if the value is * not a tracking proxy. Unlike unwrap(), this distinguishes "was a @@ -154,6 +135,27 @@ export function registerProxyTarget(proxy: object, target: object): void { proxyTargetMap.set(proxy, target) } +/** + * Get the raw target object from a tracking proxy, or the value itself if not a proxy. + * + * Use this when you need identity comparison between values that may be + * tracking proxies. Since `proxy === rawObject` is always `false` in JS, + * unwrapping both sides allows correct identity checks: + * + * ```ts + * import { unwrap } from 'react-redux/signals' + * + * const selector = (state) => { + * const current = unwrap(state.current) + * return state.items.find(item => item === current) + * } + * ``` + * + * Safe to call on non-proxy values — returns them unchanged. + * + * @param value - The value to unwrap (proxy or raw) + * @returns The raw target object, or the original value if not a tracking proxy + */ export function unwrap(value: T): T { if (value !== null && typeof value === 'object') { const target = proxyTargetMap.get(value as object) @@ -176,7 +178,10 @@ export function unwrap(value: T): T { * that forbids returning different values for non-configurable properties. * The proxy reads actual values from the real frozen state object. * - * Child proxies are cached within a single evaluation to avoid duplicates. + * Child proxies are cached in `registry.proxyCache`, keyed by target + * identity. The cache persists across evaluations and dispatches, so an + * unchanged subtree yields the same proxy object every time — selector + * results built from those proxies stay referentially equal. * @param target - The frozen state object to wrap * @param parentPath - Dot-separated path to this object in the state tree * @param registry - Signal registry for dependency tracking @@ -208,7 +213,9 @@ export function createTrackingProxy( // Use an unfrozen shell as the proxy target to avoid ES Proxy invariant // violations with frozen objects. The shell copies the target's prototype // so that Array.isArray, instanceof, etc. work correctly. - const shell = Array.isArray(target) ? [] : Object.create(Object.getPrototypeOf(target)) + const shell = Array.isArray(target) + ? [] + : Object.create(Object.getPrototypeOf(target)) // Per-proxy cache of prop -> full path string. parentPath is fixed for // this proxy's lifetime, so path keys are stable. Reusing the same @@ -221,8 +228,7 @@ export function createTrackingProxy( function getPathKey(prop: string): string { let key = pathKeyCache.get(prop) if (key === undefined) { - const segment = encodePathSegment(prop) - key = parentPath ? parentPath + '.' + segment : segment + key = joinPath(parentPath, encodePathSegment(prop)) pathKeyCache.set(prop, key) } return key @@ -246,7 +252,13 @@ export function createTrackingProxy( if (leafTracker) { leafTracker.traversedPaths.add(parentPath) } - return createArrayMethodInterceptor(target, proxy, prop as string, registry, parentPath) + return createArrayMethodInterceptor( + target, + proxy, + prop as string, + registry, + parentPath, + ) } return value } @@ -281,7 +293,11 @@ export function createTrackingProxy( if (isObjectOrArray(value)) { // For array element access: check if parent array has identity-based tracking. // If so, use the identity path (items.{id:42}) instead of index path (items.0). - if (Array.isArray(target) && !Array.isArray(value) && !isNaN(Number(prop))) { + if ( + Array.isArray(target) && + !Array.isArray(value) && + !isNaN(Number(prop)) + ) { let meta = registry.getArrayMeta(parentPath) if (!meta) { // First time accessing this array's elements — try to detect key field @@ -360,11 +376,10 @@ export function createTrackingProxy( }, // Track when selectors iterate keys (Object.keys, for...in, .map, .filter, etc.) - // Built raw (NOT via getPathKey): '@@keys' is a meta segment, and - // getPathKey would %-escape its '@'s like a state key's. ownKeys(_obj) { - const keysPath = parentPath ? parentPath + '.@@keys' : '@@keys' - registry.getOrCreate(keysPath, Reflect.ownKeys(target)).get() + registry + .getOrCreate(keysMetaPath(parentPath), Reflect.ownKeys(target)) + .get() return Reflect.ownKeys(target) }, @@ -372,7 +387,9 @@ export function createTrackingProxy( has(_obj, prop) { if (typeof prop === 'symbol') return Reflect.has(target, prop) const pathKey = getPathKey(prop as string) - registry.getOrCreate(pathKey, (target as Record)[prop]).get() + registry + .getOrCreate(pathKey, (target as Record)[prop]) + .get() return Reflect.has(target, prop) }, diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index e728e7986..d3848fd0c 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -19,8 +19,7 @@ import { import { computed, effect, signal } from './reactiveSystem' import { untrackResult } from './untrack' -const { useRef, useMemo, useEffect, useSyncExternalStore, useDebugValue } = - React +const { useRef, useMemo, useSyncExternalStore, useDebugValue } = React const refEquality: EqualityFn = Object.is @@ -106,7 +105,6 @@ const useSignalSelectorImpl = ( // functions must never be called with the unseeded value — stock // useSelector never compares against a nonexistent previous result. let hasResult = false - let version = 0 let notifyReact: (() => void) | null = null let suppressNotify = false // Set when the selector threw during an effect re-evaluation (classic @@ -136,13 +134,6 @@ const useSignalSelectorImpl = ( let lastServerState: unknown = null let serverResult: R - // Create a computed that runs the selector through a tracking proxy. - // This establishes signal dependencies on the paths the selector reads. - // - // Intermediate object traversals don't create signal dependencies (to avoid - // "false sharing" where siblings cause re-runs). Only leaf primitive reads - // create deps automatically. If the selector returns a proxy (object result), - // we explicitly read that object's signal to establish the terminal dependency. // Track leaf object accesses for identity comparison support. // Objects read by the selector but never traversed deeper are // "leaf objects" — their identity matters (e.g., `a === b`). @@ -304,6 +295,13 @@ const useSignalSelectorImpl = ( firstRun = false } + // The computed runs the selector through a tracking proxy, which + // establishes signal dependencies on the paths the selector reads. + // Intermediate object traversals don't create signal dependencies + // (avoiding "false sharing" where sibling changes cause re-runs); + // only leaf primitive reads create deps automatically. If the + // selector returns a proxy (object result), the terminal dependency + // is established by explicitly reading that object's signal below. const selectorComputed = computed(() => { selectorVersionSignal.get() // pendingError reflects the LAST evaluation: a successful re-run @@ -413,7 +411,6 @@ const useSignalSelectorImpl = ( // Apply user's equality function if (!equalityFnRef.current(currentResult, newValue)) { currentResult = newValue - version++ notifyReact?.() } }) @@ -470,7 +467,6 @@ const useSignalSelectorImpl = ( } if (!equalityFnRef.current(currentResult, newValue)) { currentResult = newValue - version++ notifyReact?.() } } @@ -640,13 +636,6 @@ const useSignalSelectorImpl = ( bridge.setSelector(selector) } - // Cleanup scope on unmount - useEffect(() => { - return () => { - // bridge.subscribe's cleanup handles effect disposal - } - }, [bridge]) - const selectedState = useSyncExternalStore( bridge.subscribe, bridge.getSnapshot, From 0573ce48e3643cd9c5e52761cabc0f4fd9413d69 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 16 Aug 2026 00:56:09 -0400 Subject: [PATCH 53/95] Support swapping the store prop on SignalProvider The registry and the diff baseline are now keyed to the store via useMemo, matching how stock Provider rebuilds its Subscription. Before this, a store swap kept the old registry and diffed the old store's state against the new store's on the first dispatch. Also flips the hook function's generic order to so it matches both SignalProviderProps and stock Provider (it was for no reason), and lazily initializes state instead of allocating a registry on every render. New storeSwap.spec.tsx pins the behavior (verified the diff-baseline test fails without the fix) and a type test pins the generic order. --- src/signals/SignalProvider.tsx | 24 ++- test/signals/storeSwap.spec.tsx | 171 ++++++++++++++++++++++ test/typetests/signal-provider.test-d.tsx | 46 ++++++ 3 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 test/signals/storeSwap.spec.tsx create mode 100644 test/typetests/signal-provider.test-d.tsx diff --git a/src/signals/SignalProvider.tsx b/src/signals/SignalProvider.tsx index 133ed434c..54296b936 100644 --- a/src/signals/SignalProvider.tsx +++ b/src/signals/SignalProvider.tsx @@ -17,15 +17,23 @@ export interface SignalProviderProps< > extends ProviderProps {} export function SignalProvider< - S extends object, - A extends Action = UnknownAction, + A extends Action = UnknownAction, + S extends object = object, >(providerProps: SignalProviderProps) { const { children, context, serverState, store } = providerProps - const [registry] = React.useState(() => createPathSignalRegistry(alienEngine)) - - // Track previous state for signal diffing - const prevStateRef = React.useRef(store.getState()) + // The signal graph and the diff baseline are keyed to the store: + // swapping the `store` prop starts over with a fresh registry, the + // same way stock Provider rebuilds its Subscription. `prevState` is + // mutable — onStateChange advances it after each diff. + const signalState = React.useMemo( + () => ({ + registry: createPathSignalRegistry(alienEngine), + prevState: store.getState() as S, + }), + [store], + ) + const registry = signalState.registry // Build context value: standard ReactReduxContextValue + signal fields const contextValue = React.useMemo(() => { @@ -60,9 +68,9 @@ export function SignalProvider< subscription.onStateChange = () => { // Run signal diff BEFORE notifying nested subs, so computed values // are up-to-date when useSelector/useSignalSelector read them - const prev = prevStateRef.current + const prev = signalState.prevState const next = store.getState() - prevStateRef.current = next + signalState.prevState = next const changedRootKeys = reconcileState(prev, next, registry, alienEngine) // Coarse tier: wake deferred subscribers whose top-level segments diff --git a/test/signals/storeSwap.spec.tsx b/test/signals/storeSwap.spec.tsx new file mode 100644 index 000000000..cc9eb649c --- /dev/null +++ b/test/signals/storeSwap.spec.tsx @@ -0,0 +1,171 @@ +/** + * Swapping the `store` prop on a mounted SignalProvider must behave like + * stock Provider: components immediately read from the new store, the old + * store is fully disconnected, and the signal graph starts over — the + * registry and the diff baseline are keyed to the store, so the first + * dispatch on the new store diffs new-store state against new-store + * state, never against the old store's. + */ +import { configureStore } from '@reduxjs/toolkit' +import * as rtl from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { SignalProvider, useSignalSelector } from '../../src/signals' + +interface RootState { + counter: { value: number } + other: { name: string } +} + +const makeStore = (initialValue = 0, name = 'stock') => + configureStore({ + reducer: { + counter: (state = { value: initialValue }, action: any) => + action.type === 'increment' ? { value: state.value + 1 } : state, + other: (state = { name }, action: any) => + action.type === 'rename' ? { name: action.payload } : state, + }, + }) + +afterEach(() => { + rtl.cleanup() +}) + +describe('SignalProvider store prop swap', () => { + it('reads from the new store after a swap and disconnects the old one', () => { + const storeA = makeStore(0) + const storeB = makeStore(100) + + let renderCount = 0 + let selectorCalls = 0 + + function Counter() { + renderCount++ + const value = useSignalSelector((s: RootState) => { + selectorCalls++ + return s.counter.value + }) + return
{value}
+ } + + const ui = (store: ReturnType) => ( + + + + ) + + const { getByTestId, rerender } = rtl.render(ui(storeA)) + expect(getByTestId('value').textContent).toBe('0') + expect(renderCount).toBe(1) + + rtl.act(() => { + storeA.dispatch({ type: 'increment' }) + }) + expect(getByTestId('value').textContent).toBe('1') + expect(renderCount).toBe(2) + + rerender(ui(storeB)) + expect(getByTestId('value').textContent).toBe('100') + + const rendersAfterSwap = renderCount + + // The old store is disconnected: dispatching on it does nothing. + rtl.act(() => { + storeA.dispatch({ type: 'increment' }) + }) + expect(getByTestId('value').textContent).toBe('100') + expect(renderCount).toBe(rendersAfterSwap) + + // The new store is live. + rtl.act(() => { + storeB.dispatch({ type: 'increment' }) + }) + expect(getByTestId('value').textContent).toBe('101') + expect(renderCount).toBe(rendersAfterSwap + 1) + }) + + it('selective execution still holds after a swap', () => { + const storeA = makeStore(0, 'a') + const storeB = makeStore(0, 'b') + + let nameRenders = 0 + + function Name() { + nameRenders++ + const name = useSignalSelector((s: RootState) => s.other.name) + return
{name}
+ } + function Counter() { + const value = useSignalSelector((s: RootState) => s.counter.value) + return
{value}
+ } + + const ui = (store: ReturnType) => ( + + + + + ) + + const { getByTestId, rerender } = rtl.render(ui(storeA)) + expect(getByTestId('name').textContent).toBe('a') + + rerender(ui(storeB)) + expect(getByTestId('name').textContent).toBe('b') + expect(getByTestId('value').textContent).toBe('0') + + const nameRendersAfterSwap = nameRenders + + // A counter-only dispatch on the new store must not re-render Name: + // the fresh registry gates it on the `other` segment, not stale + // footprints from the old store's graph. + rtl.act(() => { + storeB.dispatch({ type: 'increment' }) + }) + expect(getByTestId('value').textContent).toBe('1') + expect(nameRenders).toBe(nameRendersAfterSwap) + + rtl.act(() => { + storeB.dispatch({ type: 'rename', payload: 'c' }) + }) + expect(getByTestId('name').textContent).toBe('c') + expect(nameRenders).toBe(nameRendersAfterSwap + 1) + }) + + it('first dispatch after a swap diffs against the new store state, not the old', () => { + // Same shape, different values. If the provider kept the old diff + // baseline, the swap-then-dispatch diff would report `other` as + // changed (old 'a' vs new 'b') and wake the Name subscriber. + const storeA = makeStore(0, 'a') + const storeB = makeStore(50, 'b') + + let nameSelectorCalls = 0 + + function Name() { + const name = useSignalSelector((s: RootState) => { + nameSelectorCalls++ + return s.other.name + }) + return
{name}
+ } + + const ui = (store: ReturnType) => ( + + + + ) + + const { getByTestId, rerender } = rtl.render(ui(storeA)) + rerender(ui(storeB)) + expect(getByTestId('name').textContent).toBe('b') + + const callsAfterSwap = nameSelectorCalls + + // `other` did not change within storeB, so the selector must not + // re-run — a stale old-store baseline would make it re-run here. + rtl.act(() => { + storeB.dispatch({ type: 'increment' }) + }) + expect(getByTestId('name').textContent).toBe('b') + expect(nameSelectorCalls).toBe(callsAfterSwap) + }) +}) diff --git a/test/typetests/signal-provider.test-d.tsx b/test/typetests/signal-provider.test-d.tsx new file mode 100644 index 000000000..56d36ecfe --- /dev/null +++ b/test/typetests/signal-provider.test-d.tsx @@ -0,0 +1,46 @@ +import type { Action, Store, UnknownAction } from 'redux' +import { SignalProvider } from '../../src/signals' + +type FooAction = Action<'foo'> + +declare const store: Store<{ foo: string }> +declare const fooStore: Store<{ foo: string }, FooAction> + +describe('type tests', () => { + test('SignalProvider rejects the same bad props as stock Provider', () => { + expectTypeOf(SignalProvider) + .parameter(0) + .not.toMatchTypeOf({ store, serverState: 'oops', children: 'foo' }) + + const App = () => { + return ( + + foo + + ) + } + }) + + test('SignalProvider generic order matches stock Provider ()', () => { + const Explicit = () => { + return ( + store={fooStore}> + foo +
+ ) + } + + const WrongOrder = () => { + return ( + // @ts-expect-error state type is the second parameter, not the first + store={fooStore}> + foo +
+ ) + } + }) +}) From 322c9380a54eb0e294ad04ecd84bb6aa2a9e1c24 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 16 Aug 2026 01:14:28 -0400 Subject: [PATCH 54/95] Strengthen signals test suite: exact assertions and new coverage - diff.spec.ts: capture signals before diffing instead of getOrCreate-seeding the expected value, which passed vacuously; assert outcomes in the two previously assertion-free edge-case tests - Exact counts replace loose bounds in engine, useSignalSelector, devModeChecks, and trackingProxy specs - Replace getByTestId().toBeTruthy() with content assertions in suspenseUncommitted/zombieChildren/ssr specs - pathSignalRegistry.spec.ts: new unit coverage for ensurePrefix/hasPrefix, pruneChildren, release + flushReleases (incl. resurrection and stale-node guards), column/structure signal tracking and release cleanup, debugStats - edgeCasesReact.spec.tsx: selector throw-then-recover (pendingError retry leg) and dispatch-during-selector tests --- test/signals/coarseTier.spec.tsx | 9 +- test/signals/devModeChecks.spec.tsx | 10 +- test/signals/diff.spec.ts | 80 +++-- test/signals/edgeCasesReact.spec.tsx | 146 +++++++++ test/signals/engine.spec.ts | 4 +- test/signals/pathSignalRegistry.spec.ts | 378 +++++++++++++++++++++- test/signals/ssr.spec.tsx | 7 +- test/signals/suspenseUncommitted.spec.tsx | 16 +- test/signals/trackingProxy.spec.ts | 356 ++++++++++++++------ test/signals/useSignalSelector.spec.tsx | 42 ++- test/signals/zombieChildren.spec.tsx | 12 +- 11 files changed, 895 insertions(+), 165 deletions(-) diff --git a/test/signals/coarseTier.spec.tsx b/test/signals/coarseTier.spec.tsx index b144f4301..15751b5e7 100644 --- a/test/signals/coarseTier.spec.tsx +++ b/test/signals/coarseTier.spec.tsx @@ -149,10 +149,7 @@ describe('coarse tier: deferred deep-graph build', () => { renders++ const [useB, setUseB] = useState(false) const value = useSignalSelector( - useCallback( - (s: AppState) => (useB ? s.b.value : s.a.value), - [useB], - ), + useCallback((s: AppState) => (useB ? s.b.value : s.a.value), [useB]), noStabilityCheck, ) return ( @@ -285,8 +282,8 @@ describe('coarse tier: deferred deep-graph build', () => { expect(getByTestId('count').textContent).toBe('2') }) - it('builds eagerly for an identity selector and returns the raw state', () => { - const { store, bumpA } = makeStore() + it('skips coarse registration for an identity selector and returns the raw state', () => { + const { store } = makeStore() const { captured, Capture } = makeRegistryCapture() let selected: AppState | null = null diff --git a/test/signals/devModeChecks.spec.tsx b/test/signals/devModeChecks.spec.tsx index cfcd09c39..942194253 100644 --- a/test/signals/devModeChecks.spec.tsx +++ b/test/signals/devModeChecks.spec.tsx @@ -197,9 +197,9 @@ describe('dev-mode argument validation', () => { } it('throws if no selector is passed', () => { - expect(() => - renderWith(() => (useSignalSelector as any)()), - ).toThrow(/You must pass a selector to useSignalSelector/) + expect(() => renderWith(() => (useSignalSelector as any)())).toThrow( + /You must pass a selector to useSignalSelector/, + ) }) it('throws if the selector is not a function', () => { @@ -391,7 +391,9 @@ describe('stabilityCheck', () => { rtl.act(() => { store.dispatch(addTodo()) }) - expect(stabilityWarnings().length).toBeGreaterThanOrEqual(2) + // One more warning per selector evaluation after the dispatch: + // one for the promotion run, one for the render-phase swap run + expect(stabilityWarnings()).toHaveLength(3) }) it('"once" does not re-check on later evaluations', () => { diff --git a/test/signals/diff.spec.ts b/test/signals/diff.spec.ts index 740418cef..3d9cd6242 100644 --- a/test/signals/diff.spec.ts +++ b/test/signals/diff.spec.ts @@ -65,6 +65,7 @@ describe('diffAndUpdateSignals', () => { // Identity-based path for keyed array element const todoTextSig = registry.getOrCreate('todos.{id:1}.text', 'hi') const todosSig = registry.getOrCreate('todos', todos) + const countSig = registry.getOrCreate('count', 0) const initialTodosVersion = todosSig.get() const initialText = todoTextSig.get() @@ -74,7 +75,7 @@ describe('diffAndUpdateSignals', () => { expect(todosSig.get()).toBe(initialTodosVersion) expect(todoTextSig.get()).toBe(initialText) // count should be updated - expect(registry.getOrCreate('count', 1).get()).toBe(1) + expect(countSig.get()).toBe(1) }) }) @@ -88,10 +89,13 @@ describe('diffAndUpdateSignals', () => { s.age }) + const nameSig = registry.getOrCreate('name', 'Alice') + const ageSig = registry.getOrCreate('age', 30) + diffAndUpdateSignals(prev, next, '', registry) - expect(registry.getOrCreate('name', 'Bob').get()).toBe('Bob') - expect(registry.getOrCreate('age', 30).get()).toBe(30) // unchanged + expect(nameSig.get()).toBe('Bob') + expect(ageSig.get()).toBe(30) // unchanged }) it('handles number changes', () => { @@ -99,9 +103,11 @@ describe('diffAndUpdateSignals', () => { const next = { value: 42 } const registry = setupRegistry(prev, (s) => s.value) + const valueSig = registry.getOrCreate('value', 0) + diffAndUpdateSignals(prev, next, '', registry) - expect(registry.getOrCreate('value', 42).get()).toBe(42) + expect(valueSig.get()).toBe(42) }) it('handles boolean changes', () => { @@ -109,9 +115,11 @@ describe('diffAndUpdateSignals', () => { const next = { active: false } const registry = setupRegistry(prev, (s) => s.active) + const activeSig = registry.getOrCreate('active', true) + diffAndUpdateSignals(prev, next, '', registry) - expect(registry.getOrCreate('active', false).get()).toBe(false) + expect(activeSig.get()).toBe(false) }) it('handles null ↔ value changes', () => { @@ -119,9 +127,11 @@ describe('diffAndUpdateSignals', () => { const next = { data: 'loaded' } const registry = setupRegistry(prev, (s) => s.data) + const dataSig = registry.getOrCreate('data', null) + diffAndUpdateSignals(prev, next, '', registry) - expect(registry.getOrCreate('data', 'loaded').get()).toBe('loaded') + expect(dataSig.get()).toBe('loaded') }) it('handles undefined ↔ value changes', () => { @@ -129,9 +139,11 @@ describe('diffAndUpdateSignals', () => { const next = { data: 'loaded' } const registry = setupRegistry(prev, (s) => s.data) + const dataSig = registry.getOrCreate('data', undefined) + diffAndUpdateSignals(prev, next, '', registry) - expect(registry.getOrCreate('data', 'loaded').get()).toBe('loaded') + expect(dataSig.get()).toBe('loaded') }) }) @@ -143,12 +155,13 @@ describe('diffAndUpdateSignals', () => { const registry = setupRegistry(prev, (s) => s.user.name) const userSig = registry.getOrCreate('user', prev.user) + const nameSig = registry.getOrCreate('user.name', 'Alice') const initialVersion = userSig.get() as number diffAndUpdateSignals(prev, next, '', registry) expect(userSig.get()).toBe(initialVersion + 1) - expect(registry.getOrCreate('user.name', 'Bob').get()).toBe('Bob') + expect(nameSig.get()).toBe('Bob') }) it('does not update untracked paths', () => { @@ -176,10 +189,12 @@ describe('diffAndUpdateSignals', () => { const registry = setupRegistry(prev, (s) => s.a.b.c.d) + const leafSig = registry.getOrCreate('a.b.c.d', 'original') + diffAndUpdateSignals(prev, next, '', registry) // Leaf signal should be updated - expect(registry.getOrCreate('a.b.c.d', 'changed').get()).toBe('changed') + expect(leafSig.get()).toBe('changed') // Intermediate objects have no signals (only prefix registrations) // They don't get version bumps — that's the optimization expect(registry.has('a')).toBe(false) @@ -291,10 +306,17 @@ describe('diffAndUpdateSignals', () => { s.todos[1].completed }) + // Identity-based paths: elements have 'id' field + const completedSig = registry.getOrCreate('todos.{id:1}.completed', false) + const otherCompletedSig = registry.getOrCreate( + 'todos.{id:2}.completed', + false, + ) + diffAndUpdateSignals(prev, next, '', registry) - // Identity-based paths: elements have 'id' field - expect(registry.getOrCreate('todos.{id:1}.completed', true).get()).toBe(true) + expect(completedSig.get()).toBe(true) + expect(otherCompletedSig.get()).toBe(false) // unchanged }) it('handles array item addition (push)', () => { @@ -370,13 +392,13 @@ describe('diffAndUpdateSignals', () => { ) proxy.items.length // triggers @@keys and items tracking - const keysPath = 'items.@@keys' - registry.getOrCreate(keysPath, prev.length) + const keysSig = registry.getOrCreate('items.@@keys', prev.length) + const initialKeysVersion = keysSig.get() diffAndUpdateSignals({ items: prev }, { items: next }, '', registry) // @@keys updated for length change - // The items signal itself should be version-bumped + expect(keysSig.get()).not.toBe(initialKeysVersion) }) it('handles empty array to non-empty', () => { @@ -387,15 +409,16 @@ describe('diffAndUpdateSignals', () => { s.items.length }) - registry.getOrCreate('items.@@keys', 0) + const keysSig = registry.getOrCreate('items.@@keys', 0) + const itemsSig = registry.getOrCreate('items', prev.items) + const initialKeysVersion = keysSig.get() + const initialItemsVersion = itemsSig.get() diffAndUpdateSignals(prev, next, '', registry) - // @@keys should be updated - const keysSignal = registry.getOrCreate('items.@@keys', 3) - // items should be version-bumped - const itemsSig = registry.getOrCreate('items', next.items) - expect(typeof itemsSig.get()).toBe('number') + // @@keys should be updated and items version-bumped + expect(keysSig.get()).not.toBe(initialKeysVersion) + expect(itemsSig.get()).not.toBe(initialItemsVersion) }) }) @@ -668,16 +691,21 @@ describe('diffAndUpdateSignals', () => { const next = { a: 1 } const registry = createPathSignalRegistry(alienEngine) - // Should not throw - diffAndUpdateSignals(prev, next, '', registry) + expect(() => diffAndUpdateSignals(prev, next, '', registry)).not.toThrow() + // Nothing was tracked, so nothing should be created + expect(registry.size()).toBe(0) }) it('handles root-level primitive comparison', () => { // Edge case: comparing primitives at root level const registry = createPathSignalRegistry(alienEngine) + const rootSig = registry.getOrCreate('root', 'old') - // Should not throw — just returns because prev !== next but neither is object - diffAndUpdateSignals('old', 'new', 'root', registry) + expect(() => + diffAndUpdateSignals('old', 'new', 'root', registry), + ).not.toThrow() + // A tracked signal at the offset path gets the new primitive value + expect(rootSig.get()).toBe('new') }) it('handles nested path offset correctly', () => { @@ -685,12 +713,12 @@ describe('diffAndUpdateSignals', () => { const next = { value: 20 } const registry = createPathSignalRegistry(alienEngine) - registry.getOrCreate('nested.obj.value', 10) + const valueSig = registry.getOrCreate('nested.obj.value', 10) // Diff starting from a path offset diffAndUpdateSignals(prev, next, 'nested.obj', registry) - expect(registry.getOrCreate('nested.obj.value', 20).get()).toBe(20) + expect(valueSig.get()).toBe(20) }) }) }) diff --git a/test/signals/edgeCasesReact.spec.tsx b/test/signals/edgeCasesReact.spec.tsx index 580a9932d..87c2e73cb 100644 --- a/test/signals/edgeCasesReact.spec.tsx +++ b/test/signals/edgeCasesReact.spec.tsx @@ -291,6 +291,152 @@ describe('edge cases: React integration', () => { }) }) + describe('selector errors', () => { + class ErrorBoundary extends React.Component< + { children: React.ReactNode }, + { error: Error | null } + > { + state = { error: null as Error | null } + static getDerivedStateFromError(error: Error) { + return { error } + } + render() { + if (this.state.error) { + return
{this.state.error.message}
+ } + return this.props.children + } + } + + it('recovers when a throwing evaluation is superseded before React renders', () => { + // Two dispatches in one act: the first makes the selector throw + // during the effect's re-evaluation, the second fixes the state. + // The successful re-run must clear the pending error so the + // render that follows never sees it. + let throwCount = 0 + function FilterReader() { + const filter = useSignalSelector((s: TestState) => { + if (s.filter === 'active') { + throwCount++ + throw new Error('transient selector failure') + } + return s.filter + }) + return
{filter}
+ } + + const { getByTestId, queryByTestId } = rtl.render( + + + + + , + ) + expect(getByTestId('filter').textContent).toBe('all') + + rtl.act(() => { + store.dispatch(filterSlice.actions.set('active')) + store.dispatch(filterSlice.actions.set('completed')) + }) + + // The throw really happened — recovery is what is being tested, + // not a selector that never saw the bad state. + expect(throwCount).toBeGreaterThan(0) + expect(queryByTestId('boundary')).toBeNull() + expect(getByTestId('filter').textContent).toBe('completed') + }) + + it('recovers when a store subscriber corrects the throwing state during notification', () => { + // Same retry leg, driven re-entrantly: the subscriber sees the + // bad state mid-notification and dispatches the fix before React + // gets to render. + let throwCount = 0 + function FilterReader() { + const filter = useSignalSelector((s: TestState) => { + if (s.filter === 'active') { + throwCount++ + throw new Error('transient selector failure') + } + return s.filter + }) + return
{filter}
+ } + + const { getByTestId, queryByTestId } = rtl.render( + + + + + , + ) + + // Promote out of the coarse tier first so the deep effect + // re-evaluates the selector synchronously on each dispatch — + // otherwise the hook never observes the intermediate state. + rtl.act(() => { + store.dispatch(filterSlice.actions.set('completed')) + }) + expect(getByTestId('filter').textContent).toBe('completed') + + const unsubscribe = store.subscribe(() => { + if (store.getState().filter === 'active') { + store.dispatch(filterSlice.actions.set('all')) + } + }) + + rtl.act(() => { + store.dispatch(filterSlice.actions.set('active')) + }) + + expect(throwCount).toBeGreaterThan(0) + expect(queryByTestId('boundary')).toBeNull() + expect(getByTestId('filter').textContent).toBe('all') + unsubscribe() + }) + }) + + describe('dispatch during a selector', () => { + it('a selector that dispatches once during evaluation settles on the final state', () => { + // Dispatching from a selector is an application bug, but it must + // not corrupt the graph or loop forever — the guarded dispatch + // lands and every hook settles on the post-dispatch state. + let dispatched = false + + function GreedyReader() { + const value = useSignalSelector((s: TestState) => { + if (!dispatched && s.counters.counter1.value === 1) { + dispatched = true + store.dispatch(countersSlice.actions.increment('counter1')) + } + return s.counters.counter1.value + }) + return
{value}
+ } + + function OtherReader() { + const value = useSignalSelector( + (s: TestState) => s.counters.counter1.value, + ) + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + + , + ) + expect(getByTestId('greedy').textContent).toBe('0') + + rtl.act(() => { + store.dispatch(countersSlice.actions.increment('counter1')) + }) + + expect(getByTestId('greedy').textContent).toBe('2') + expect(getByTestId('other').textContent).toBe('2') + }) + }) + describe('server-side rendering', () => { // useSyncExternalStore is called without a getServerSnapshot argument, // so any server render (Next.js etc.) throws. diff --git a/test/signals/engine.spec.ts b/test/signals/engine.spec.ts index 39c0e39b6..c9f9e62e6 100644 --- a/test/signals/engine.spec.ts +++ b/test/signals/engine.spec.ts @@ -173,8 +173,8 @@ describe('SignalEngine (alien-signals)', () => { // Only the final value should be seen after batch expect(s.get()).toBe(3) - // Effect should have run once (or a small number), not 3 times - expect(effectCount).toBeLessThanOrEqual(1) + // Nested batches coalesce into a single propagation, not 3 + expect(effectCount).toBe(1) scope.stop() }) diff --git a/test/signals/pathSignalRegistry.spec.ts b/test/signals/pathSignalRegistry.spec.ts index 0505aae1c..7c930442b 100644 --- a/test/signals/pathSignalRegistry.spec.ts +++ b/test/signals/pathSignalRegistry.spec.ts @@ -1,6 +1,31 @@ import { describe, it, expect } from 'vitest' import { alienEngine } from '../../src/signals/engine' import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import type { RegistryStats } from '../../src/signals/pathSignalRegistry' +import type { ReactiveSignal } from '../../src/signals/types' + +const EMPTY_STATS: RegistryStats = { + signals: 0, + prefixCounts: 0, + prefixOnlyPaths: 0, + childIndex: 0, + arrayMetas: 0, + columnsByArray: 0, + structuresByArray: 0, + segmentSubs: 0, +} + +/** Subscribe an effect to a signal; stopping the scope drops the last + * subscriber, which makes the signal call back into registry.release. */ +function watch(sig: ReactiveSignal) { + const scope = alienEngine.createScope() + scope.run(() => { + alienEngine.effect(() => { + sig.get() + }) + }) + return scope +} describe('PathSignalRegistry', () => { function createRegistry() { @@ -112,7 +137,9 @@ describe('PathSignalRegistry', () => { const sig1 = registry.getOrCreate('a', 0) const sig2 = registry.getOrCreate('b', 0) - const c = alienEngine.computed(() => (sig1.get() as number) + (sig2.get() as number)) + const c = alienEngine.computed( + () => (sig1.get() as number) + (sig2.get() as number), + ) let effectCount = 0 const scope = alienEngine.createScope() scope.run(() => { @@ -210,4 +237,353 @@ describe('PathSignalRegistry', () => { expect(registry.has('path')).toBe(false) }) }) + + describe('ensurePrefix / hasPrefix', () => { + it('hasPrefix is true for a signal path and every ancestor', () => { + const registry = createRegistry() + registry.getOrCreate('a.b.c', 1) + expect(registry.hasPrefix('a.b.c')).toBe(true) + expect(registry.hasPrefix('a.b')).toBe(true) + expect(registry.hasPrefix('a')).toBe(true) + expect(registry.hasPrefix('a.b.c.d')).toBe(false) + expect(registry.hasPrefix('x')).toBe(false) + }) + + it('ensurePrefix registers a prefix without creating a signal', () => { + const registry = createRegistry() + registry.ensurePrefix('a.b') + expect(registry.hasPrefix('a.b')).toBe(true) + expect(registry.hasPrefix('a')).toBe(true) + expect(registry.has('a.b')).toBe(false) + expect(registry.size()).toBe(0) + }) + + it('ensurePrefix is idempotent: prune fully clears double registration', () => { + const registry = createRegistry() + registry.ensurePrefix('a.b') + registry.ensurePrefix('a.b') + registry.prune('a.b') + expect(registry.hasPrefix('a.b')).toBe(false) + expect(registry.hasPrefix('a')).toBe(false) + expect(registry.debugStats()).toEqual(EMPTY_STATS) + }) + + it('getOrCreate after ensurePrefix does not double-count prefixes', () => { + const registry = createRegistry() + registry.ensurePrefix('a.b') + registry.getOrCreate('a.b', { x: 1 }) + registry.prune('a.b') + // One prune must fully clear the accounting; a leftover prefix + // count here would mean the path was counted twice. + expect(registry.hasPrefix('a.b')).toBe(false) + expect(registry.hasPrefix('a')).toBe(false) + expect(registry.debugStats()).toEqual(EMPTY_STATS) + }) + }) + + describe('pruneChildren', () => { + it('removes descendants but keeps the path itself', () => { + const registry = createRegistry() + const parentSig = registry.getOrCreate('obj', { a: 1, b: { c: 2 } }) + registry.getOrCreate('obj.a', 1) + registry.getOrCreate('obj.b', { c: 2 }) + registry.getOrCreate('obj.b.c', 2) + + registry.pruneChildren('obj') + + expect(registry.has('obj')).toBe(true) + expect(registry.has('obj.a')).toBe(false) + expect(registry.has('obj.b')).toBe(false) + expect(registry.has('obj.b.c')).toBe(false) + // The parent signal survives untouched (still version 0). + expect(parentSig.get()).toBe(0) + }) + + it('fires the pruned child signals so dependents re-run', () => { + const registry = createRegistry() + registry.getOrCreate('obj', {}) + const childSig = registry.getOrCreate('obj.name', 'x') + + let runs = 0 + const c = alienEngine.computed(() => { + runs++ + return registry.has('obj.name') + ? registry.getOrCreate('obj.name', 'x').get() + : 'gone' + }) + expect(c.get()).toBe('x') + expect(runs).toBe(1) + + registry.pruneChildren('obj') + expect(c.get()).toBe('gone') + expect(runs).toBe(2) + + void childSig + }) + + it('is a no-op for a path with no children', () => { + const registry = createRegistry() + registry.getOrCreate('leaf', 1) + registry.pruneChildren('leaf') + expect(registry.has('leaf')).toBe(true) + expect(registry.size()).toBe(1) + }) + }) + + describe('release (unwatched callback) and flushReleases', () => { + it('dropping the last subscriber removes the signal and its prefixes', () => { + const registry = createRegistry() + const sig = registry.getOrCreate('a.b.c', 1) + const scope = watch(sig) + expect(registry.has('a.b.c')).toBe(true) + + scope.stop() + + expect(registry.has('a.b.c')).toBe(false) + // hasPrefix flushes pending releases before answering. + expect(registry.hasPrefix('a.b.c')).toBe(false) + expect(registry.hasPrefix('a')).toBe(false) + expect(registry.debugStats()).toEqual(EMPTY_STATS) + }) + + it('a never-watched signal stays registered', () => { + const registry = createRegistry() + registry.getOrCreate('a', 1) + expect(registry.debugStats().signals).toBe(1) + }) + + it('releasing a parent keeps a still-watched child alive, then the child unlinks everything', () => { + const registry = createRegistry() + const parent = registry.getOrCreate('a.b', { c: 1 }) + const child = registry.getOrCreate('a.b.c', 1) + const parentScope = watch(parent) + const childScope = watch(child) + + parentScope.stop() + expect(registry.has('a.b')).toBe(false) + expect(registry.has('a.b.c')).toBe(true) + // The child still holds the shared prefixes up. + expect(registry.hasPrefix('a.b')).toBe(true) + expect(registry.hasPrefix('a')).toBe(true) + + childScope.stop() + expect(registry.hasPrefix('a')).toBe(false) + expect(registry.debugStats()).toEqual(EMPTY_STATS) + }) + + it('a subtree of releases clears prefix-only ancestors too', () => { + const registry = createRegistry() + registry.ensurePrefix('todos') + const s0 = registry.getOrCreate('todos.0.text', 'a') + const s1 = registry.getOrCreate('todos.1.text', 'b') + const scope0 = watch(s0) + const scope1 = watch(s1) + + scope0.stop() + scope1.stop() + + // The ensurePrefix registration has no signal, so no unwatched + // callback will ever fire for it — flushReleases must clear it + // once the last signal below it is gone. + expect(registry.hasPrefix('todos')).toBe(false) + expect(registry.debugStats()).toEqual(EMPTY_STATS) + }) + + it('a signal resurrected before the flush is not evicted', () => { + const registry = createRegistry() + const first = registry.getOrCreate('a.b', 1) + const scope = watch(first) + scope.stop() + // Release already ran (signal is gone from the map)… + expect(registry.has('a.b')).toBe(false) + + // …but the deferred flush has not. Recreating the path now must + // survive that flush. + const second = registry.getOrCreate('a.b', 2) + expect(registry.has('a.b')).toBe(true) + expect(registry.hasPrefix('a')).toBe(true) + expect(second.get()).toBe(2) + expect(second).not.toBe(first) + }) + + it('release ignores a stale node after prune + recreate', () => { + const registry = createRegistry() + const stale = registry.getOrCreate('x', 1) + registry.prune('x') + const live = registry.getOrCreate('x', 2) + + // Simulate the stale node's late unwatched callback. + registry.release('x', stale) + + expect(registry.has('x')).toBe(true) + expect(registry.getOrCreate('x', 0)).toBe(live) + }) + }) + + describe('array metadata', () => { + it('setArrayMeta / getArrayMeta round-trips and prune clears it', () => { + const registry = createRegistry() + const meta = { keyField: 'id', entityMap: new Map() } + registry.setArrayMeta('todos', meta) + expect(registry.getArrayMeta('todos')).toBe(meta) + expect(registry.getArrayMeta('other')).toBeUndefined() + + registry.getOrCreate('todos', []) + registry.prune('todos') + expect(registry.getArrayMeta('todos')).toBeUndefined() + }) + }) + + describe('column signals', () => { + it('trackColumn registers the prop and creates one signal per column', () => { + const registry = createRegistry() + const sig1 = registry.trackColumn('todos', 'text') + const sig2 = registry.trackColumn('todos', 'text') + expect(sig1).toBe(sig2) + expect(registry.getTrackedColumns('todos')).toEqual(new Set(['text'])) + expect(registry.getTrackedColumns('other')).toBeUndefined() + }) + + it('bumpColumn increments the column signal version', () => { + const registry = createRegistry() + const sig = registry.trackColumn('todos', 'text') + expect(sig.get()).toBe(0) + registry.bumpColumn('todos', 'text') + expect(sig.get()).toBe(1) + }) + + it('bumpColumn for an untracked column is a no-op', () => { + const registry = createRegistry() + registry.bumpColumn('todos', 'text') + expect(registry.size()).toBe(0) + }) + + it('releasing a column signal drops the prop from the tracked set', () => { + const registry = createRegistry() + const sig = registry.trackColumn('todos', 'text') + const scope = watch(sig) + expect(registry.getTrackedColumns('todos')).toEqual(new Set(['text'])) + + scope.stop() + + // Without this cleanup diffArray would keep shallow-comparing a + // column nobody reads. + expect(registry.getTrackedColumns('todos')).toBeUndefined() + expect(registry.debugStats()).toEqual(EMPTY_STATS) + }) + + it('releasing one column leaves other tracked columns intact', () => { + const registry = createRegistry() + const textSig = registry.trackColumn('todos', 'text') + registry.trackColumn('todos', 'done') + const scope = watch(textSig) + + scope.stop() + + expect(registry.getTrackedColumns('todos')).toEqual(new Set(['done'])) + }) + + it('release matches encoded column paths back to raw props', () => { + const registry = createRegistry() + // A prop containing '.' gets percent-encoded in the path key but + // stays raw in the tracked set; release has to bridge the two. + const sig = registry.trackColumn('todos', 'a.b') + const scope = watch(sig) + expect(registry.getTrackedColumns('todos')).toEqual(new Set(['a.b'])) + + scope.stop() + + expect(registry.getTrackedColumns('todos')).toBeUndefined() + }) + + it('prune(arrayPath) removes column signals and tracking', () => { + const registry = createRegistry() + registry.getOrCreate('todos', []) + registry.trackColumn('todos', 'text') + + registry.prune('todos') + + expect(registry.getTrackedColumns('todos')).toBeUndefined() + expect(registry.debugStats()).toEqual(EMPTY_STATS) + }) + }) + + describe('structural signals', () => { + it('trackStructure registers the kind and creates one signal per kind', () => { + const registry = createRegistry() + const sig1 = registry.trackStructure('todos', 'append') + const sig2 = registry.trackStructure('todos', 'append') + expect(sig1).toBe(sig2) + expect(registry.getTrackedStructures('todos')).toEqual( + new Set(['append']), + ) + }) + + it('bumpStructure fires only the matching kind', () => { + const registry = createRegistry() + const appendSig = registry.trackStructure('todos', 'append') + const removeSig = registry.trackStructure('todos', 'remove') + + registry.bumpStructure('todos', 'append') + + expect(appendSig.get()).toBe(1) + expect(removeSig.get()).toBe(0) + }) + + it('bumpStructure for an untracked kind is a no-op', () => { + const registry = createRegistry() + registry.bumpStructure('todos', 'append') + expect(registry.size()).toBe(0) + }) + + it('releasing a structural signal drops the kind from the tracked set', () => { + const registry = createRegistry() + const appendSig = registry.trackStructure('todos', 'append') + registry.trackStructure('todos', 'remove') + const scope = watch(appendSig) + + scope.stop() + + expect(registry.getTrackedStructures('todos')).toEqual( + new Set(['remove']), + ) + }) + + it('releasing the last structural signal clears the array entry', () => { + const registry = createRegistry() + const sig = registry.trackStructure('todos', 'insertOrReorder') + const scope = watch(sig) + + scope.stop() + + expect(registry.getTrackedStructures('todos')).toBeUndefined() + expect(registry.debugStats()).toEqual(EMPTY_STATS) + }) + }) + + describe('debugStats', () => { + it('reports counts for every internal structure', () => { + const registry = createRegistry() + registry.getOrCreate('a.b', 1) + registry.ensurePrefix('c') + registry.setArrayMeta('todos', { keyField: 'id', entityMap: new Map() }) + registry.trackColumn('todos', 'text') + registry.trackStructure('todos', 'append') + + expect(registry.debugStats()).toEqual({ + // a.b + column signal + structure signal + signals: 3, + // a, a.b, c, todos, todos.{*}, todos.{*}.text, todos.@@append + prefixCounts: 7, + // c and todos.{*} + prefixOnlyPaths: 2, + // a→a.b, todos→(children), todos.{*}→(column) + childIndex: 3, + arrayMetas: 1, + columnsByArray: 1, + structuresByArray: 1, + segmentSubs: 0, + }) + }) + }) }) diff --git a/test/signals/ssr.spec.tsx b/test/signals/ssr.spec.tsx index 856a6e93f..2db16f8ab 100644 --- a/test/signals/ssr.spec.tsx +++ b/test/signals/ssr.spec.tsx @@ -74,7 +74,8 @@ const selectCount = (state: AppState) => state.count const selectTheme = (state: AppState) => state.user.prefs.theme const selectDoneCount = (state: AppState) => state.todos.filter((t) => t.done).length -const selectTexts = (state: AppState) => state.todos.map((t) => t.text).join(',') +const selectTexts = (state: AppState) => + state.todos.map((t) => t.text).join(',') function Body({ useHook, @@ -189,7 +190,9 @@ describe('renderToString parity', () => { }) describe('hydration', () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) afterEach(() => { vi.clearAllMocks() diff --git a/test/signals/suspenseUncommitted.spec.tsx b/test/signals/suspenseUncommitted.spec.tsx index 713b6c7f6..892f411f0 100644 --- a/test/signals/suspenseUncommitted.spec.tsx +++ b/test/signals/suspenseUncommitted.spec.tsx @@ -195,7 +195,7 @@ describe('component suspends before commit, promise never resolves', () => {
, ) - expect(getByTestId('fallback')).toBeTruthy() + expect(getByTestId('fallback').textContent).toBe('loading') // The coarse sub is only registered inside subscribe, which never // ran; the probe itself creates no signals. Nothing to leak. expect(registry().debugStats()).toEqual(EMPTY_STATS) @@ -224,7 +224,7 @@ describe('component suspends before commit, promise never resolves', () => {
, ) - expect(getByTestId('fallback')).toBeTruthy() + expect(getByTestId('fallback').textContent).toBe('loading') // The component rendered but never committed: subscribe never ran, // no effect exists, and no cleanup path will ever fire for this // hook. Anything it left in the registry is unreachable garbage. @@ -279,7 +279,7 @@ describe('component suspends, then commits when the promise resolves', () => {
, ) - expect(getByTestId('fallback')).toBeTruthy() + expect(getByTestId('fallback').textContent).toBe('loading') await rtl.act(async () => { await gate.open() @@ -302,7 +302,7 @@ describe('component suspends, then commits when the promise resolves', () => { }) describe("today's only recovery path: pruning when state paths disappear", () => { - it('a never-committed hook\'s leaked leaf signal is removed when its state key is deleted', async () => { + it("a never-committed hook's leaked leaf signal is removed when its state key is deleted", async () => { // This test documents CURRENT behavior, not desired behavior: the // leaked path signal sits in the registry until the state key it // tracks is deleted, and prune() removes it as part of normal diff @@ -376,7 +376,7 @@ describe('transitions', () => { } const { unmount, getByTestId } = rtl.render() - expect(getByTestId('idle')).toBeTruthy() + expect(getByTestId('idle').textContent).toBe('idle') // Mount the suspending component inside a transition. The transition // render runs the hook (and, today, the eager deep build), then @@ -386,7 +386,7 @@ describe('transitions', () => { setShowOuter!(true) }) }) - expect(getByTestId('idle')).toBeTruthy() + expect(getByTestId('idle').textContent).toBe('idle') unmount() expect(registry().debugStats()).toEqual(EMPTY_STATS) @@ -433,7 +433,7 @@ describe('transitions', () => { setShowOuter!(false) }) - expect(getByTestId('idle')).toBeTruthy() + expect(getByTestId('idle').textContent).toBe('idle') expect(registry().debugStats()).toEqual(EMPTY_STATS) }) }) @@ -498,7 +498,7 @@ describe('StrictMode', () => { , ) - expect(getByTestId('fallback')).toBeTruthy() + expect(getByTestId('fallback').textContent).toBe('loading') unmount() expect(registry().debugStats()).toEqual(EMPTY_STATS) }) diff --git a/test/signals/trackingProxy.spec.ts b/test/signals/trackingProxy.spec.ts index 89f19bb92..17ba6d340 100644 --- a/test/signals/trackingProxy.spec.ts +++ b/test/signals/trackingProxy.spec.ts @@ -1,5 +1,9 @@ import { describe, it, expect, vi } from 'vitest' -import { createTrackingProxy, getProxyPath, unwrap } from '../../src/signals/trackingProxy' +import { + createTrackingProxy, + getProxyPath, + unwrap, +} from '../../src/signals/trackingProxy' import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' import { reconcileState } from '../../src/signals/diff' import { alienEngine } from '../../src/signals/engine' @@ -200,7 +204,9 @@ describe('createTrackingProxy', () => { const registry = makeRegistry() const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) - const names = proxy.items.map((item: { id: number; name: string }) => item.name) + const names = proxy.items.map( + (item: { id: number; name: string }) => item.name, + ) expect(names).toEqual(['A', 'B']) }) @@ -446,14 +452,24 @@ describe('createTrackingProxy', () => { const c1 = scope.run(() => alienEngine.computed(() => { comp1Calls++ - const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) return proxy.counter.value }), ) const c2 = scope.run(() => alienEngine.computed(() => { comp2Calls++ - const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) return proxy.counter.value }), ) @@ -491,7 +507,12 @@ describe('createTrackingProxy', () => { const c = scope.run(() => alienEngine.computed(() => { calls++ - const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) const result = proxy.nested.deep // Simulate what useSignalSelector does: detect proxy result // and explicitly read the object's signal for terminal dependency @@ -530,7 +551,12 @@ describe('createTrackingProxy', () => { const c = scope.run(() => alienEngine.computed(() => { calls++ - const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) // Read through intermediate objects to a leaf return proxy.nested.deep.value }), @@ -566,7 +592,12 @@ describe('createTrackingProxy', () => { const parentComp = scope.run(() => alienEngine.computed(() => { parentCalls++ - const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) return proxy.parent }), ) @@ -575,7 +606,12 @@ describe('createTrackingProxy', () => { const leafComp = scope.run(() => alienEngine.computed(() => { leafCalls++ - const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) return proxy.parent.child.leaf }), ) @@ -599,7 +635,12 @@ describe('createTrackingProxy', () => { const nested = { x: 1, y: 2 } const registry = makeRegistry() // Create proxy with a base path — simulates a proxy for a nested subtree - const proxy = createTrackingProxy(nested, 'root.sub', registry, registry.proxyCache) + const proxy = createTrackingProxy( + nested, + 'root.sub', + registry, + registry.proxyCache, + ) proxy.x @@ -611,9 +652,7 @@ describe('createTrackingProxy', () => { it('establishes signal dependencies for computed tracking', () => { const state = Object.freeze({ - todos: Object.freeze([ - Object.freeze({ id: 1, text: 'Test' }), - ]), + todos: Object.freeze([Object.freeze({ id: 1, text: 'Test' })]), filter: 'all' as const, }) const registry = makeRegistry() @@ -625,7 +664,12 @@ describe('createTrackingProxy', () => { const c = scope.run(() => { return alienEngine.computed(() => { callCount++ - const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) return proxy.filter }) }) @@ -664,7 +708,12 @@ describe('createTrackingProxy', () => { const todoComputed = scope.run(() => alienEngine.computed(() => { todoCalls++ - const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) const result = proxy.todos // Simulate terminal dependency for object result const proxyPath = getProxyPath(result) @@ -678,7 +727,12 @@ describe('createTrackingProxy', () => { const counterComputed = scope.run(() => alienEngine.computed(() => { counterCalls++ - const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) return proxy.counter }), ) @@ -833,7 +887,7 @@ describe('array method dependency tracking', () => { */ function makeFrozenState(items: TodoItem[]): TodoState { return Object.freeze({ - items: Object.freeze(items.map(i => Object.freeze(i))), + items: Object.freeze(items.map((i) => Object.freeze(i))), }) as TodoState } @@ -851,7 +905,7 @@ describe('array method dependency tracking', () => { ): TodoState { return Object.freeze({ items: Object.freeze( - state.items.map(item => + state.items.map((item) => item.id === id ? Object.freeze({ ...item, ...patch }) : item, ), ), @@ -928,9 +982,13 @@ describe('array method dependency tracking', () => { ]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.find(x => x.done)?.text, + (s: TodoState) => s.items.find((x) => x.done)?.text, registry, ) @@ -955,9 +1013,13 @@ describe('array method dependency tracking', () => { ]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.find(x => x.done)?.text, + (s: TodoState) => s.items.find((x) => x.done)?.text, registry, ) @@ -976,14 +1038,16 @@ describe('array method dependency tracking', () => { }) it('find(x => x.done) — SHOULD re-run when a new element is added', () => { - let state = makeFrozenState([ - { id: 1, text: 'a', done: false }, - ]) + let state = makeFrozenState([{ id: 1, text: 'a', done: false }]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.find(x => x.done)?.text ?? 'none', + (s: TodoState) => s.items.find((x) => x.done)?.text ?? 'none', registry, ) @@ -994,7 +1058,7 @@ describe('array method dependency tracking', () => { const prevState = state state = makeFrozenState([ { id: 1, text: 'a', done: false }, - { id: 2, text: 'b', done: true }, // new, done + { id: 2, text: 'b', done: true }, // new, done ]) reconcileState(prevState, state, registry, alienEngine) expect(c.get()).toBe('b') @@ -1013,9 +1077,17 @@ describe('array method dependency tracking', () => { ]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.filter(x => x.done).map(x => x.text).join(','), + (s: TodoState) => + s.items + .filter((x) => x.done) + .map((x) => x.text) + .join(','), registry, ) @@ -1040,9 +1112,17 @@ describe('array method dependency tracking', () => { ]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.filter(x => x.done).map(x => x.text).join(','), + (s: TodoState) => + s.items + .filter((x) => x.done) + .map((x) => x.text) + .join(','), registry, ) @@ -1068,9 +1148,13 @@ describe('array method dependency tracking', () => { ]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.some(x => x.done), + (s: TodoState) => s.items.some((x) => x.done), registry, ) @@ -1094,9 +1178,13 @@ describe('array method dependency tracking', () => { ]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.every(x => x.done), + (s: TodoState) => s.items.every((x) => x.done), registry, ) @@ -1123,9 +1211,13 @@ describe('array method dependency tracking', () => { ]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.map(x => x.text).join(','), + (s: TodoState) => s.items.map((x) => x.text).join(','), registry, ) @@ -1149,9 +1241,13 @@ describe('array method dependency tracking', () => { ]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.map(x => x.text).join(','), + (s: TodoState) => s.items.map((x) => x.text).join(','), registry, ) @@ -1163,11 +1259,9 @@ describe('array method dependency tracking', () => { state = updateItem(state, 1, { done: true }) reconcileState(prevState, state, registry, alienEngine) expect(c.get()).toBe('a,b') - // Ideally callCount stays 1, but diff fires item's parent signal - // which causes re-eval. The computed's value is === equal so the effect - // won't notify React, but the computed itself re-evaluates. - // Accept 1 or 2 here — the important thing is correctness. - expect(getCallCount()).toBeLessThanOrEqual(2) + // Only `text` was read via the column signal, so changing `done` fires + // no tracked dependency and the selector does not re-run at all. + expect(getCallCount()).toBe(1) stop() }) @@ -1182,7 +1276,11 @@ describe('array method dependency tracking', () => { const registry = makeRegistry() const targetItem = state.items[0] // raw ref - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, (s: TodoState) => s.items.includes(targetItem as TodoItem), registry, @@ -1206,14 +1304,20 @@ describe('array method dependency tracking', () => { // ─── slice — structural ─── it('slice(0, 2) — SHOULD re-run when array gains elements', () => { - let state = makeFrozenState([ - { id: 1, text: 'a', done: false }, - ]) + let state = makeFrozenState([{ id: 1, text: 'a', done: false }]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.slice(0, 2).map(x => x.text).join(','), + (s: TodoState) => + s.items + .slice(0, 2) + .map((x) => x.text) + .join(','), registry, ) @@ -1242,9 +1346,13 @@ describe('array method dependency tracking', () => { ]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.findIndex(x => x.done), + (s: TodoState) => s.items.findIndex((x) => x.done), registry, ) @@ -1288,7 +1396,7 @@ describe('array method dependency tracking', () => { */ function removeItem(state: TodoState, id: number): TodoState { return Object.freeze({ - items: Object.freeze(state.items.filter(i => i.id !== id)), + items: Object.freeze(state.items.filter((i) => i.id !== id)), }) as TodoState } @@ -1299,7 +1407,11 @@ describe('array method dependency tracking', () => { * @param item - New item * @returns New frozen state */ - function insertItem(state: TodoState, index: number, item: TodoItem): TodoState { + function insertItem( + state: TodoState, + index: number, + item: TodoItem, + ): TodoState { const items = [...state.items] items.splice(index, 0, Object.freeze(item) as TodoItem) return Object.freeze({ items: Object.freeze(items) }) as TodoState @@ -1312,9 +1424,13 @@ describe('array method dependency tracking', () => { ]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.find(x => x.done)?.text, + (s: TodoState) => s.items.find((x) => x.done)?.text, registry, ) @@ -1339,9 +1455,13 @@ describe('array method dependency tracking', () => { ]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.find(x => x.done)?.text, + (s: TodoState) => s.items.find((x) => x.done)?.text, registry, ) @@ -1366,9 +1486,13 @@ describe('array method dependency tracking', () => { ]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.find(x => x.done)?.text, + (s: TodoState) => s.items.find((x) => x.done)?.text, registry, ) @@ -1392,9 +1516,13 @@ describe('array method dependency tracking', () => { ]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.find(x => x.done)?.text, + (s: TodoState) => s.items.find((x) => x.done)?.text, registry, ) @@ -1420,9 +1548,13 @@ describe('array method dependency tracking', () => { ]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.findIndex(x => x.done), + (s: TodoState) => s.items.findIndex((x) => x.done), registry, ) @@ -1446,9 +1578,13 @@ describe('array method dependency tracking', () => { ]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.some(x => x.done), + (s: TodoState) => s.items.some((x) => x.done), registry, ) @@ -1466,14 +1602,16 @@ describe('array method dependency tracking', () => { }) it('some (true) — does NOT re-run on append', () => { - let state = makeFrozenState([ - { id: 1, text: 'a', done: true }, - ]) + let state = makeFrozenState([{ id: 1, text: 'a', done: true }]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.some(x => x.done), + (s: TodoState) => s.items.some((x) => x.done), registry, ) @@ -1491,14 +1629,16 @@ describe('array method dependency tracking', () => { }) it('every (true) — SHOULD re-run on append (new element might fail predicate)', () => { - let state = makeFrozenState([ - { id: 1, text: 'a', done: true }, - ]) + let state = makeFrozenState([{ id: 1, text: 'a', done: true }]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.every(x => x.done), + (s: TodoState) => s.items.every((x) => x.done), registry, ) @@ -1521,9 +1661,13 @@ describe('array method dependency tracking', () => { ]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.every(x => x.done), + (s: TodoState) => s.items.every((x) => x.done), registry, ) @@ -1547,9 +1691,13 @@ describe('array method dependency tracking', () => { ]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.every(x => x.done), + (s: TodoState) => s.items.every((x) => x.done), registry, ) @@ -1574,9 +1722,17 @@ describe('array method dependency tracking', () => { ]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.filter(x => x.done).map(x => x.text).join(','), + (s: TodoState) => + s.items + .filter((x) => x.done) + .map((x) => x.text) + .join(','), registry, ) @@ -1593,14 +1749,20 @@ describe('array method dependency tracking', () => { }) it('filter — SHOULD re-run on append', () => { - let state = makeFrozenState([ - { id: 1, text: 'a', done: true }, - ]) + let state = makeFrozenState([{ id: 1, text: 'a', done: true }]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.filter(x => x.done).map(x => x.text).join(','), + (s: TodoState) => + s.items + .filter((x) => x.done) + .map((x) => x.text) + .join(','), registry, ) @@ -1617,14 +1779,16 @@ describe('array method dependency tracking', () => { }) it('find (missed) — SHOULD re-run on append', () => { - let state = makeFrozenState([ - { id: 1, text: 'a', done: false }, - ]) + let state = makeFrozenState([{ id: 1, text: 'a', done: false }]) const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: TodoState) => s.items.find(x => x.done)?.text ?? 'none', + (s: TodoState) => s.items.find((x) => x.done)?.text ?? 'none', registry, ) @@ -1647,9 +1811,13 @@ describe('array method dependency tracking', () => { }) as NumState const registry = makeRegistry() - const { computed: c, getCallCount, stop } = createSelectorComputed( + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( () => state, - (s: NumState) => s.nums.find(x => x > 2), + (s: NumState) => s.nums.find((x) => x > 2), registry, ) diff --git a/test/signals/useSignalSelector.spec.tsx b/test/signals/useSignalSelector.spec.tsx index 8d954a06f..389ef3461 100644 --- a/test/signals/useSignalSelector.spec.tsx +++ b/test/signals/useSignalSelector.spec.tsx @@ -2,7 +2,11 @@ import * as rtl from '@testing-library/react' import React, { useLayoutEffect } from 'react' -import { configureStore, createSlice, type PayloadAction } from '@reduxjs/toolkit' +import { + configureStore, + createSlice, + type PayloadAction, +} from '@reduxjs/toolkit' import { SignalProvider, useSignalSelector } from '../../src/signals' // ============================================================================ @@ -34,7 +38,11 @@ const todosSlice = createSlice({ if (todo) todo.completed = !todo.completed }, add(state, action: PayloadAction<{ id: number; text: string }>) { - state.push({ id: action.payload.id, text: action.payload.text, completed: false }) + state.push({ + id: action.payload.id, + text: action.payload.text, + completed: false, + }) }, remove(state, action: PayloadAction) { return state.filter((t) => t.id !== action.payload) @@ -355,8 +363,10 @@ describe('useSignalSelector', () => {
, ) - expect(todoSelectorCalls).toBeGreaterThanOrEqual(1) - expect(counterSelectorCalls).toBeGreaterThanOrEqual(1) + // Mount: one probe run each, plus one extra from the default + // stabilityCheck: 'once' dev check + expect(todoSelectorCalls).toBe(2) + expect(counterSelectorCalls).toBe(2) const todoCallsBefore = todoSelectorCalls const counterCallsBefore = counterSelectorCalls @@ -365,10 +375,10 @@ describe('useSignalSelector', () => { store.dispatch(countersSlice.actions.increment('counter1')) }) - // Counter selector should have run, todo selector should NOT - expect(counterSelectorCalls - counterCallsBefore).toBeGreaterThanOrEqual( - 1, - ) + // Counter selector runs twice: once on promotion (old inline ref), + // once when the re-render swaps in the new inline function. + // The todo selector must NOT run at all. + expect(counterSelectorCalls - counterCallsBefore).toBe(2) expect(todoSelectorCalls - todoCallsBefore).toBe(0) }) @@ -407,7 +417,8 @@ describe('useSignalSelector', () => { }) expect(getByTestId('todo').textContent).toBe('pending') - expect(todoSelector.getCalls()).toBeGreaterThanOrEqual(1) + // Stable selector ref: exactly one run on promotion, no swap re-run + expect(todoSelector.getCalls()).toBe(1) expect(counterSelector.getCalls()).toBe(0) }) @@ -444,7 +455,8 @@ describe('useSignalSelector', () => { }) expect(getByTestId('todos').textContent).toBe('4') - expect(todoSelector.getCalls()).toBeGreaterThanOrEqual(1) + // Stable selector ref: exactly one run on promotion, no swap re-run + expect(todoSelector.getCalls()).toBe(1) expect(filterSelector.getCalls()).toBe(0) }) @@ -492,9 +504,9 @@ describe('useSignalSelector', () => { store.dispatch(countersSlice.actions.increment('counter1')) }) - // counter1 value changed → must re-render + // counter1 value changed → exactly one re-render expect(getByTestId('counter1').textContent).toBe('1') - expect(counter1Renders).toBeGreaterThan(renders1After) + expect(counter1Renders).toBe(renders1After + 1) // counter2 value did NOT change → should NOT re-render // (Object.is cutoff on the primitive computed result) @@ -599,7 +611,9 @@ describe('useSignalSelector', () => { // Verify nested state is frozen expect(Object.isFrozen(frozenStore.getState().counters)).toBe(true) - expect(Object.isFrozen(frozenStore.getState().counters.counter1)).toBe(true) + expect(Object.isFrozen(frozenStore.getState().counters.counter1)).toBe( + true, + ) expect(getByTestId('value').textContent).toBe('0') @@ -861,5 +875,3 @@ describe('useSignalSelector', () => { }) }) }) - - diff --git a/test/signals/zombieChildren.spec.tsx b/test/signals/zombieChildren.spec.tsx index c6778032d..8c632a576 100644 --- a/test/signals/zombieChildren.spec.tsx +++ b/test/signals/zombieChildren.spec.tsx @@ -172,9 +172,7 @@ describe('zombie children', () => { it('a throwing selector on a component that stays mounted reaches an error boundary', () => { const store = makeStore() - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) function StubbornWatcher() { // Not conditionally rendered by any parent — stays mounted after @@ -208,9 +206,7 @@ describe('zombie children', () => { it('other components are still notified when one selector throws', () => { const store = makeStore() - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) function StubbornWatcher() { const name = useSignalSelector( @@ -242,7 +238,9 @@ describe('zombie children', () => { }) expect(rtl.screen.getByTestId('count').textContent).toBe('1') - expect(rtl.screen.getByTestId('boundary')).toBeDefined() + expect(rtl.screen.getByTestId('boundary').textContent).toMatch( + /Cannot read properties of undefined/, + ) consoleSpy.mockRestore() }) }) From cc02981ce9ad3dfbcf191d3fb087ef559570d609 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 16 Aug 2026 01:27:46 -0400 Subject: [PATCH 55/95] Add Reselect interop tests for the signals implementation - Add reselect as a devDependency - New test/signals/reselect.spec.tsx running in both modes: weakMapMemoize cache hits must not wipe dependency tracking, exact render/recompute counts for memoized and layered selectors, lruMemoize parity, shared derived object references across components --- package.json | 1 + test/signals/reselect.spec.tsx | 412 +++++++++++++++++++++++++++++++++ 2 files changed, 413 insertions(+) create mode 100644 test/signals/reselect.spec.tsx diff --git a/package.json b/package.json index 9d7cfc6ce..01c702d79 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "redux": "^5.0.1", + "reselect": "^5.2.0", "rimraf": "^6.1.3", "tsdown": "^0.22.14", "typescript": "^6.0.3", diff --git a/test/signals/reselect.spec.tsx b/test/signals/reselect.spec.tsx new file mode 100644 index 000000000..d582284e9 --- /dev/null +++ b/test/signals/reselect.spec.tsx @@ -0,0 +1,412 @@ +/** + * Reselect interop. Runs against both implementations via the + * `react-redux` alias in vitest.config.mts. + * + * The interesting interaction is in signals mode: `weakMapMemoize` + * (Reselect 5's default) caches on the state argument's identity, and + * the signals implementation passes a tracking proxy as that argument. + * The proxy for a given state object is cached and reused, so when the + * hook re-runs a memoized selector with an unchanged state, Reselect + * short-circuits WITHOUT touching any state properties. Dependency + * tracking must survive those cache-hit runs — a hook must never end up + * with an empty dependency set just because one of its evaluations was + * a memoization hit. + */ +import { configureStore } from '@reduxjs/toolkit' +import * as rtl from '@testing-library/react' +import React from 'react' +import { Provider, useSelector } from 'react-redux' +import { createSelector, lruMemoize, weakMapMemoize } from 'reselect' +import { afterEach, describe, expect, it } from 'vitest' + +interface Todo { + id: number + text: string + completed: boolean +} + +interface RootState { + todos: Todo[] + counter: { value: number } + filter: { showCompleted: boolean } +} + +const initialTodos: Todo[] = [ + { id: 1, text: 'write tests', completed: false }, + { id: 2, text: 'review code', completed: true }, +] + +const makeStore = () => + configureStore({ + reducer: { + todos: (state: Todo[] = initialTodos, action: any) => { + switch (action.type) { + case 'toggle': + return state.map((t) => + t.id === action.id ? { ...t, completed: !t.completed } : t, + ) + case 'add': + return [ + ...state, + { id: action.id, text: action.text, completed: false }, + ] + case 'editText': + return state.map((t) => + t.id === action.id ? { ...t, text: action.text } : t, + ) + default: + return state + } + }, + counter: (state = { value: 0 }, action: any) => + action.type === 'increment' ? { value: state.value + 1 } : state, + filter: (state = { showCompleted: true }, action: any) => + action.type === 'setFilter' ? { showCompleted: action.payload } : state, + }, + }) + +afterEach(() => { + rtl.cleanup() +}) + +describe('createSelector with the default weakMapMemoize', () => { + it('re-renders on relevant changes and skips irrelevant ones', () => { + const store = makeStore() + + const selectCompletedCount = createSelector( + [(s: RootState) => s.todos], + (todos) => todos.filter((t) => t.completed).length, + ) + + let renderCount = 0 + function CompletedCount() { + renderCount++ + const count = useSelector(selectCompletedCount) + return
{count}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + expect(getByTestId('count').textContent).toBe('1') + expect(renderCount).toBe(1) + + // Irrelevant slice: no re-render. + rtl.act(() => { + store.dispatch({ type: 'increment' }) + }) + expect(renderCount).toBe(1) + + // Relevant slice: exactly one re-render with the new derived value. + rtl.act(() => { + store.dispatch({ type: 'toggle', id: 1 }) + }) + expect(getByTestId('count').textContent).toBe('2') + expect(renderCount).toBe(2) + }) + + it('does not recompute the result function for unrelated dispatches', () => { + const store = makeStore() + + let resultRuns = 0 + const selectTodoTexts = createSelector( + [(s: RootState) => s.todos], + (todos) => { + resultRuns++ + return todos.map((t) => t.text) + }, + ) + + function Texts() { + const texts = useSelector(selectTodoTexts) + return
{texts.join(',')}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + expect(getByTestId('texts').textContent).toBe('write tests,review code') + + const runsAfterMount = resultRuns + expect(runsAfterMount).toBe(1) + + // The counter slice is not an input; the todos array reference is + // unchanged, so the result function must not run again no matter + // how many times the wrapper selector itself is evaluated. + rtl.act(() => { + store.dispatch({ type: 'increment' }) + }) + rtl.act(() => { + store.dispatch({ type: 'increment' }) + }) + expect(resultRuns).toBe(runsAfterMount) + + rtl.act(() => { + store.dispatch({ type: 'add', id: 3, text: 'ship it' }) + }) + expect(getByTestId('texts').textContent).toBe( + 'write tests,review code,ship it', + ) + expect(resultRuns).toBe(runsAfterMount + 1) + }) + + it('keeps delivering updates after memoization cache hits', () => { + // A memoization hit skips the selector body entirely — no state + // properties are touched on that evaluation. If the implementation + // rebuilt its dependency set from such a run, the hook would go + // deaf. Force extra evaluations with unchanged state (parent + // re-renders), then prove a relevant dispatch still lands. + const store = makeStore() + + const selectFirstTodoText = createSelector( + [(s: RootState) => s.todos], + (todos) => todos[0].text, + ) + + function Text() { + const text = useSelector(selectFirstTodoText) + return
{text}
+ } + + function App({ tick }: { tick: number }) { + return ( + +
{tick}
+ +
+ ) + } + + const { getByTestId, rerender } = rtl.render() + expect(getByTestId('text').textContent).toBe('write tests') + + // Same store state, new renders: every selector evaluation here is + // a weakMapMemoize cache hit. + rerender() + rerender() + expect(getByTestId('text').textContent).toBe('write tests') + + rtl.act(() => { + store.dispatch({ type: 'toggle', id: 1 }) + }) + // toggle replaces the array and the todo object but keeps `text`; + // the derived value is unchanged. + expect(getByTestId('text').textContent).toBe('write tests') + + // A real change to the derived value must still come through — this + // is the assertion that fails if cache-hit evaluations wiped the + // hook's dependency tracking. + rtl.act(() => { + store.dispatch({ type: 'editText', id: 1, text: 'ship it' }) + }) + expect(getByTestId('text').textContent).toBe('ship it') + }) + + it('returns a stable derived object reference across components', () => { + const store = makeStore() + + const selectVisibleTodos = createSelector( + [(s: RootState) => s.todos, (s: RootState) => s.filter.showCompleted], + (todos, showCompleted) => + showCompleted ? todos : todos.filter((t) => !t.completed), + ) + + const seenA: Todo[][] = [] + const seenB: Todo[][] = [] + + function ListA() { + seenA.push(useSelector(selectVisibleTodos)) + return null + } + function ListB() { + seenB.push(useSelector(selectVisibleTodos)) + return null + } + + rtl.render( + + + + , + ) + + // Both components got the same object from the shared memoized + // selector, not two structurally-equal copies. + expect(seenA.at(-1)).toBe(seenB.at(-1)) + + rtl.act(() => { + store.dispatch({ type: 'setFilter', payload: false }) + }) + expect(seenA.at(-1)).toEqual([initialTodos[0]]) + expect(seenA.at(-1)).toBe(seenB.at(-1)) + }) + + it('re-renders exactly once when a derived object changes, with shared inputs', () => { + const store = makeStore() + + const selectIncomplete = createSelector( + [(s: RootState) => s.todos], + (todos) => todos.filter((t) => !t.completed), + ) + + let renderCount = 0 + const results: Todo[][] = [] + function Incomplete() { + renderCount++ + const todos = useSelector(selectIncomplete) + results.push(todos) + return
{todos.length}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + expect(getByTestId('incomplete').textContent).toBe('1') + expect(renderCount).toBe(1) + + rtl.act(() => { + store.dispatch({ type: 'toggle', id: 2 }) + }) + expect(getByTestId('incomplete').textContent).toBe('2') + expect(renderCount).toBe(2) + expect(results.at(-1)).not.toBe(results[0]) + }) +}) + +describe('createSelector with lruMemoize', () => { + it('behaves the same as weakMapMemoize for a single subscriber', () => { + const store = makeStore() + + let resultRuns = 0 + const selectCounterDouble = createSelector( + [(s: RootState) => s.counter.value], + (value) => { + resultRuns++ + return value * 2 + }, + { + memoize: lruMemoize, + argsMemoize: lruMemoize, + // Reselect's own dev-mode identity check calls the result + // function one extra time on the first invocation; disable it + // so the run counts below stay exact. + devModeChecks: { identityFunctionCheck: 'never' }, + }, + ) + + let renderCount = 0 + function Double() { + renderCount++ + const doubled = useSelector(selectCounterDouble) + return
{doubled}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + expect(getByTestId('double').textContent).toBe('0') + expect(resultRuns).toBe(1) + + rtl.act(() => { + store.dispatch({ type: 'toggle', id: 1 }) + }) + expect(renderCount).toBe(1) + expect(resultRuns).toBe(1) + + rtl.act(() => { + store.dispatch({ type: 'increment' }) + }) + expect(getByTestId('double').textContent).toBe('2') + expect(renderCount).toBe(2) + expect(resultRuns).toBe(2) + }) +}) + +describe('layered memoized selectors', () => { + it('a selector composed from other memoized selectors updates correctly', () => { + const store = makeStore() + + const selectTodos = (s: RootState) => s.todos + const selectCompleted = createSelector([selectTodos], (todos) => + todos.filter((t) => t.completed), + ) + const selectCompletedCount = createSelector( + [selectCompleted], + (completed) => completed.length, + ) + const selectSummary = createSelector( + [selectTodos, selectCompletedCount], + (all, completedCount) => `${completedCount}/${all.length}`, + ) + + let renderCount = 0 + function Summary() { + renderCount++ + const summary = useSelector(selectSummary) + return
{summary}
+ } + + const { getByTestId } = rtl.render( + +
+ , + ) + expect(getByTestId('summary').textContent).toBe('1/2') + expect(renderCount).toBe(1) + + rtl.act(() => { + store.dispatch({ type: 'increment' }) + }) + expect(renderCount).toBe(1) + + rtl.act(() => { + store.dispatch({ type: 'toggle', id: 1 }) + }) + expect(getByTestId('summary').textContent).toBe('2/2') + expect(renderCount).toBe(2) + + rtl.act(() => { + store.dispatch({ type: 'add', id: 3, text: 'third' }) + }) + expect(getByTestId('summary').textContent).toBe('2/3') + expect(renderCount).toBe(3) + }) + + it('an explicitly weakMapMemoize-configured selector works with per-item lookups', () => { + const store = makeStore() + + const selectTodoById = createSelector( + [(s: RootState) => s.todos, (_s: RootState, id: number) => id], + (todos, id) => todos.find((t) => t.id === id), + { memoize: weakMapMemoize, argsMemoize: weakMapMemoize }, + ) + + function TodoText({ id }: { id: number }) { + const todo = useSelector((s: RootState) => selectTodoById(s, id)) + return
{todo?.text}
+ } + + const { getByTestId } = rtl.render( + + + + , + ) + expect(getByTestId('todo-1').textContent).toBe('write tests') + expect(getByTestId('todo-2').textContent).toBe('review code') + + rtl.act(() => { + store.dispatch({ type: 'toggle', id: 1 }) + }) + expect(getByTestId('todo-1').textContent).toBe('write tests') + expect(getByTestId('todo-2').textContent).toBe('review code') + }) +}) From 8cf23db230caccb7629d1482884ef517bd61c6a5 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 16 Aug 2026 01:33:30 -0400 Subject: [PATCH 56/95] Add react-redux/signals entry point - New src/index-signals.ts: presents SignalProvider/useSignalSelector under the stock names Provider/useSelector/createSelectorHook so a bundler alias swaps an app onto the signals implementation - Build outputs for all formats (modern ESM, legacy ESM, browser, CJS dev/prod) plus dist/cjs/signals.js env switcher - package.json exports map entry for './signals' --- package.json | 5 +++++ src/index-signals.ts | 29 +++++++++++++++++++++++++++++ tsdown.config.mts | 14 ++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 src/index-signals.ts diff --git a/package.json b/package.json index 01c702d79..c5ab5e4ac 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,11 @@ "types": "./dist/react-redux.d.ts", "import": "./dist/react-redux.mjs", "default": "./dist/cjs/index.js" + }, + "./signals": { + "types": "./dist/react-redux-signals.d.ts", + "import": "./dist/react-redux-signals.mjs", + "default": "./dist/cjs/signals.js" } }, "sideEffects": false, diff --git a/src/index-signals.ts b/src/index-signals.ts new file mode 100644 index 000000000..87fe0677b --- /dev/null +++ b/src/index-signals.ts @@ -0,0 +1,29 @@ +/** + * `react-redux/signals` entry point. + * + * Presents the signals implementation under the stock public names, so a + * bundler alias can swap an entire app onto the signals implementation + * without touching application code: + * + * ```js + * // vite.config.js + * export default defineConfig({ + * resolve: { + * alias: { 'react-redux': 'react-redux/signals' }, + * }, + * }) + * ``` + * + * Everything else (`connect`, `useDispatch`, `useStore`, types) is + * re-exported unchanged from the main entry. The explicit exports below + * shadow the corresponding names from `export *`. + */ +export * from './index' + +export { + SignalProvider as Provider, + useSignalSelector as useSelector, + createSignalSelectorHook as createSelectorHook, +} from './signals' + +export type { SignalProviderProps as ProviderProps } from './signals' diff --git a/tsdown.config.mts b/tsdown.config.mts index 78455f6ac..605b5249b 100644 --- a/tsdown.config.mts +++ b/tsdown.config.mts @@ -11,6 +11,15 @@ if (process.env.NODE_ENV === 'production') { module.exports = require('./react-redux.production.min.cjs') } else { module.exports = require('./react-redux.development.cjs') +}`, + ) + await fs.writeFile( + path.join('dist/cjs/', 'signals.js'), + `'use strict' +if (process.env.NODE_ENV === 'production') { + module.exports = require('./react-redux-signals.production.min.cjs') +} else { + module.exports = require('./react-redux-signals.development.cjs') }`, ) } @@ -21,6 +30,7 @@ export default defineConfig((options): UserConfig[] => { const commonOptions = { entry: { 'react-redux': 'src/index.ts', + 'react-redux-signals': 'src/index-signals.ts', }, sourcemap: true, // `pnpm clean` already removes `dist/`; letting each of the seven builds @@ -59,6 +69,7 @@ export default defineConfig((options): UserConfig[] => { name: 'Legacy ESM, Webpack 4', entry: { 'react-redux.legacy-esm': 'src/index.ts', + 'react-redux-signals.legacy-esm': 'src/index-signals.ts', }, target: ['es2017'], format: ['esm'], @@ -71,6 +82,7 @@ export default defineConfig((options): UserConfig[] => { name: 'Browser-ready ESM', entry: { 'react-redux.browser': 'src/index.ts', + 'react-redux-signals.browser': 'src/index-signals.ts', }, platform: 'browser', env: { @@ -85,6 +97,7 @@ export default defineConfig((options): UserConfig[] => { name: 'CJS Development', entry: { 'react-redux.development': 'src/index.ts', + 'react-redux-signals.development': 'src/index-signals.ts', }, env: { NODE_ENV: 'development', @@ -98,6 +111,7 @@ export default defineConfig((options): UserConfig[] => { name: 'CJS production', entry: { 'react-redux.production.min': 'src/index.ts', + 'react-redux-signals.production.min': 'src/index-signals.ts', }, env: { NODE_ENV: 'production', From 16f9878d7038eb640450eb38e0a131e07a661996 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Wed, 26 Aug 2026 22:11:39 -0400 Subject: [PATCH 57/95] Add mutable-state interop tests pinning behavior with non-immutable slices - in-place mutation: silent staleness, recovery via honest change or coarse promotion - non-plain values (Map/class): ref-level gating semantics - selectors that mutate: proxy TypeErrors, and the mount-time probe gap where a sorting selector silently mutates real store state before promotion - rAF-style batched notification pattern --- test/signals/mutableStateInterop.spec.tsx | 507 ++++++++++++++++++++++ 1 file changed, 507 insertions(+) create mode 100644 test/signals/mutableStateInterop.spec.tsx diff --git a/test/signals/mutableStateInterop.spec.tsx b/test/signals/mutableStateInterop.spec.tsx new file mode 100644 index 000000000..dc839c71e --- /dev/null +++ b/test/signals/mutableStateInterop.spec.tsx @@ -0,0 +1,507 @@ +/** + * Behavior-pinning tests for non-idiomatic store patterns reported from the + * Slack field trial (2026-08) and the original 2023 Slack perf call: + * + * 1. Reducers that mutate state in place ("mutable slices" with clever + * tricks to make Redux behave). The diff relies on referential + * inequality, so these tests pin exactly which mutations go silently + * stale, which recover, and when. + * 2. Non-plain values (Map, class instances) in state. These are tracked + * by reference only. + * 3. Selectors that mutate state (e.g. calling .sort() directly on a state + * array). The tracking proxy rejects the write; these tests pin the + * error the user actually sees. + * 4. Batched store notifications (Slack batches subscriber notification + * via rAF). Several dispatches followed by one notification must diff + * cumulatively and render once. + * + * These tests document CURRENT behavior, including known staleness + * hazards. If we add a dev-mode "slice is not diffable / appears mutated" + * warning later, these are the scenarios it must cover. + */ +import * as rtl from '@testing-library/react' +import React from 'react' +import type { Reducer, Store } from 'redux' +import { legacy_createStore as createStore } from 'redux' +import { describe, expect, it, vi } from 'vitest' +import { + createPathSignalRegistry, + createTrackingProxy, + SignalProvider, + useSignalSelector, +} from '../../src/signals' +import { alienEngine } from '../../src/signals/engine' + +describe('in-place mutation (mutable slices)', () => { + interface MutState { + slice: { data: { value: number }; label: string } + other: { count: number } + } + + type MutAction = + | { type: 'mutateInPlace' } + | { type: 'mutateChildCloneSliceRoot' } + | { type: 'honestSliceChange' } + | { type: 'honestOtherChange' } + | { type: 'init' } + + const makeMutableStore = () => { + const initial: MutState = { + slice: { data: { value: 1 }, label: 'a' }, + other: { count: 0 }, + } + const reducer: Reducer = (state = initial, action) => { + switch (action.type) { + case 'mutateInPlace': + // Mutates a nested value and returns the SAME root reference. + state.slice.data.value += 1 + return state + case 'mutateChildCloneSliceRoot': + // Mutates the child object, then clones the slice root — the + // "clever tricks" shape: root and slice refs change, but the + // mutated child is the same object in prev and next. + state.slice.data.value += 1 + return { ...state, slice: { ...state.slice } } + case 'honestSliceChange': + return { + ...state, + slice: { + ...state.slice, + data: { value: state.slice.data.value + 1 }, + }, + } + case 'honestOtherChange': + return { ...state, other: { count: state.other.count + 1 } } + default: + return state + } + } + return createStore(reducer) + } + + const renderValueReader = (store: Store) => { + let renders = 0 + function ValueReader() { + renders++ + const value = useSignalSelector((s: MutState) => s.slice.data.value) + return
{value}
+ } + const utils = rtl.render( + + + , + ) + return { ...utils, getRenders: () => renders } + } + + it('same-ref root mutation: no signals fire, the component goes silently stale', () => { + const store = makeMutableStore() + const { getByTestId, getRenders } = renderValueReader(store) + expect(getByTestId('value').textContent).toBe('1') + const rendersAfterMount = getRenders() + + rtl.act(() => { + store.dispatch({ type: 'mutateInPlace' }) + }) + + // The store has the new value, but prev === next made the diff skip + // everything: no re-render, stale UI. + expect(store.getState().slice.data.value).toBe(2) + expect(getByTestId('value').textContent).toBe('1') + expect(getRenders()).toBe(rendersAfterMount) + }) + + it('a later honest change to the same path recovers with the correct value', () => { + const store = makeMutableStore() + const { getByTestId } = renderValueReader(store) + + rtl.act(() => { + store.dispatch({ type: 'mutateInPlace' }) // -> 2, silently stale + }) + expect(getByTestId('value').textContent).toBe('1') + + rtl.act(() => { + store.dispatch({ type: 'honestSliceChange' }) // -> 3, new refs + }) + // The provider's prev-state snapshot was mutated along with the store + // (same object graph), so the diff compares 2 vs 3 and fires normally. + expect(getByTestId('value').textContent).toBe('3') + }) + + it('an unrelated dispatch never catches a same-ref mutation', () => { + const store = makeMutableStore() + const { getByTestId } = renderValueReader(store) + + rtl.act(() => { + store.dispatch({ type: 'mutateInPlace' }) + }) + rtl.act(() => { + store.dispatch({ type: 'honestOtherChange' }) + }) + // The mutation happened to the shared prev/next object graph, so no + // later diff can observe it: still stale. + expect(store.getState().slice.data.value).toBe(2) + expect(getByTestId('value').textContent).toBe('1') + }) + + it('mutate-child-clone-root: a NOT-yet-promoted hook recovers via coarse promotion', () => { + const store = makeMutableStore() + const { getByTestId } = renderValueReader(store) + expect(getByTestId('value').textContent).toBe('1') + + // First dispatch after mount: the slice root ref changed, so the + // changed-root-keys wake the coarse subscriber, which promotes and + // runs the selector fresh against the new state — it sees the + // mutated value even though no deep signal fired. + rtl.act(() => { + store.dispatch({ type: 'mutateChildCloneSliceRoot' }) + }) + expect(getByTestId('value').textContent).toBe('2') + }) + + it('mutate-child-clone-root: an already-promoted hook goes silently stale', () => { + const store = makeMutableStore() + const { getByTestId } = renderValueReader(store) + + // Promote the hook out of the coarse tier with an honest change first. + rtl.act(() => { + store.dispatch({ type: 'honestSliceChange' }) // -> 2 + }) + expect(getByTestId('value').textContent).toBe('2') + + rtl.act(() => { + store.dispatch({ type: 'mutateChildCloneSliceRoot' }) // -> 3 + }) + // The slice root ref changed so the diff descends, but the mutated + // child is the same object in prev and next: the leaf signal never + // fires and the promoted hook is stale. + expect(store.getState().slice.data.value).toBe(3) + expect(getByTestId('value').textContent).toBe('2') + }) +}) + +describe('non-plain values in state (Map, class instances)', () => { + interface LookupState { + lookup: Map + other: { count: number } + } + type LookupAction = + | { type: 'replaceMap'; value: string } + | { type: 'mutateMap'; value: string } + | { type: 'bumpOther' } + | { type: 'init' } + + const makeLookupStore = () => { + const initial: LookupState = { + lookup: new Map([['a', 'one']]), + other: { count: 0 }, + } + const reducer: Reducer = ( + state = initial, + action, + ) => { + switch (action.type) { + case 'replaceMap': + return { ...state, lookup: new Map([['a', action.value]]) } + case 'mutateMap': + state.lookup.set('a', action.value) + return { ...state, other: { ...state.other } } + case 'bumpOther': + return { ...state, other: { count: state.other.count + 1 } } + default: + return state + } + } + return createStore(reducer) + } + + const renderMapReader = (store: Store) => { + let selectorCalls = 0 + let renders = 0 + const selectA = (s: LookupState) => { + selectorCalls++ + return s.lookup.get('a') + } + function MapReader() { + renders++ + const value = useSignalSelector(selectA) + return
{value}
+ } + const utils = rtl.render( + + + , + ) + return { + ...utils, + getSelectorCalls: () => selectorCalls, + getRenders: () => renders, + } + } + + it('replacing a Map re-renders the reader exactly once', () => { + const store = makeLookupStore() + const { getByTestId, getRenders } = renderMapReader(store) + expect(getByTestId('value').textContent).toBe('one') + const rendersAfterMount = getRenders() + + rtl.act(() => { + store.dispatch({ type: 'replaceMap', value: 'two' }) + }) + // The Map is tracked by reference: a ref swap fires its leaf signal. + expect(getByTestId('value').textContent).toBe('two') + expect(getRenders()).toBe(rendersAfterMount + 1) + }) + + it('mutating a Map in place goes silently stale', () => { + const store = makeLookupStore() + const { getByTestId } = renderMapReader(store) + + // Promote first so the deep graph exists before the mutation. + rtl.act(() => { + store.dispatch({ type: 'replaceMap', value: 'two' }) + }) + expect(getByTestId('value').textContent).toBe('two') + + rtl.act(() => { + store.dispatch({ type: 'mutateMap', value: 'three' }) + }) + // Same Map reference: the diff treats it as an unchanged leaf. + expect(store.getState().lookup.get('a')).toBe('three') + expect(getByTestId('value').textContent).toBe('two') + }) + + it('ref-level gating stays precise: unrelated dispatches do not re-run a Map reader', () => { + const store = makeLookupStore() + const { getSelectorCalls, getRenders } = renderMapReader(store) + + // Promote out of the coarse tier. + rtl.act(() => { + store.dispatch({ type: 'replaceMap', value: 'two' }) + }) + const callsAfterPromotion = getSelectorCalls() + const rendersAfterPromotion = getRenders() + + rtl.act(() => { + store.dispatch({ type: 'bumpOther' }) + }) + // The Map's ref signal did not change: no selector run, no render. + expect(getSelectorCalls()).toBe(callsAfterPromotion) + expect(getRenders()).toBe(rendersAfterPromotion) + }) + + it('class instances are tracked by reference: replacement fires, equal-content replacement re-runs without re-rendering', () => { + class Session { + constructor(public userName: string) {} + } + interface SessionState { + session: Session + } + type SessionAction = { type: 'setSession'; name: string } | { type: 'init' } + const reducer: Reducer = ( + state = { session: new Session('alice') }, + action, + ) => + action.type === 'setSession' + ? { session: new Session(action.name) } + : state + const store = createStore(reducer) + + let renders = 0 + const selectName = (s: SessionState) => s.session.userName + function NameReader() { + renders++ + const name = useSignalSelector(selectName) + return
{name}
+ } + const { getByTestId } = rtl.render( + + + , + ) + expect(getByTestId('name').textContent).toBe('alice') + + rtl.act(() => { + store.dispatch({ type: 'setSession', name: 'bob' }) + }) + expect(getByTestId('name').textContent).toBe('bob') + const rendersAfterChange = renders + + // New instance, same derived value: the ref signal fires and the + // selector recomputes, but the equal result stops the render. + rtl.act(() => { + store.dispatch({ type: 'setSession', name: 'bob' }) + }) + expect(getByTestId('name').textContent).toBe('bob') + expect(renders).toBe(rendersAfterChange) + }) +}) + +describe('selectors that mutate state', () => { + const buildProxy = () => { + const registry = createPathSignalRegistry(alienEngine) + const state = { + items: [3, 1, 2], + user: { name: 'alice' }, + } + return createTrackingProxy(state, '', registry, registry.proxyCache) + } + + it('calling .sort() on a state array throws a TypeError', () => { + const proxy = buildProxy() + // Array.prototype.sort writes back through the proxy's set trap, + // which rejects mutation. This raw TypeError is the current + // first-contact experience (it's what the Slack trial hit). + expect(() => proxy.items.sort()).toThrow(TypeError) + expect(() => proxy.items.sort()).toThrow(/'set' on proxy/) + }) + + it('calling .push() on a state array throws a TypeError', () => { + const proxy = buildProxy() + expect(() => proxy.items.push(4)).toThrow(/'set' on proxy/) + }) + + it('assigning a property throws a TypeError', () => { + const proxy = buildProxy() + expect(() => { + proxy.user.name = 'bob' + }).toThrow(/'set' on proxy/) + }) + + it('deleting a property throws a TypeError', () => { + const proxy = buildProxy() + expect(() => { + // @ts-expect-error -- deleting a required property on purpose + delete proxy.user.name + }).toThrow(/'deleteProperty' on proxy/) + }) + + it('Object.assign onto state throws a TypeError', () => { + const proxy = buildProxy() + expect(() => Object.assign(proxy.user, { name: 'bob' })).toThrow( + /'set' on proxy/, + ) + }) + + it('a sorting selector mutates real state at mount (coarse probe), then throws on promotion', () => { + interface ItemsState { + items: number[] + } + type ItemsAction = { type: 'setItems'; items: number[] } | { type: 'init' } + const reducer: Reducer = ( + state = { items: [3, 1, 2] }, + action, + ) => (action.type === 'setItems' ? { items: action.items } : state) + const store = createStore(reducer) + + class Boundary extends React.Component< + { children: React.ReactNode }, + { message: string | null } + > { + state = { message: null as string | null } + static getDerivedStateFromError(err: Error) { + return { message: err.message } + } + render() { + return this.state.message ? ( +
{this.state.message}
+ ) : ( + this.props.children + ) + } + } + + function SortedItems() { + const sorted = useSignalSelector((s: ItemsState) => s.items.sort()) + return
{sorted.join(',')}
+ } + + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const { getByTestId } = rtl.render( + + + + + , + ) + // At mount the hook is in the coarse tier: the probe proxy only + // wraps the top level, so `s.items` is the RAW array and sort() + // silently mutates real store state. No error — corrupted state. + expect(getByTestId('items').textContent).toBe('1,2,3') + expect(store.getState().items).toEqual([1, 2, 3]) + + // A dispatch touching the segment promotes the hook to the deep + // tracking proxy, whose set trap rejects the write. + rtl.act(() => { + store.dispatch({ type: 'setItems', items: [9, 7, 8] }) + }) + expect(getByTestId('boundary').textContent).toMatch(/'set' on proxy/) + } finally { + spy.mockRestore() + } + }) +}) + +describe('batched store notifications (rAF-style)', () => { + interface CounterState { + counter: { count: number } + other: { flag: boolean } + } + type CounterAction = { type: 'increment' } | { type: 'init' } + + it('several dispatches followed by one notification diff cumulatively and render once', () => { + const reducer: Reducer = ( + state = { counter: { count: 0 }, other: { flag: false } }, + action, + ) => + action.type === 'increment' + ? { ...state, counter: { count: state.counter.count + 1 } } + : state + + const inner = createStore(reducer) + // Slack batches subscriber notification behind rAF. Model that with a + // store wrapper whose subscribe defers listener calls until flush(). + let listeners: Array<() => void> = [] + const store = { + ...inner, + subscribe(listener: () => void) { + listeners.push(listener) + return () => { + listeners = listeners.filter((l) => l !== listener) + } + }, + } as Store + const flush = () => listeners.forEach((l) => l()) + + let renders = 0 + function Counter() { + renders++ + const count = useSignalSelector((s: CounterState) => s.counter.count) + return
{count}
+ } + const { getByTestId } = rtl.render( + + + , + ) + expect(getByTestId('count').textContent).toBe('0') + const rendersAfterMount = renders + + rtl.act(() => { + store.dispatch({ type: 'increment' }) + store.dispatch({ type: 'increment' }) + store.dispatch({ type: 'increment' }) + }) + // No notification yet: nothing rendered. + expect(getByTestId('count').textContent).toBe('0') + expect(renders).toBe(rendersAfterMount) + + rtl.act(() => { + flush() + }) + // One notification diffs 0 -> 3 in a single pass: exactly one render. + expect(getByTestId('count').textContent).toBe('3') + expect(renders).toBe(rendersAfterMount + 1) + }) +}) From 4e25b073ba5a586d41f73200b8f8e5a9db8365c9 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Wed, 26 Aug 2026 23:32:19 -0400 Subject: [PATCH 58/95] Reject selector writes to state consistently in both tiers - probe proxy wraps nested objects in dev-only write guards, so a mutating selector throws at mount instead of silently corrupting store state before promotion - all write traps throw a named error ('selectors must not mutate state') instead of the engine's 'trap returned falsish' TypeError - probe proxy now uses an unfrozen shell target so the guard-returning get trap satisfies ES Proxy invariants on frozen state - fix reducer state type in reselect.spec.tsx typecheck --- src/signals/arrayMethodOverrides.ts | 9 +- src/signals/coarseSegments.ts | 149 +++++++++++++++++++--- src/signals/mutationError.ts | 32 +++++ src/signals/trackingProxy.ts | 9 +- test/signals/coarseSegments.spec.ts | 28 +++- test/signals/mutableStateInterop.spec.tsx | 73 ++++++++--- test/signals/reselect.spec.tsx | 2 +- 7 files changed, 251 insertions(+), 51 deletions(-) create mode 100644 src/signals/mutationError.ts diff --git a/src/signals/arrayMethodOverrides.ts b/src/signals/arrayMethodOverrides.ts index ed36ba75a..c8d27f2d6 100644 --- a/src/signals/arrayMethodOverrides.ts +++ b/src/signals/arrayMethodOverrides.ts @@ -1,3 +1,4 @@ +import { throwStateMutationError } from './mutationError' import type { PathSignalRegistry, StructureKind } from './pathSignalRegistry' import { registerRecordingHolder, unwrap } from './trackingProxy' @@ -161,11 +162,11 @@ function createScanRecorder(): ScanRecorder { getPrototypeOf() { return Object.getPrototypeOf(holder.current as object) }, - set() { - return false + set(_obj, prop) { + throwStateMutationError('set', String(prop)) }, - deleteProperty() { - return false + deleteProperty(_obj, prop) { + throwStateMutationError('delete', String(prop)) }, }) diff --git a/src/signals/coarseSegments.ts b/src/signals/coarseSegments.ts index b039e2450..f108fc011 100644 --- a/src/signals/coarseSegments.ts +++ b/src/signals/coarseSegments.ts @@ -1,3 +1,4 @@ +import { throwStateMutationError } from './mutationError' import { registerProxyTarget } from './trackingProxy' /** @@ -117,6 +118,100 @@ export interface ProbeRecord { enumerated: boolean } +function isGuardable(value: unknown): value is object { + if (value === null || typeof value !== 'object') return false + if (Array.isArray(value)) return true + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +// Guards are cached by target identity so a probe run wraps each object +// once. Module-level: guards carry no per-probe state, and `untrackResult` +// strips them from results regardless of which probe created them. +const guardCache = new WeakMap() + +/** + * Dev-only read-only wrapper for nested objects handed out by the probe + * proxy. The deep tracking proxy rejects selector writes, but the probe + * returns nested values raw — without this guard, a mutating selector + * (e.g. `s => s.items.sort()`) silently corrupts real store state during + * the mount evaluation and only starts throwing after promotion. + * + * The guard records nothing (the probe already recorded the top-level + * segment); it exists purely to make write rejection consistent across + * both tiers. Plain objects and arrays only: wrapping Date/Map/Set/class + * instances breaks methods that need internal slots, and the deep tier + * returns those raw too. + * + * The proxy target is an unfrozen shell (same trick as the deep tracking + * proxy): the get trap returns nested guards, which would violate ES + * Proxy invariants on a frozen target's non-configurable properties. + * + * Registered in the proxy→target map so `untrackResult` and `unwrap` + * strip guards from selector results. + * @param target - The raw nested state object to guard + * @returns A proxy that reads through to `target` and throws on writes + */ +function createWriteGuard(target: T): T { + const cached = guardCache.get(target) + if (cached) return cached as T + + const shell = Array.isArray(target) + ? [] + : Object.create(Object.getPrototypeOf(target)) + + const proxy = new Proxy(shell as T, { + get(_obj, prop) { + if (typeof prop === 'symbol') return Reflect.get(target, prop) + const value = (target as Record)[prop] + return isGuardable(value) ? createWriteGuard(value) : value + }, + + has(_obj, prop) { + return Reflect.has(target, prop) + }, + + ownKeys(_obj) { + return Reflect.ownKeys(target) + }, + + getOwnPropertyDescriptor(_obj, prop) { + const desc = Object.getOwnPropertyDescriptor(target, prop) + // Frozen state props are non-configurable; the shell doesn't have + // them, so report configurable to satisfy proxy invariants. + return desc ? { ...desc, configurable: true } : desc + }, + + getPrototypeOf(_obj) { + return Object.getPrototypeOf(target) + }, + + isExtensible(_obj) { + return Object.isExtensible(target) + }, + + set(_obj, prop) { + throwStateMutationError('set', String(prop)) + }, + + deleteProperty(_obj, prop) { + throwStateMutationError('delete', String(prop)) + }, + + defineProperty(_obj, prop) { + throwStateMutationError('defineProperty', String(prop)) + }, + + setPrototypeOf() { + throwStateMutationError('setPrototypeOf', '') + }, + }) as T + + guardCache.set(target, proxy) + registerProxyTarget(proxy as object, target) + return proxy +} + /** * Create a one-level-shallow probe proxy over the root state object. * @@ -124,11 +219,12 @@ export interface ProbeRecord { * - records only WHICH top-level keys the selector reads (no signals, * no path strings, no nested proxies) * - returns raw nested values, so the selector runs at native speed - * below the first level + * below the first level (in production; dev builds wrap nested + * objects in write guards so mutating selectors throw at mount too) * - * The proxy target is the real (frozen) state object and every trap - * returns the target's own values, so no ES Proxy invariants are - * violated and no shell object is needed. + * The proxy target is an unfrozen shell so the dev-mode get trap can + * return write guards without violating ES Proxy invariants on frozen + * state; the other traps read through to the real state object. * * The proxy is registered in the proxy→target map so `untrackResult` * and `unwrap` strip it from selector results (`s => s`, @@ -145,38 +241,55 @@ export function createProbeProxy( ): { proxy: T; record: ProbeRecord } { const record: ProbeRecord = { segments: new Set(), enumerated: false } - const proxy = new Proxy(state, { - get(target, prop) { - if (typeof prop === 'symbol') return Reflect.get(target, prop) + const shell = Object.create(Object.getPrototypeOf(state)) as T + + const proxy = new Proxy(shell, { + get(_obj, prop) { + if (typeof prop === 'symbol') return Reflect.get(state, prop) record.segments.add(prop) - return (target as Record)[prop] + const value = (state as Record)[prop] + if (process.env.NODE_ENV !== 'production' && isGuardable(value)) { + return createWriteGuard(value) + } + return value }, - has(target, prop) { + has(_obj, prop) { if (typeof prop !== 'symbol') record.segments.add(prop) - return Reflect.has(target, prop) + return Reflect.has(state, prop) }, - ownKeys(target) { + ownKeys(_obj) { record.enumerated = true - return Reflect.ownKeys(target) + return Reflect.ownKeys(state) }, // Fires for Object.getOwnPropertyDescriptor and hasOwnProperty, and // per-key during enumeration (where `enumerated` is already set by // ownKeys). A single descriptor read reveals one key's existence and // value — record it as a segment read, like get/has. - getOwnPropertyDescriptor(target, prop) { + getOwnPropertyDescriptor(_obj, prop) { if (typeof prop !== 'symbol') record.segments.add(prop) - return Reflect.getOwnPropertyDescriptor(target, prop) + const desc = Object.getOwnPropertyDescriptor(state, prop) + // The shell target doesn't have the state's props; report them + // configurable to satisfy proxy invariants (matters for frozen state). + return desc ? { ...desc, configurable: true } : desc + }, + + getPrototypeOf(_obj) { + return Object.getPrototypeOf(state) + }, + + isExtensible(_obj) { + return Object.isExtensible(state) }, - set() { - return false + set(_obj, prop) { + throwStateMutationError('set', String(prop)) }, - deleteProperty() { - return false + deleteProperty(_obj, prop) { + throwStateMutationError('delete', String(prop)) }, }) as T diff --git a/src/signals/mutationError.ts b/src/signals/mutationError.ts new file mode 100644 index 000000000..38af394a5 --- /dev/null +++ b/src/signals/mutationError.ts @@ -0,0 +1,32 @@ +/** + * Throw a descriptive error for a selector attempting to write to Redux + * state through one of the read-only proxies (probe guard, deep tracking + * proxy, or array scan recorder). + * + * Every write trap throws instead of returning `false`: `return false` + * only throws in strict-mode callers (with the engine's cryptic + * "'set' on proxy: trap returned falsish"), and silently no-ops in + * sloppy-mode code. Mutating state from a selector is always a bug, so + * it should fail loudly with a message that names the property. + * @param op - The kind of write that was attempted + * @param path - Dot-separated state path, or a bare property name when + * the full path is unknown + */ +export function throwStateMutationError( + op: 'set' | 'delete' | 'defineProperty' | 'setPrototypeOf', + path: string, +): never { + const verb = + op === 'set' + ? `assign to property '${path}' of` + : op === 'delete' + ? `delete property '${path}' from` + : op === 'defineProperty' + ? `define property '${path}' on` + : 'change the prototype of' + throw new TypeError( + `[react-redux] A selector attempted to ${verb} the Redux state. ` + + `Selectors must not mutate state — treat state as read-only and ` + + `return new values instead.`, + ) +} diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index 8ff8b91d3..ffe8d1c27 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -10,6 +10,7 @@ import { isOverriddenArrayMethod, createArrayMethodInterceptor, } from './arrayMethodOverrides' +import { throwStateMutationError } from './mutationError' import type { PathSignalRegistry } from './pathSignalRegistry' function isObjectOrArray(v: unknown): v is object { @@ -415,11 +416,11 @@ export function createTrackingProxy( }, // Prevent mutation - set() { - return false + set(_obj, prop) { + throwStateMutationError('set', String(prop)) }, - deleteProperty() { - return false + deleteProperty(_obj, prop) { + throwStateMutationError('delete', String(prop)) }, }) as T diff --git a/test/signals/coarseSegments.spec.ts b/test/signals/coarseSegments.spec.ts index 94efda358..d8f20598c 100644 --- a/test/signals/coarseSegments.spec.ts +++ b/test/signals/coarseSegments.spec.ts @@ -135,11 +135,13 @@ describe('createProbeProxy', () => { list: Object.freeze([1, 2, 3]), }) - it('records top-level keys read through the proxy and returns raw values', () => { + it('records top-level keys read through the proxy and reads through to raw values', () => { const { proxy, record } = createProbeProxy(state) const a = proxy.a - expect(a).toBe(state.a) // raw value, not a nested proxy + // In dev, nested objects come back wrapped in a write guard that + // reads through to the raw object and unwraps to it. + expect(unwrap(a)).toBe(state.a) expect(a.value).toBe(1) expect(record.segments).toEqual(new Set(['a'])) @@ -183,10 +185,28 @@ describe('createProbeProxy', () => { const { proxy, record } = createProbeProxy(state) const copy = { ...proxy } - expect(copy.a).toBe(state.a) + expect(unwrap(copy.a)).toBe(state.a) expect(record.enumerated).toBe(true) }) + it('rejects writes at the top level and through nested dev write guards', () => { + const { proxy } = createProbeProxy(state) + + expect(() => { + ;(proxy as any).a = { value: 9 } + }).toThrow(/must not mutate state/) + expect(() => { + ;(proxy as any).a.value = 9 + }).toThrow(/assign to property 'value'/) + expect(() => proxy.list.slice().sort()).not.toThrow() + expect(() => (proxy.list as number[]).sort()).toThrow( + /must not mutate state/, + ) + // Frozen state was never touched. + expect(state.a.value).toBe(1) + expect(state.list).toEqual([1, 2, 3]) + }) + it('does not record symbol reads', () => { const { proxy, record } = createProbeProxy(state) @@ -219,7 +239,7 @@ describe('createProbeProxy', () => { const { proxy } = createProbeProxy(state) expect(() => { - (proxy as any).a = 42 + ;(proxy as any).a = 42 }).toThrow(TypeError) expect(() => { delete (proxy as any).a diff --git a/test/signals/mutableStateInterop.spec.tsx b/test/signals/mutableStateInterop.spec.tsx index dc839c71e..37df213cf 100644 --- a/test/signals/mutableStateInterop.spec.tsx +++ b/test/signals/mutableStateInterop.spec.tsx @@ -350,22 +350,22 @@ describe('selectors that mutate state', () => { it('calling .sort() on a state array throws a TypeError', () => { const proxy = buildProxy() // Array.prototype.sort writes back through the proxy's set trap, - // which rejects mutation. This raw TypeError is the current - // first-contact experience (it's what the Slack trial hit). + // which throws a descriptive mutation error (the Slack trial hit + // the engine's raw "trap returned falsish" before this existed). expect(() => proxy.items.sort()).toThrow(TypeError) - expect(() => proxy.items.sort()).toThrow(/'set' on proxy/) + expect(() => proxy.items.sort()).toThrow(/must not mutate state/) }) it('calling .push() on a state array throws a TypeError', () => { const proxy = buildProxy() - expect(() => proxy.items.push(4)).toThrow(/'set' on proxy/) + expect(() => proxy.items.push(4)).toThrow(/must not mutate state/) }) it('assigning a property throws a TypeError', () => { const proxy = buildProxy() expect(() => { proxy.user.name = 'bob' - }).toThrow(/'set' on proxy/) + }).toThrow(/assign to property 'name'/) }) it('deleting a property throws a TypeError', () => { @@ -373,17 +373,17 @@ describe('selectors that mutate state', () => { expect(() => { // @ts-expect-error -- deleting a required property on purpose delete proxy.user.name - }).toThrow(/'deleteProperty' on proxy/) + }).toThrow(/delete property 'name'/) }) it('Object.assign onto state throws a TypeError', () => { const proxy = buildProxy() expect(() => Object.assign(proxy.user, { name: 'bob' })).toThrow( - /'set' on proxy/, + /must not mutate state/, ) }) - it('a sorting selector mutates real state at mount (coarse probe), then throws on promotion', () => { + it('a sorting selector throws at mount (dev write guard) without corrupting store state', () => { interface ItemsState { items: number[] } @@ -425,22 +425,55 @@ describe('selectors that mutate state', () => { , ) - // At mount the hook is in the coarse tier: the probe proxy only - // wraps the top level, so `s.items` is the RAW array and sort() - // silently mutates real store state. No error — corrupted state. - expect(getByTestId('items').textContent).toBe('1,2,3') - expect(store.getState().items).toEqual([1, 2, 3]) - - // A dispatch touching the segment promotes the hook to the deep - // tracking proxy, whose set trap rejects the write. - rtl.act(() => { - store.dispatch({ type: 'setItems', items: [9, 7, 8] }) - }) - expect(getByTestId('boundary').textContent).toMatch(/'set' on proxy/) + // At mount the hook is in the coarse tier. The probe proxy wraps + // nested objects in dev-only write guards, so sort()'s write-back + // throws during the mount evaluation instead of silently mutating + // real store state. + expect(getByTestId('boundary').textContent).toMatch( + /must not mutate state/, + ) + expect(store.getState().items).toEqual([3, 1, 2]) } finally { spy.mockRestore() } }) + + it('read-only selectors still get raw values through the mount probe', () => { + interface ItemsState { + items: Array<{ id: number; text: string }> + other: number + } + type ItemsAction = { type: 'bump' } | { type: 'init' } + const initial: ItemsState = { + items: [ + { id: 1, text: 'a' }, + { id: 2, text: 'b' }, + ], + other: 0, + } + const reducer: Reducer = ( + state = initial, + action, + ) => (action.type === 'bump' ? { ...state, other: state.other + 1 } : state) + const store = createStore(reducer) + + const seen: Array> = [] + function Items() { + const items = useSignalSelector((s: ItemsState) => s.items) + seen.push(items) + return
{items.map((i) => i.text).join(',')}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + expect(getByTestId('items').textContent).toBe('a,b') + // untrackResult strips the dev-mode write guard from the selector + // result: the component sees the raw store array, not a proxy. + expect(seen.at(-1)).toBe(store.getState().items) + }) }) describe('batched store notifications (rAF-style)', () => { diff --git a/test/signals/reselect.spec.tsx b/test/signals/reselect.spec.tsx index d582284e9..d82b4ec4f 100644 --- a/test/signals/reselect.spec.tsx +++ b/test/signals/reselect.spec.tsx @@ -58,7 +58,7 @@ const makeStore = () => return state } }, - counter: (state = { value: 0 }, action: any) => + counter: (state: { value: number } = { value: 0 }, action: any) => action.type === 'increment' ? { value: state.value + 1 } : state, filter: (state = { showCompleted: true }, action: any) => action.type === 'setFilter' ? { showCompleted: action.payload } : state, From 6e108e1066896a4c950ef5a47bb77564134951fd Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Wed, 26 Aug 2026 23:48:03 -0400 Subject: [PATCH 59/95] Fix lint errors: jsdoc tags and leading semicolons --- src/signals/mutationError.ts | 1 + src/signals/pathSignalRegistry.ts | 10 ++++------ test/signals/coarseSegments.spec.ts | 8 +++++--- test/signals/pathSignalRegistry.spec.ts | 4 ++-- test/signals/suspenseUncommitted.spec.tsx | 2 +- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/signals/mutationError.ts b/src/signals/mutationError.ts index 38af394a5..a77e00753 100644 --- a/src/signals/mutationError.ts +++ b/src/signals/mutationError.ts @@ -11,6 +11,7 @@ * @param op - The kind of write that was attempted * @param path - Dot-separated state path, or a bare property name when * the full path is unknown + * @returns Never returns — always throws a `TypeError` */ export function throwStateMutationError( op: 'set' | 'delete' | 'defineProperty' | 'setPrototypeOf', diff --git a/src/signals/pathSignalRegistry.ts b/src/signals/pathSignalRegistry.ts index b67e2f920..74e11ef0d 100644 --- a/src/signals/pathSignalRegistry.ts +++ b/src/signals/pathSignalRegistry.ts @@ -154,12 +154,10 @@ function buildStructurePath(arrayPath: string, kind: StructureKind): string { return arrayPath + '.@@' + kind } -/** - * Object/array/column/structure signals hold version counters, not - * values. Bump forces every subscribed computed to re-evaluate. The - * non-number fallback resets a signal whose last value was a PRUNED - * sentinel or a real leaf value before the path's shape changed. - */ +// Object/array/column/structure signals hold version counters, not +// values. Bump forces every subscribed computed to re-evaluate. The +// non-number fallback resets a signal whose last value was a PRUNED +// sentinel or a real leaf value before the path's shape changed. function bumpVersion(sig: ReactiveSignal): void { const current = sig.get() sig.set(typeof current === 'number' ? current + 1 : 0) diff --git a/test/signals/coarseSegments.spec.ts b/test/signals/coarseSegments.spec.ts index d8f20598c..b1dd203cd 100644 --- a/test/signals/coarseSegments.spec.ts +++ b/test/signals/coarseSegments.spec.ts @@ -191,12 +191,13 @@ describe('createProbeProxy', () => { it('rejects writes at the top level and through nested dev write guards', () => { const { proxy } = createProbeProxy(state) + const writable = proxy as any expect(() => { - ;(proxy as any).a = { value: 9 } + writable.a = { value: 9 } }).toThrow(/must not mutate state/) expect(() => { - ;(proxy as any).a.value = 9 + writable.a.value = 9 }).toThrow(/assign to property 'value'/) expect(() => proxy.list.slice().sort()).not.toThrow() expect(() => (proxy.list as number[]).sort()).toThrow( @@ -237,9 +238,10 @@ describe('createProbeProxy', () => { it('rejects writes', () => { const { proxy } = createProbeProxy(state) + const writable = proxy as any expect(() => { - ;(proxy as any).a = 42 + writable.a = 42 }).toThrow(TypeError) expect(() => { delete (proxy as any).a diff --git a/test/signals/pathSignalRegistry.spec.ts b/test/signals/pathSignalRegistry.spec.ts index 7c930442b..e9aacd4c5 100644 --- a/test/signals/pathSignalRegistry.spec.ts +++ b/test/signals/pathSignalRegistry.spec.ts @@ -15,8 +15,8 @@ const EMPTY_STATS: RegistryStats = { segmentSubs: 0, } -/** Subscribe an effect to a signal; stopping the scope drops the last - * subscriber, which makes the signal call back into registry.release. */ +// Subscribe an effect to a signal; stopping the scope drops the last +// subscriber, which makes the signal call back into registry.release. function watch(sig: ReactiveSignal) { const scope = alienEngine.createScope() scope.run(() => { diff --git a/test/signals/suspenseUncommitted.spec.tsx b/test/signals/suspenseUncommitted.spec.tsx index 892f411f0..c9c449e23 100644 --- a/test/signals/suspenseUncommitted.spec.tsx +++ b/test/signals/suspenseUncommitted.spec.tsx @@ -123,7 +123,7 @@ function makeRegistryCapture() { return { registry, Capture } } -/** A manually-controlled Suspense gate. */ +// A manually-controlled Suspense gate. function makeGate() { let resolve!: () => void let resolved = false From f91d0740dd084574f9aa6b39d830c24c1f9ac48a Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 10 Sep 2026 14:23:04 +0200 Subject: [PATCH 60/95] Modernize signals branch for pnpm, oxlint, oxfmt, and tsdown - Regenerate pnpm-lock.yaml (adds alien-signals and reselect) - Drop the alien-signals/system tsconfig paths hack; bundler resolution reads the exports map - Point test:both at pnpm - Allow bare property reads in the tracking-proxy specs, where they register dependencies - Reformat test files with oxfmt --- .oxlintrc.json | 10 ++++ package.json | 2 +- pnpm-lock.yaml | 11 ++++ test/benchmarks/reconcile/benchEngine.ts | 6 +- test/benchmarks/reconcile/registryHelper.ts | 19 +++++-- test/benchmarks/reconcile/run-single.ts | 4 +- test/benchmarks/reconcile/runner.ts | 57 ++++++++++++++++--- test/benchmarks/reconcile/scenarios.ts | 38 +++++++++++-- test/benchmarks/reconcile/stateBuilders.ts | 10 +++- test/hooks/useSelector.spec.tsx | 55 +++++++++--------- test/signals/arrayMethodOverrides.spec.ts | 18 +++++- .../signals/createSignalSelectorHook.spec.tsx | 11 ++-- test/signals/identityArrayTracking.spec.ts | 30 ++++------ test/signals/identityComparison.spec.tsx | 55 ++++++------------ test/signals/interop.spec.tsx | 23 +++++--- test/signals/registryLeak.spec.tsx | 17 ++++-- test/signals/renderPhaseSwap.spec.tsx | 13 +++-- test/signals/untrack.spec.tsx | 19 +++++-- tsconfig.base.json | 7 +-- 19 files changed, 259 insertions(+), 146 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 9799dcbc5..7024e0c1b 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -47,6 +47,16 @@ "rules": { "no-unused-expressions": "off" } + }, + { + "files": [ + "test/signals/diff.spec.ts", + "test/signals/identityArrayTracking.spec.ts", + "test/signals/trackingProxy.spec.ts" + ], + "rules": { + "no-unused-expressions": "off" + } } ] } diff --git a/package.json b/package.json index c5ab5e4ac..5eb08e3b7 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "prepack": "pnpm build", "test": "vitest --run --typecheck", "test:signals": "cross-env TEST_IMPL=signals vitest --run", - "test:both": "yarn test && yarn test:signals", + "test:both": "pnpm test && pnpm test:signals", "test:watch": "vitest --watch", "type-tests": "tsc --noEmit -p tsconfig.test.json" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48dc6284a..255f0985b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@types/use-sync-external-store': specifier: ^0.0.6 version: 0.0.6 + alien-signals: + specifier: ^3.2.1 + version: 3.2.1 use-sync-external-store: specifier: ^1.4.0 version: 1.6.0(react@19.2.8) @@ -66,6 +69,9 @@ importers: redux: specifier: ^5.0.1 version: 5.0.1 + reselect: + specifier: ^5.2.0 + version: 5.3.0 rimraf: specifier: ^6.1.3 version: 6.1.3 @@ -751,6 +757,9 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + alien-signals@3.2.1: + resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} + ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} @@ -2168,6 +2177,8 @@ snapshots: agent-base@7.1.4: {} + alien-signals@3.2.1: {} + ansi-escapes@7.3.0: dependencies: environment: 1.1.0 diff --git a/test/benchmarks/reconcile/benchEngine.ts b/test/benchmarks/reconcile/benchEngine.ts index a92c0ec8f..486359ead 100644 --- a/test/benchmarks/reconcile/benchEngine.ts +++ b/test/benchmarks/reconcile/benchEngine.ts @@ -2,7 +2,11 @@ * Minimal SignalEngine stub for benchmarking. * Signals store values but don't propagate — we measure diff cost, not signal propagation. */ -import type { SignalEngine, ReactiveSignal, SignalScope } from '../../../src/signals/types' +import type { + SignalEngine, + ReactiveSignal, + SignalScope, +} from '../../../src/signals/types' let updateCount = 0 diff --git a/test/benchmarks/reconcile/registryHelper.ts b/test/benchmarks/reconcile/registryHelper.ts index b953c1994..71f1bff30 100644 --- a/test/benchmarks/reconcile/registryHelper.ts +++ b/test/benchmarks/reconcile/registryHelper.ts @@ -5,7 +5,11 @@ import { createPathSignalRegistry } from '../../../src/signals/pathSignalRegistry' import type { PathSignalRegistry } from '../../../src/signals/pathSignalRegistry' import type { SignalEngine } from '../../../src/signals/types' -import { findKeyField, buildIdentityPath, getKeyValue } from '../../../src/signals/arrayKeys' +import { + findKeyField, + buildIdentityPath, + getKeyValue, +} from '../../../src/signals/arrayKeys' /** * Walk an object and register all leaf paths as tracked signals. @@ -36,7 +40,11 @@ export function registerAllPaths( const keys = Object.keys(state as Record) for (const key of keys) { const childPath = parentPath ? `${parentPath}.${key}` : key - registerAllPaths((state as Record)[key], childPath, registry) + registerAllPaths( + (state as Record)[key], + childPath, + registry, + ) } } } @@ -102,9 +110,10 @@ export function registerEntityArrayPaths( let itemPath: string if (keyField) { const kv = getKeyValue(item, keyField) - itemPath = kv !== undefined - ? buildIdentityPath(arrayPath, keyField, kv) - : `${arrayPath}.${i}` + itemPath = + kv !== undefined + ? buildIdentityPath(arrayPath, keyField, kv) + : `${arrayPath}.${i}` } else { itemPath = `${arrayPath}.${i}` } diff --git a/test/benchmarks/reconcile/run-single.ts b/test/benchmarks/reconcile/run-single.ts index 2bf2e8fcc..5c8550cc8 100644 --- a/test/benchmarks/reconcile/run-single.ts +++ b/test/benchmarks/reconcile/run-single.ts @@ -76,7 +76,9 @@ function main() { console.log(`Total: ${elapsed.toFixed(1)} ms`) console.log(`Per iteration: ${(elapsed / iterations).toFixed(4)} ms`) - console.log(`Iterations/sec: ${Math.round(1000 / (elapsed / iterations)).toLocaleString()}`) + console.log( + `Iterations/sec: ${Math.round(1000 / (elapsed / iterations)).toLocaleString()}`, + ) } main() diff --git a/test/benchmarks/reconcile/runner.ts b/test/benchmarks/reconcile/runner.ts index 0c999485e..8ce3d4b28 100644 --- a/test/benchmarks/reconcile/runner.ts +++ b/test/benchmarks/reconcile/runner.ts @@ -26,7 +26,10 @@ interface BenchResult { itersPerSec: number } -function runScenario(scenario: BenchmarkScenario, iterations: number): BenchResult { +function runScenario( + scenario: BenchmarkScenario, + iterations: number, +): BenchResult { const { forward, reverse, registry } = scenario // Warmup @@ -71,12 +74,42 @@ function runScenario(scenario: BenchmarkScenario, iterations: number): BenchResu function formatTable(results: BenchResult[]): string { const cols = [ { key: 'name' as const, header: 'Scenario', width: 24 }, - { key: 'trackedPaths' as const, header: 'Tracked', width: 8, align: 'right' as const }, - { key: 'signalUpdatesForward' as const, header: 'Fwd Upd', width: 9, align: 'right' as const }, - { key: 'signalUpdatesReverse' as const, header: 'Rev Upd', width: 9, align: 'right' as const }, - { key: 'msPerIter' as const, header: 'ms/iter', width: 10, align: 'right' as const }, - { key: 'itersPerSec' as const, header: 'iter/s', width: 10, align: 'right' as const }, - { key: 'totalMs' as const, header: 'Total ms', width: 10, align: 'right' as const }, + { + key: 'trackedPaths' as const, + header: 'Tracked', + width: 8, + align: 'right' as const, + }, + { + key: 'signalUpdatesForward' as const, + header: 'Fwd Upd', + width: 9, + align: 'right' as const, + }, + { + key: 'signalUpdatesReverse' as const, + header: 'Rev Upd', + width: 9, + align: 'right' as const, + }, + { + key: 'msPerIter' as const, + header: 'ms/iter', + width: 10, + align: 'right' as const, + }, + { + key: 'itersPerSec' as const, + header: 'iter/s', + width: 10, + align: 'right' as const, + }, + { + key: 'totalMs' as const, + header: 'Total ms', + width: 10, + align: 'right' as const, + }, ] function pad(s: string, w: number, align: 'left' | 'right' = 'left'): string { @@ -127,14 +160,20 @@ function main() { } console.log(`\nReconcile Diff Benchmark`) - console.log(`Warmup: ${WARMUP} | Iterations: ${iterations} (forward+reverse per iter)\n`) + console.log( + `Warmup: ${WARMUP} | Iterations: ${iterations} (forward+reverse per iter)\n`, + ) let scenarios = createScenarios() if (filterName) { scenarios = scenarios.filter((s) => s.name === filterName) if (scenarios.length === 0) { console.error(`Unknown scenario: ${filterName}`) - console.error(`Available: ${createScenarios().map((s) => s.name).join(', ')}`) + console.error( + `Available: ${createScenarios() + .map((s) => s.name) + .join(', ')}`, + ) process.exit(1) } } diff --git a/test/benchmarks/reconcile/scenarios.ts b/test/benchmarks/reconcile/scenarios.ts index c05d3cc61..92489aa47 100644 --- a/test/benchmarks/reconcile/scenarios.ts +++ b/test/benchmarks/reconcile/scenarios.ts @@ -65,21 +65,36 @@ export function createScenarios(): BenchmarkScenario[] { 'Remove first of 1000 entities (flat array), all shift by 1', entityShift.forward, entityShift.reverse, - (reg, state) => registerEntityArrayPaths(state as Record, 'entities', reg), + (reg, state) => + registerEntityArrayPaths( + state as Record, + 'entities', + reg, + ), ), makeScenario( 'entity-single-update', 'Update 1 entity name in 1000 (structural sharing)', entitySingle.forward, entitySingle.reverse, - (reg, state) => registerEntityArrayPaths(state as Record, 'entities', reg), + (reg, state) => + registerEntityArrayPaths( + state as Record, + 'entities', + reg, + ), ), makeScenario( 'entity-bulk-update', 'Update 10% of 1000 entities (every 10th, structural sharing)', entityBulk.forward, entityBulk.reverse, - (reg, state) => registerEntityArrayPaths(state as Record, 'entities', reg), + (reg, state) => + registerEntityArrayPaths( + state as Record, + 'entities', + reg, + ), ), makeScenario( 'deep-nested', @@ -93,21 +108,32 @@ export function createScenarios(): BenchmarkScenario[] { '500 top-level keys (2 fields each), change 5', wideFlat.forward, wideFlat.reverse, - (reg, state) => registerWideFlatPaths(state as Record, reg), + (reg, state) => + registerWideFlatPaths(state as Record, reg), ), makeScenario( 'array-reorder', 'Reverse 100-element entity array', arrayReorder.forward, arrayReorder.reverse, - (reg, state) => registerEntityArrayPaths(state as Record, 'items', reg), + (reg, state) => + registerEntityArrayPaths( + state as Record, + 'items', + reg, + ), ), makeScenario( 'array-append', 'Append 10 items to 1000-element array', arrayAppend.forward, arrayAppend.reverse, - (reg, state) => registerEntityArrayPaths(state as Record, 'items', reg), + (reg, state) => + registerEntityArrayPaths( + state as Record, + 'items', + reg, + ), ), ] } diff --git a/test/benchmarks/reconcile/stateBuilders.ts b/test/benchmarks/reconcile/stateBuilders.ts index 3f31e12c3..eb749b232 100644 --- a/test/benchmarks/reconcile/stateBuilders.ts +++ b/test/benchmarks/reconcile/stateBuilders.ts @@ -109,11 +109,17 @@ export function buildDeepNested(depth = 5, breadth = 5) { // Change one deep leaf with structural sharing along the path // Path: level0.level0.level0.level0.level0.leaf0 - function changeDeepLeaf(state: Record, d: number): Record { + function changeDeepLeaf( + state: Record, + d: number, + ): Record { if (d === 0) { return { ...state, leaf0: 'CHANGED' } } - return { ...state, level0: changeDeepLeaf(state.level0 as Record, d - 1) } + return { + ...state, + level0: changeDeepLeaf(state.level0 as Record, d - 1), + } } const next = deepFreeze(changeDeepLeaf(base, depth)) diff --git a/test/hooks/useSelector.spec.tsx b/test/hooks/useSelector.spec.tsx index ea8a97c46..61a2eb950 100644 --- a/test/hooks/useSelector.spec.tsx +++ b/test/hooks/useSelector.spec.tsx @@ -193,36 +193,39 @@ describe('React', () => { expect(appSubscription!.getListeners().get().length).toBe(2) }) - it.skipIf(IS_SIGNALS)('unsubscribes when the component is unmounted', () => { - let appSubscription: Subscription | null = null - - const Parent = () => { - const contextVal = useContext(ReactReduxContext) - appSubscription = contextVal && contextVal.subscription - const count = useNormalSelector((s) => s.count) - return count === 0 ? : null - } + it.skipIf(IS_SIGNALS)( + 'unsubscribes when the component is unmounted', + () => { + let appSubscription: Subscription | null = null + + const Parent = () => { + const contextVal = useContext(ReactReduxContext) + appSubscription = contextVal && contextVal.subscription + const count = useNormalSelector((s) => s.count) + return count === 0 ? : null + } - const Child = () => { - const count = useNormalSelector((s) => s.count) - return
{count}
- } + const Child = () => { + const count = useNormalSelector((s) => s.count) + return
{count}
+ } - rtl.render( - - - , - ) - // Parent + 1 child component - expect(appSubscription!.getListeners().get().length).toBe(2) + rtl.render( + + + , + ) + // Parent + 1 child component + expect(appSubscription!.getListeners().get().length).toBe(2) - rtl.act(() => { - normalStore.dispatch({ type: '' }) - }) + rtl.act(() => { + normalStore.dispatch({ type: '' }) + }) - // Parent component only - expect(appSubscription!.getListeners().get().length).toBe(1) - }) + // Parent component only + expect(appSubscription!.getListeners().get().length).toBe(1) + }, + ) it('notices store updates between render and store subscription effect', () => { const Child = ({ count }: { count: number }) => { diff --git a/test/signals/arrayMethodOverrides.spec.ts b/test/signals/arrayMethodOverrides.spec.ts index b6c2bec35..fedb4cd8a 100644 --- a/test/signals/arrayMethodOverrides.spec.ts +++ b/test/signals/arrayMethodOverrides.spec.ts @@ -1,7 +1,10 @@ import { describe, test, expect, vi } from 'vitest' import { alienEngine } from '@internal/signals/engine' import { createPathSignalRegistry } from '@internal/signals/pathSignalRegistry' -import { createTrackingProxy, type ProxyCache } from '@internal/signals/trackingProxy' +import { + createTrackingProxy, + type ProxyCache, +} from '@internal/signals/trackingProxy' import { getProxyPath, unwrap } from '@internal/signals/trackingProxy' function deepFreeze(obj: T): T { @@ -558,7 +561,10 @@ describe('Array method overrides on tracking proxy', () => { test('flat with depth', () => { const state = deepFreeze({ items: [[[1, 2]], [[3, 4]]] }) const { proxy } = setup(state) - expect(proxy.items.flat(1)).toEqual([[1, 2], [3, 4]]) + expect(proxy.items.flat(1)).toEqual([ + [1, 2], + [3, 4], + ]) expect(proxy.items.flat(2)).toEqual([1, 2, 3, 4]) }) }) @@ -724,7 +730,13 @@ describe('Array method overrides on tracking proxy', () => { test('nested array access', () => { const state = deepFreeze({ groups: [ - { id: 1, items: [{ id: 10, name: 'a' }, { id: 11, name: 'b' }] }, + { + id: 1, + items: [ + { id: 10, name: 'a' }, + { id: 11, name: 'b' }, + ], + }, { id: 2, items: [{ id: 20, name: 'c' }] }, ], }) diff --git a/test/signals/createSignalSelectorHook.spec.tsx b/test/signals/createSignalSelectorHook.spec.tsx index cca5eb7a4..661334365 100644 --- a/test/signals/createSignalSelectorHook.spec.tsx +++ b/test/signals/createSignalSelectorHook.spec.tsx @@ -44,10 +44,9 @@ const makeStore = (initialValue = 0) => { type RootState = { counter: { value: number } } -const customContext = createContext | null>(null) +const customContext = createContext | null>( + null, +) const useCustomSelector = createSignalSelectorHook(customContext) @@ -78,9 +77,7 @@ describe('createSignalSelectorHook', () => { const customStore = makeStore(100) function Display() { - const defaultValue = useSignalSelector( - (s: RootState) => s.counter.value, - ) + const defaultValue = useSignalSelector((s: RootState) => s.counter.value) const customValue = useCustomSelector((s: RootState) => s.counter.value) return (
diff --git a/test/signals/identityArrayTracking.spec.ts b/test/signals/identityArrayTracking.spec.ts index 2a3a7f052..cc3842f09 100644 --- a/test/signals/identityArrayTracking.spec.ts +++ b/test/signals/identityArrayTracking.spec.ts @@ -752,9 +752,7 @@ describe('identity-based array tracking: diff', () => { }) // Should not throw — just works with last-wins semantics - expect(() => - diffAndUpdateSignals(prev, next, '', registry), - ).not.toThrow() + expect(() => diffAndUpdateSignals(prev, next, '', registry)).not.toThrow() }) it('handles entity with id=0', () => { @@ -778,9 +776,9 @@ describe('identity-based array tracking: diff', () => { diffAndUpdateSignals(prev, next, '', registry) - expect(registry.getOrCreate('items.{id:0}.name', 'Zero Updated').get()).toBe( - 'Zero Updated', - ) + expect( + registry.getOrCreate('items.{id:0}.name', 'Zero Updated').get(), + ).toBe('Zero Updated') expect(registry.getOrCreate('items.{id:1}.name', 'One').get()).toBe('One') }) @@ -859,14 +857,10 @@ describe('identity-based array tracking: diff', () => { diffAndUpdateSignals(prev, next, '', registry) expect( - registry - .getOrCreate('data.nested.items.{id:a}.val', 1) - .get(), + registry.getOrCreate('data.nested.items.{id:a}.val', 1).get(), ).toBe(1) expect( - registry - .getOrCreate('data.nested.items.{id:b}.val', 99) - .get(), + registry.getOrCreate('data.nested.items.{id:b}.val', 99).get(), ).toBe(99) }) @@ -891,12 +885,12 @@ describe('identity-based array tracking: diff', () => { diffAndUpdateSignals(prev, next, '', registry) - expect( - registry.getOrCreate('items.{id:1}.profile.age', 31).get(), - ).toBe(31) - expect( - registry.getOrCreate('items.{id:2}.profile.age', 25).get(), - ).toBe(25) + expect(registry.getOrCreate('items.{id:1}.profile.age', 31).get()).toBe( + 31, + ) + expect(registry.getOrCreate('items.{id:2}.profile.age', 25).get()).toBe( + 25, + ) }) }) diff --git a/test/signals/identityComparison.spec.tsx b/test/signals/identityComparison.spec.tsx index d632965d2..710d82115 100644 --- a/test/signals/identityComparison.spec.tsx +++ b/test/signals/identityComparison.spec.tsx @@ -400,8 +400,7 @@ describe('object identity comparison in selectors', () => { it('returns true when current and saved are different refs', () => { const Comp = () => { const hasChanges = useSignalSelector( - (state: RootState) => - state.identity.current !== state.identity.saved, + (state: RootState) => state.identity.current !== state.identity.saved, ) return
{String(hasChanges)}
} @@ -642,7 +641,9 @@ describe('object identity comparison in selectors', () => { // state.identity.selectedItem is also a proxy. // Array.includes on a proxy array checks if the proxy of // selectedItem is in the proxy array. - return state.identity.items.includes(state.identity.selectedItem as Item) + return state.identity.items.includes( + state.identity.selectedItem as Item, + ) }) return
{String(isFirstItemSelected)}
} @@ -912,9 +913,8 @@ describe('object identity comparison in selectors', () => { // Read a primitive from each to establish signal deps const draftTitle = (state.identity.draft as Record) ?.title - const pubTitle = ( - state.identity.published as Record - )?.title + const pubTitle = (state.identity.published as Record) + ?.title return draftTitle === pubTitle }) return
{String(result)}
@@ -1052,9 +1052,7 @@ describe('object identity comparison in selectors', () => { expect(getByTestId('result').textContent).toBe('none') rtl.act(() => { - store.dispatch( - identitySlice.actions.setMaybeNull({ label: 'new-obj' }), - ) + store.dispatch(identitySlice.actions.setMaybeNull({ label: 'new-obj' })) }) expect(getByTestId('result').textContent).toBe('exists') }) @@ -1098,9 +1096,7 @@ describe('object identity comparison in selectors', () => { it('tracks when deeply nested object becomes same ref as sibling', () => { // First diverge them - store.dispatch( - identitySlice.actions.setObjA({ label: 'diverged' }), - ) + store.dispatch(identitySlice.actions.setObjA({ label: 'diverged' })) const Comp = () => { const isSame = useSignalSelector( @@ -1128,8 +1124,7 @@ describe('object identity comparison in selectors', () => { // After Immer, these are different refs even with same content const state = store.getState() - const expected = - state.identity.deep.nested.target === state.identity.objA + const expected = state.identity.deep.nested.target === state.identity.objA expect(getByTestId('result').textContent).toBe(String(expected)) }) }) @@ -1145,8 +1140,7 @@ describe('object identity comparison in selectors', () => { it('tracks identity between sibling objects', () => { const Comp = () => { const isSame = useSignalSelector( - (state: RootState) => - state.identity.objA === state.identity.objB, + (state: RootState) => state.identity.objA === state.identity.objB, ) return
{String(isSame)}
} @@ -1162,9 +1156,7 @@ describe('object identity comparison in selectors', () => { // Diverge objA rtl.act(() => { - store.dispatch( - identitySlice.actions.setObjA({ label: 'changed' }), - ) + store.dispatch(identitySlice.actions.setObjA({ label: 'changed' })) }) expect(getByTestId('result').textContent).toBe('false') @@ -1172,14 +1164,11 @@ describe('object identity comparison in selectors', () => { it('tracks when siblings converge to same ref', () => { // Diverge first - store.dispatch( - identitySlice.actions.setObjA({ label: 'different' }), - ) + store.dispatch(identitySlice.actions.setObjA({ label: 'different' })) const Comp = () => { const isSame = useSignalSelector( - (state: RootState) => - state.identity.objA === state.identity.objB, + (state: RootState) => state.identity.objA === state.identity.objB, ) return
{String(isSame)}
} @@ -1194,9 +1183,7 @@ describe('object identity comparison in selectors', () => { // Change objB to match objA's content (Immer creates new refs) rtl.act(() => { - store.dispatch( - identitySlice.actions.setObjB({ label: 'different' }), - ) + store.dispatch(identitySlice.actions.setObjB({ label: 'different' })) }) const state = store.getState() @@ -1220,8 +1207,7 @@ describe('object identity comparison in selectors', () => { const Comp = () => { renderCount++ const isSame = useSignalSelector( - (state: RootState) => - state.identity.objA === state.identity.objB, + (state: RootState) => state.identity.objA === state.identity.objB, ) return
{String(isSame)}
} @@ -1281,9 +1267,7 @@ describe('object identity comparison in selectors', () => { // Diverge objA — different refs now rtl.act(() => { - store.dispatch( - identitySlice.actions.setObjA({ label: 'alpha' }), - ) + store.dispatch(identitySlice.actions.setObjA({ label: 'alpha' })) }) expect(getByTestId('result').textContent).toBe('alpha vs shared-nested') @@ -1354,9 +1338,7 @@ describe('object identity comparison in selectors', () => { const callsAfterMount = selectorCalls rtl.act(() => { - store.dispatch( - identitySlice.actions.setObjA({ label: 'updated' }), - ) + store.dispatch(identitySlice.actions.setObjA({ label: 'updated' })) }) expect(getByTestId('result').textContent).toBe('updated') @@ -1376,8 +1358,7 @@ describe('object identity comparison in selectors', () => { it('tracks both identity and primitive changes', () => { const Comp = () => { const result = useSignalSelector((state: RootState) => { - const isSame = - state.identity.draft === state.identity.published + const isSame = state.identity.draft === state.identity.published const count = state.identity.siblingPrimitive return `${isSame}-${count}` }) diff --git a/test/signals/interop.spec.tsx b/test/signals/interop.spec.tsx index 37c50ce24..f8690cf11 100644 --- a/test/signals/interop.spec.tsx +++ b/test/signals/interop.spec.tsx @@ -138,9 +138,11 @@ describe('connect() under SignalProvider', () => { it('dispatches from a connected component and updates a sibling signal hook', () => { const store = makeStore() - const Button = connect()(({ dispatch }: { dispatch: Store['dispatch'] }) => ( - - )) + const Button = connect()( + ({ dispatch }: { dispatch: Store['dispatch'] }) => ( + + ), + ) function SignalDisplay() { const a = useSignalSelector((state: CounterState) => state.a) return
{a}
@@ -181,7 +183,9 @@ describe('connect() under SignalProvider', () => { rtl.act(() => { store.dispatch(incA()) }) - expect(rtl.screen.getByTestId('connected').textContent).toBe(String(i + 1)) + expect(rtl.screen.getByTestId('connected').textContent).toBe( + String(i + 1), + ) expect(rtl.screen.getByTestId('signal').textContent).toBe(String(i + 1)) } }) @@ -448,9 +452,14 @@ describe('nested providers', () => { it('connect() reads a custom context supplied by SignalProvider', () => { const store = makeStore() const SignalContext = createContext(null) - const Display = connect((state: CounterState) => ({ a: state.a }), null, null, { - context: SignalContext, - })(({ a }: { a: number }) =>
{a}
) + const Display = connect( + (state: CounterState) => ({ a: state.a }), + null, + null, + { + context: SignalContext, + }, + )(({ a }: { a: number }) =>
{a}
) rtl.render( diff --git a/test/signals/registryLeak.spec.tsx b/test/signals/registryLeak.spec.tsx index 242ac4347..25276c09c 100644 --- a/test/signals/registryLeak.spec.tsx +++ b/test/signals/registryLeak.spec.tsx @@ -63,7 +63,10 @@ function makeInitialState(): AppState { return { slices, dyn: {}, items, other: { flag: false } } } -function reducer(state: AppState = makeInitialState(), action: Action): AppState { +function reducer( + state: AppState = makeInitialState(), + action: Action, +): AppState { switch (action.type) { case 'bump': { const prev = state.slices[action.key] @@ -310,7 +313,9 @@ describe('selectors that switch which paths they read', () => { const store = makeStore() function Branching({ index }: { index: number }) { - const n = useSignalSelector((s: AppState) => s.slices[`s${index}`].nested.n) + const n = useSignalSelector( + (s: AppState) => s.slices[`s${index}`].nested.n, + ) return
{n}
} @@ -369,7 +374,9 @@ describe('partial unmount under a shared ancestor', () => { rtl.act(() => { store.dispatch({ type: 'bump', key: 's0' }) }) - expect(registry.debugPaths()).toContain(`slices.s${SLICE_COUNT - 1}.nested.n`) + expect(registry.debugPaths()).toContain( + `slices.s${SLICE_COUNT - 1}.nested.n`, + ) // Drop 15 of the 20 watchers in a single commit. Every released // path shares the `slices` ancestor, so its bookkeeping takes 15 @@ -513,9 +520,7 @@ describe('array metadata', () => { let registry: PathSignalRegistry | null = null for (let cycle = 0; cycle < 4; cycle++) { - const { unmount } = rtl.render( - , - ) + const { unmount } = rtl.render() registry = requireRegistry() rtl.act(() => { store.dispatch({ type: 'toggleItem', id: 1 }) diff --git a/test/signals/renderPhaseSwap.spec.tsx b/test/signals/renderPhaseSwap.spec.tsx index 556e90e8c..dadd229c7 100644 --- a/test/signals/renderPhaseSwap.spec.tsx +++ b/test/signals/renderPhaseSwap.spec.tsx @@ -20,7 +20,11 @@ * event handler into ONE render, so a notify-triggered render can still * carry a changed closure. */ -import { configureStore, createSlice, type PayloadAction } from '@reduxjs/toolkit' +import { + configureStore, + createSlice, + type PayloadAction, +} from '@reduxjs/toolkit' import * as rtl from '@testing-library/react' import React, { useCallback, useState } from 'react' import { SignalProvider, useSignalSelector } from '../../src/signals' @@ -74,10 +78,9 @@ describe('render-phase selector swap', () => { function Watcher() { const [idx, setIdx] = useState(0) - const name = useSignalSelector( - (s: AppState) => s.items.list[idx].name, - { devModeChecks: { stabilityCheck: 'never' } }, - ) + const name = useSignalSelector((s: AppState) => s.items.list[idx].name, { + devModeChecks: { stabilityCheck: 'never' }, + }) return (
{name}
diff --git a/test/signals/untrack.spec.tsx b/test/signals/untrack.spec.tsx index 3a9787e9e..11307a878 100644 --- a/test/signals/untrack.spec.tsx +++ b/test/signals/untrack.spec.tsx @@ -15,7 +15,10 @@ import { SignalProvider } from '../../src/signals/SignalProvider' import { useSignalSelector } from '../../src/signals/useSignalSelector' import { alienEngine } from '../../src/signals/engine' import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' -import { createTrackingProxy, getProxyPath } from '../../src/signals/trackingProxy' +import { + createTrackingProxy, + getProxyPath, +} from '../../src/signals/trackingProxy' import { untrackResult } from '../../src/signals/untrack' const isTrackingProxy = (v: unknown) => @@ -232,7 +235,6 @@ describe('untrackResult unit behavior', () => { // are skipped for the same check, which is the common (safe) case. expect(isTrackingProxy(result.child)).toBe(true) }) - }) describe('untrackResult with derived arrays', () => { @@ -272,7 +274,9 @@ describe('untrackResult with derived arrays', () => { it('untracks filter() results to raw elements', () => { const { state, proxy } = setupArrayState() - const result = evaluate(proxy, (s) => s.rows.filter((r) => r.status === 'even')) + const result = evaluate(proxy, (s) => + s.rows.filter((r) => r.status === 'even'), + ) expect(result).toHaveLength(5) for (let i = 0; i < result.length; i++) { expect(isTrackingProxy(result[i])).toBe(false) @@ -284,7 +288,9 @@ describe('untrackResult with derived arrays', () => { it('handles filter().sort() — user mutation of a library-built array', () => { const { state, proxy } = setupArrayState() const result = evaluate(proxy, (s) => - s.rows.filter((r) => r.status === 'even').sort((a, b) => b.score - a.score), + s.rows + .filter((r) => r.status === 'even') + .sort((a, b) => b.score - a.score), ) expect(result.map((r) => r.id)).toEqual([8, 6, 4, 2, 0]) for (const r of result) { @@ -300,7 +306,9 @@ describe('untrackResult with derived arrays', () => { matches[0] = { wrapped: s.extra } return matches }) - expect(isTrackingProxy((result[0] as { wrapped: object }).wrapped)).toBe(false) + expect(isTrackingProxy((result[0] as { wrapped: object }).wrapped)).toBe( + false, + ) expect((result[0] as { wrapped: object }).wrapped).toBe(state.extra) expect(isTrackingProxy(result[1])).toBe(false) }) @@ -327,5 +335,4 @@ describe('untrackResult with derived arrays', () => { expect(isTrackingProxy(result.first)).toBe(false) expect(result.first).toBe(state.rows[0]) }) - }) diff --git a/tsconfig.base.json b/tsconfig.base.json index 293bb1b6f..64038b0f2 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -21,12 +21,7 @@ // paths rather than the Vite alias, so they must point at the same // stock entry — otherwise a benchmark override in `src/exports.ts` // silently changes what the type tests are checking. - "react-redux": ["./test/entries/stock.ts"], // @remap-prod-remove-line - // `moduleResolution: "Node"` predates `exports` maps, so it cannot - // find the types for alien-signals' `./system` subpath export. - // Vite and tsup both resolve it correctly at runtime; this only - // points the type checker at the declarations. - "alien-signals/system": ["./node_modules/alien-signals/types/system.d.ts"] + "react-redux": ["./test/entries/stock.ts"] // @remap-prod-remove-line }, "resolveJsonModule": true, "skipLibCheck": true, From 2e0acecd2d4be5ddd66044f27e230714a2fc9cc7 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 16 Aug 2026 01:08:22 -0400 Subject: [PATCH 61/95] docs: split useSelector into its own API page --- docs/api/useSelector.md | 410 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 410 insertions(+) create mode 100644 docs/api/useSelector.md diff --git a/docs/api/useSelector.md b/docs/api/useSelector.md new file mode 100644 index 000000000..6539479f9 --- /dev/null +++ b/docs/api/useSelector.md @@ -0,0 +1,410 @@ +--- +id: useSelector +title: useSelector +sidebar_label: useSelector() +hide_title: true +description: 'API > useSelector: reading values from the Redux store state with a selector function' +--- + +  + +# `useSelector()` + +```ts +const result: Selected = useSelector( + selector: (state: RootState) => Selected, + options?: EqualityFn | UseSelectorOptions +) +``` + +_added in v7.1.0_ + +Allows you to extract data from the Redux store state for use in this component, using a selector function. + +```jsx +import React from 'react' +import { useSelector } from 'react-redux' + +export const CounterComponent = () => { + const counter = useSelector((state) => state.counter) + return
{counter}
+} +``` + +:::info + +The selector function should be [pure](https://en.wikipedia.org/wiki/Pure_function) since it is potentially executed multiple times and at arbitrary points in time. + +See [Using Redux: Deriving Data with Selectors](https://redux.js.org/usage/deriving-data-selectors) in the Redux docs for more details on writing and using selector functions. + +::: + +## API Reference + +### Signature + +```ts +type RootState = ReturnType +type SelectorFn = (state: RootState) => Selected +type EqualityFn = (a: any, b: any) => boolean +export type DevModeCheckFrequency = 'never' | 'once' | 'always' + +interface UseSelectorOptions { + equalityFn?: EqualityFn + devModeChecks?: { + stabilityCheck?: DevModeCheckFrequency + identityFunctionCheck?: DevModeCheckFrequency + } +} + +const result: Selected = useSelector( + selector: SelectorFn, + options?: EqualityFn | UseSelectorOptions +) +``` + +### Parameters + +| Name | Description | +| :--------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `selector` | A function that receives the entire Redux store state as its only argument, and returns the value this component needs from it. | +| `options?` | Either an equality function used to compare selector results (such as `shallowEqual`), or an options object containing an `equalityFn` field and a [`devModeChecks`](#development-mode-checks) field for configuring the development-mode checks below. | + +### Returns + +The return value of the selector function, recalculated whenever the component renders with a new selector reference or a dispatched action changes the selected value. + +## Usage Guide + +### Selector behavior + +The selector will be called with the entire Redux store state as its only argument. The selector may return any value as a result, including directly returning a value that was nested inside `state`, or deriving new values. The return value of the selector will be used as the return value of the `useSelector()` hook. + +The selector will be run whenever the function component renders (unless its reference hasn't changed since a previous render of the component so that a cached result can be returned by the hook without re-running the selector). `useSelector()` will also subscribe to the Redux store, and run your selector whenever an action is dispatched. + +When an action is dispatched, `useSelector()` will do a reference comparison of the previous selector result value and the current result value. If they are different, the component will be forced to re-render. If they are the same, the component will not re-render. `useSelector()` uses strict `===` reference equality checks by default, not shallow equality (see the following section for more details). + +The selector is approximately equivalent to the [`mapStateToProps` argument to `connect`](../using-react-redux/connect-extracting-data-with-mapStateToProps.md) conceptually. + +You may call `useSelector()` multiple times within a single function component. Each call to `useSelector()` creates an individual subscription to the Redux store. Because of the React update batching behavior used in React Redux v7, a dispatched action that causes multiple `useSelector()`s in the same component to return new values _should_ only result in a single re-render. + +:::info + +There are potential edge cases with using props in selectors that may cause issues. See the [Usage Warnings](#usage-warnings) section of this page for further details. + +::: + +### Equality Comparisons and Updates + +When the function component renders, the provided selector function will be called and its result will be returned +from the `useSelector()` hook. (A cached result may be returned by the hook without re-running the selector if it's the same function reference as on a previous render of the component.) + +However, when an action is dispatched to the Redux store, `useSelector()` only forces a re-render if the selector result +appears to be different than the last result. The default comparison is a strict `===` reference +comparison. This is different than `connect()`, which uses shallow equality checks on the results of `mapState` calls +to determine if re-rendering is needed. This has several implications on how you should use `useSelector()`. + +With `mapState`, all individual fields were returned in a combined object. It didn't matter if the return object was +a new reference or not - `connect()` just compared the individual fields. With `useSelector()`, returning a new object +every time will _always_ force a re-render by default. If you want to retrieve multiple values from the store, you can: + +- Call `useSelector()` multiple times, with each call returning a single field value +- Use Reselect or a similar library to create a memoized selector that returns multiple values in one object, but + only returns a new object when one of the values has changed. +- Use the `shallowEqual` function from React-Redux as the `equalityFn` argument to `useSelector()`, like: + +```js +import { shallowEqual, useSelector } from 'react-redux' + +// Pass it as the second argument directly +const selectedData = useSelector(selectorReturningObject, shallowEqual) + +// or pass it as the `equalityFn` field in the options argument +const selectedData = useSelector(selectorReturningObject, { + equalityFn: shallowEqual, +}) +``` + +- Use a custom equality function as the `equalityFn` argument to `useSelector()`, like: + +```js +import { useSelector } from 'react-redux' + +// equality function +const customEqual = (oldValue, newValue) => oldValue === newValue + +// later +const selectedData = useSelector(selectorReturningObject, customEqual) +``` + +The optional comparison function also enables using something like Lodash's `_.isEqual()` or Immutable.js's comparison capabilities. + +### Examples + +Basic usage: + +```jsx +import React from 'react' +import { useSelector } from 'react-redux' + +export const CounterComponent = () => { + const counter = useSelector((state) => state.counter) + return
{counter}
+} +``` + +Using props via closure to determine what to extract: + +```jsx +import React from 'react' +import { useSelector } from 'react-redux' + +export const TodoListItem = (props) => { + const todo = useSelector((state) => state.todos[props.id]) + return
{todo.text}
+} +``` + +### Using memoizing selectors + +When using `useSelector` with an inline selector as shown above, a new instance of the selector is created whenever the component is rendered. This works as long as the selector does not maintain any state. However, memoizing selectors (e.g. created via `createSelector` from `reselect`) do have internal state, and therefore care must be taken when using them. Below you can find typical usage scenarios for memoizing selectors. + +When the selector does only depend on the state, simply ensure that it is declared outside of the component so that the same selector instance is used for each render: + +```jsx +import React from 'react' +import { useSelector } from 'react-redux' +import { createSelector } from 'reselect' + +const selectNumCompletedTodos = createSelector( + (state) => state.todos, + (todos) => todos.filter((todo) => todo.completed).length, +) + +export const CompletedTodosCounter = () => { + const numCompletedTodos = useSelector(selectNumCompletedTodos) + return
{numCompletedTodos}
+} + +export const App = () => { + return ( + <> + Number of completed todos: + + + ) +} +``` + +The same is true if the selector depends on the component's props, but will only ever be used in a single instance of a single component: + +```jsx +import React from 'react' +import { useSelector } from 'react-redux' +import { createSelector } from 'reselect' + +const selectCompletedTodosCount = createSelector( + (state) => state.todos, + (_, completed) => completed, + (todos, completed) => + todos.filter((todo) => todo.completed === completed).length, +) + +export const CompletedTodosCount = ({ completed }) => { + const matchingCount = useSelector((state) => + selectCompletedTodosCount(state, completed), + ) + + return
{matchingCount}
+} + +export const App = () => { + return ( + <> + Number of done todos: + + + ) +} +``` + +However, when the selector is used in multiple component instances and depends on the component's props, you need to ensure that selector's memoization behavior is properly configured (see [here](https://reselect.js.org/faq/#can-i-share-a-selector-across-multiple-component-instances) for details). + +### Development mode checks + +`useSelector` runs some extra checks in development mode to watch for unexpected behavior. These checks do not run in production builds. + +:::info + +These checks were first added in v8.1.0 + +::: + +#### Selector result stability + +In development, the provided selector function is run an extra time with the same parameter during the first call to `useSelector`, and warns in the console if the selector returns a different result (based on the `equalityFn` provided). + +This is important, as **a selector that returns a different result reference when called again with the same inputs will cause unnecessary rerenders**. + +```ts +// this selector will return a new object reference whenever called, +// which causes the component to rerender after *every* action is dispatched +const { count, user } = useSelector((state) => ({ + count: state.count, + user: state.user, +})) +``` + +If a selector result is suitably stable (or the selector is memoized), it will not return a different result and no warning will be logged. + +By default, this will only happen when the selector is first called. You can configure the check in the Provider or at each `useSelector` call. + +```tsx title="Global setting via context" + + {children} + +``` + +```tsx title="Individual hook setting" +function Component() { + const count = useSelector(selectCount, { + devModeChecks: { stabilityCheck: 'never' }, + }) + // run once (default) + const user = useSelector(selectUser, { + devModeChecks: { stabilityCheck: 'once' }, + }) + // ... +} +``` + +#### Identity Function (`state => state`) Check + +:::danger Breaking Change! + +This was previously referred to as `noopCheck`. + +::: + +In development, a check is conducted on the result returned by the selector. It warns in the console if the result is the same as the parameter passed in, i.e. the root state. + +**A `useSelector` call returning the entire root state is almost always a mistake**, as it means the component will rerender whenever _anything_ in state changes. Selectors should be as granular as possible, like `state => state.some.nested.field`. + +```ts no-transpile +// BAD: this selector returns the entire state, meaning that the component will rerender unnecessarily +const { count, user } = useSelector((state) => state) + +// GOOD: instead, select only the state you need, calling useSelector as many times as needed +const count = useSelector((state) => state.count.value) +const user = useSelector((state) => state.auth.currentUser) +``` + +By default, this will only happen when the selector is first called. You can configure the check in the Provider or at each `useSelector` call. + +```tsx title="Global setting via context" + + {children} + +``` + +```tsx title="Individual hook setting" +function Component() { + const count = useSelector(selectCount, { + devModeChecks: { identityFunctionCheck: 'never' }, + }) + // run once (default) + const user = useSelector(selectUser, { + devModeChecks: { identityFunctionCheck: 'once' }, + }) + // ... +} +``` + +### Comparisons with `connect` + +There are some differences between the selectors passed to `useSelector()` and a `mapState` function: + +- The selector may return any value as a result, not just an object. +- The selector normally _should_ return just a single value, and not an object. If you do return an object or an array, be sure to use a memoized selector to avoid unnecessary re-renders. +- The selector function does _not_ receive an `ownProps` argument. However, props can be used through closure (see the examples above) or by using a curried selector. +- You can use the `equalityFn` option to customize the comparison behavior + +### Usage Warnings + +#### Stale Props and "Zombie Children" + +:::info + +The React-Redux hooks API has been production-ready since we released it in v7.1.0, and **we recommend using the hooks API as the default approach in your components**. However, there are a couple of edge cases that can occur, and **we're documenting those so that you can be aware of them**. + +In practice, these are a rare concern - we've received far more comments about these being in the docs than actual reports of these being a real problem in an app. + +::: + +One of the most difficult aspects of React Redux's implementation is ensuring that if your `mapStateToProps` function is defined as `(state, ownProps)`, it will be called with the "latest" props every time. Up through version 4, there were recurring bugs reported involving edge case situations, such as errors thrown from a `mapState` function for a list item whose data had just been deleted. + +Starting with version 5, React Redux has attempted to guarantee that consistency with `ownProps`. In version 7, that is implemented using a custom `Subscription` class internally in `connect()`, which forms a nested hierarchy. This ensures that connected components lower in the tree will only receive store update notifications once the nearest connected ancestor has been updated. However, this relies on each `connect()` instance overriding part of the internal React context, supplying its own unique `Subscription` instance to form that nesting, and rendering the `` with that new context value. + +With hooks, there is no way to render a context provider, which means there's also no nested hierarchy of subscriptions. Because of this, the "stale props" and "zombie child" issues may potentially re-occur in an app that relies on using hooks instead of `connect()`. + +Specifically, "stale props" means any case where: + +- a selector function relies on this component's props to extract data +- a parent component _would_ re-render and pass down new props as a result of an action +- but this component's selector function executes before this component has had a chance to re-render with those new props + +Depending on what props were used and what the current store state is, this _may_ result in incorrect data being returned from the selector, or even an error being thrown. + +"Zombie child" refers specifically to the case where: + +- Multiple nested connected components are mounted in a first pass, causing a child component to subscribe to the store before its parent +- An action is dispatched that deletes data from the store, such as a todo item +- The parent component _would_ stop rendering that child as a result +- However, because the child subscribed first, its subscription runs before the parent stops rendering it. When it reads a value from the store based on props, that data no longer exists, and if the extraction logic is not careful, this may result in an error being thrown. + +`useSelector()` tries to deal with this by catching all errors that are thrown when the selector is executed due to a store update (but not when it is executed during rendering). When an error occurs, the component will be forced to render, at which point the selector is executed again. This works as long as the selector is a pure function and you do not depend on the selector throwing errors. + +If you prefer to deal with this issue yourself, here are some possible options for avoiding these problems altogether with `useSelector()`: + +- Don't rely on props in your selector function for extracting data +- In cases where you do rely on props in your selector function _and_ those props may change over time, _or_ the data you're extracting may be based on items that can be deleted, try writing the selector functions defensively. Don't just reach straight into `state.todos[props.id].name` - read `state.todos[props.id]` first, and verify that it exists before trying to read `todo.name`. +- Because `connect` adds the necessary `Subscription` to the context provider and delays evaluating child subscriptions until the connected component has re-rendered, putting a connected component in the component tree just above the component using `useSelector` will prevent these issues as long as the connected component gets re-rendered due to the same store update as the hooks component. + +:::info + +For a longer description of these scenarios, see: + +- ["Stale props and zombie children in Redux" by Kai Hao](https://kaihao.dev/posts/Stale-props-and-zombie-children-in-Redux) +- [A chat log that describes the problems in more detail](https://gist.github.com/markerikson/faac6ae4aca7b82a058e13216a7888ec) +- [issue #1179](https://github.com/reduxjs/react-redux/issues/1179) + +::: + +### Performance + +As mentioned earlier, by default `useSelector()` will do a reference equality comparison of the selected value when running the selector function after an action is dispatched, and will only cause the component to re-render if the selected value changed. However, unlike `connect()`, `useSelector()` does not prevent the component from re-rendering due to its parent re-rendering, even if the component's props did not change. + +If further performance optimizations are necessary, you may consider wrapping your function component in `React.memo()`: + +```jsx +const CounterComponent = ({ name }) => { + const counter = useSelector((state) => state.counter) + return ( +
+ {name}: {counter} +
+ ) +} + +export const MemoizedCounterComponent = React.memo(CounterComponent) +``` + +### TypeScript + +`useSelector` accepts a pre-typed setup via `useSelector.withTypes()`, which lets you define the `state` type once instead of specifying it in every selector. See [Usage with TypeScript](../using-react-redux/usage-with-typescript.md) for details on defining pre-typed hooks. + +## See Also + +- [`useSignalSelector`](./useSignalSelector.md): a fine-grained variant of this hook that tracks which parts of the state each selector reads +- [`useDispatch`](./useDispatch.md) and [`useStore`](./useStore.md): the other core React Redux hooks +- [Hooks overview](./hooks.md) From 08050c7ae7ac9a4a44878cbd3bea76f89ca7ac74 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 16 Aug 2026 01:08:22 -0400 Subject: [PATCH 62/95] docs: split useDispatch into its own API page --- docs/api/useDispatch.md | 110 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/api/useDispatch.md diff --git a/docs/api/useDispatch.md b/docs/api/useDispatch.md new file mode 100644 index 000000000..33550d1c0 --- /dev/null +++ b/docs/api/useDispatch.md @@ -0,0 +1,110 @@ +--- +id: useDispatch +title: useDispatch +sidebar_label: useDispatch() +hide_title: true +description: 'API > useDispatch: dispatching actions from React components' +--- + +  + +# `useDispatch()` + +```ts +import type { Dispatch } from 'redux' +const dispatch: Dispatch = useDispatch() +``` + +_added in v7.1.0_ + +This hook returns a reference to the `dispatch` function from the Redux store. You may use it to dispatch actions as needed. + +## API Reference + +### Parameters + +`useDispatch` takes no arguments. + +### Returns + +The `dispatch` function from the Redux store provided by the nearest [``](./Provider.md). + +:::info + +The `dispatch` function reference will be stable as long as the same store instance is being passed to the ``. +Normally, that store instance never changes in an application. + +However, the React hooks lint rules do not know that `dispatch` should be stable, and will warn that the `dispatch` variable +should be added to dependency arrays for `useEffect` and `useCallback`. The simplest solution is to do just that: + +```js +export const Todos = () => { + const dispatch = useDispatch() + + useEffect(() => { + dispatch(fetchTodos()) + // highlight-start + // Safe to add dispatch to the dependencies array + }, [dispatch]) + // highlight-end +} +``` + +::: + +## Usage Guide + +### Examples + +```jsx +import React from 'react' +import { useDispatch } from 'react-redux' + +export const CounterComponent = ({ value }) => { + const dispatch = useDispatch() + + return ( +
+ {value} + +
+ ) +} +``` + +When passing a callback using `dispatch` to a child component, you may sometimes want to memoize it with [`useCallback`](https://react.dev/reference/react/useCallback). _If_ the child component is trying to optimize render behavior using `React.memo()` or similar, this avoids unnecessary rendering of child components due to the changed callback reference. + +```jsx +import React, { useCallback } from 'react' +import { useDispatch } from 'react-redux' + +export const CounterComponent = ({ value }) => { + const dispatch = useDispatch() + const incrementCounter = useCallback( + () => dispatch({ type: 'increment-counter' }), + [dispatch], + ) + + return ( +
+ {value} + +
+ ) +} + +export const MyIncrementButton = React.memo(({ onIncrement }) => ( + +)) +``` + +### TypeScript + +`useDispatch` accepts a pre-typed setup via `useDispatch.withTypes()`, which lets you define the store's `dispatch` type once (including thunk support) instead of specifying it at every call site. See [Usage with TypeScript](../using-react-redux/usage-with-typescript.md) for details on defining pre-typed hooks. + +## See Also + +- [`useSelector`](./useSelector.md) and [`useStore`](./useStore.md): the other core React Redux hooks +- [Hooks overview](./hooks.md) From d885e60667e911ac9e242d74ea405a935c9da05b Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 16 Aug 2026 01:08:23 -0400 Subject: [PATCH 63/95] docs: split useStore into its own API page --- docs/api/useStore.md | 66 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 docs/api/useStore.md diff --git a/docs/api/useStore.md b/docs/api/useStore.md new file mode 100644 index 000000000..f5a524161 --- /dev/null +++ b/docs/api/useStore.md @@ -0,0 +1,66 @@ +--- +id: useStore +title: useStore +sidebar_label: useStore() +hide_title: true +description: 'API > useStore: accessing the Redux store instance directly' +--- + +  + +# `useStore()` + +```ts +import type { Store } from 'redux' +const store: Store = useStore() +``` + +_added in v7.1.0_ + +This hook returns a reference to the same Redux store that was passed in to the `` component. + +This hook should probably not be used frequently. Prefer [`useSelector()`](./useSelector.md) as your primary choice. However, this may be useful for less common scenarios that do require access to the store, such as replacing reducers. + +## API Reference + +### Parameters + +`useStore` takes no arguments. + +### Returns + +The Redux store instance provided by the nearest [``](./Provider.md). + +## Usage Guide + +### Examples + +```js +import React from 'react' +import { useStore } from 'react-redux' + +export const ExampleComponent = ({ value }) => { + const store = useStore() + + const onClick = () => { + // Not _recommended_, but safe + // This avoids subscribing to the state via `useSelector` + // Prefer moving this logic into a thunk instead + const numTodos = store.getState().todos.length + } + + // EXAMPLE ONLY! Do not do this in a real app. + // The component will not automatically update if the store state changes + return
{store.getState().todos.length}
+} +``` + +### TypeScript + +`useStore` accepts a pre-typed setup via `useStore.withTypes()`, which lets you define the store type once instead of specifying it at every call site. See [Usage with TypeScript](../using-react-redux/usage-with-typescript.md) for details on defining pre-typed hooks. + +## See Also + +- [`useSelector`](./useSelector.md) and [`useDispatch`](./useDispatch.md): the other core React Redux hooks +- [Accessing the Store](../using-react-redux/accessing-store.md): other techniques for store access, including custom context +- [Hooks overview](./hooks.md) From 7e2576ae01190ac93a89d12221ee9918cd9a4bd3 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 16 Aug 2026 01:08:23 -0400 Subject: [PATCH 64/95] docs: rewrite Hooks page as an overview shell Keeps headings whose slugs match the old single-page anchors (#useselector, #usedispatch, #usestore, #custom-context, #usage-warnings) so existing external deep links still land on a relevant blurb. --- docs/api/hooks.md | 472 ++-------------------- docs/using-react-redux/accessing-store.md | 2 +- 2 files changed, 24 insertions(+), 450 deletions(-) diff --git a/docs/api/hooks.md b/docs/api/hooks.md index fdbcc2274..92c65b0d6 100644 --- a/docs/api/hooks.md +++ b/docs/api/hooks.md @@ -1,9 +1,9 @@ --- id: hooks title: Hooks -sidebar_label: Hooks +sidebar_label: Hooks Overview hide_title: true -description: 'API > Hooks: the `useSelector` and `useDispatch` hooks`' +description: 'API > Hooks: an overview of the React Redux hooks APIs' ---   @@ -24,6 +24,8 @@ The existing `connect` API still works and will continue to be supported, but th These hooks were first added in v7.1.0. +Each hook has its own API reference page. This page provides an overview of the available hooks and shared usage information. + ## Using Hooks in a React Redux App As with `connect()`, you should start by wrapping your entire application in a `` component to make the store available throughout the component tree: @@ -42,399 +44,33 @@ root.render( From there, you may import any of the listed React Redux hooks APIs and use them within your function components. -## `useSelector()` +## The Hooks APIs -```ts -type RootState = ReturnType -type SelectorFn = (state: RootState) => Selected -type EqualityFn = (a: any, b: any) => boolean -export type DevModeCheckFrequency = 'never' | 'once' | 'always' +### `useSelector()` -interface UseSelectorOptions { - equalityFn?: EqualityFn - devModeChecks?: { - stabilityCheck?: DevModeCheckFrequency - identityFunctionCheck?: DevModeCheckFrequency - } -} +Reads a value from the Redux store state using a selector function, and subscribes the component so it re-renders when that value changes. This is the primary hook for reading Redux state in components. -const result: Selected = useSelector( - selector: SelectorFn, - options?: EqualityFn | UseSelectorOptions -) -``` +**[Full `useSelector` API reference →](./useSelector.md)** -Allows you to extract data from the Redux store state for use in this component, using a selector function. +### `useDispatch()` -:::info +Returns the store's `dispatch` function, so components can dispatch actions in event handlers and effects. -The selector function should be [pure](https://en.wikipedia.org/wiki/Pure_function) since it is potentially executed multiple times and at arbitrary points in time. - -See [Using Redux: Deriving Data with Selectors](https://redux.js.org/usage/deriving-data-selectors) in the Redux docs for more details on writing and using selector functions. - -::: +**[Full `useDispatch` API reference →](./useDispatch.md)** -The selector will be called with the entire Redux store state as its only argument. The selector may return any value as a result, including directly returning a value that was nested inside `state`, or deriving new values. The return value of the selector will be used as the return value of the `useSelector()` hook. +### `useStore()` -The selector will be run whenever the function component renders (unless its reference hasn't changed since a previous render of the component so that a cached result can be returned by the hook without re-running the selector). `useSelector()` will also subscribe to the Redux store, and run your selector whenever an action is dispatched. +Returns the Redux store instance itself. Rarely needed - prefer `useSelector` for reading state. -When an action is dispatched, `useSelector()` will do a reference comparison of the previous selector result value and the current result value. If they are different, the component will be forced to re-render. If they are the same, the component will not re-render. `useSelector()` uses strict `===` reference equality checks by default, not shallow equality (see the following section for more details). - -The selector is approximately equivalent to the [`mapStateToProps` argument to `connect`](../using-react-redux/connect-extracting-data-with-mapStateToProps.md) conceptually. - -You may call `useSelector()` multiple times within a single function component. Each call to `useSelector()` creates an individual subscription to the Redux store. Because of the React update batching behavior used in React Redux v7, a dispatched action that causes multiple `useSelector()`s in the same component to return new values _should_ only result in a single re-render. - -:::info - -There are potential edge cases with using props in selectors that may cause issues. See the [Usage Warnings](#usage-warnings) section of this page for further details. - -::: +**[Full `useStore` API reference →](./useStore.md)** -### Equality Comparisons and Updates - -When the function component renders, the provided selector function will be called and its result will be returned -from the `useSelector()` hook. (A cached result may be returned by the hook without re-running the selector if it's the same function reference as on a previous render of the component.) - -However, when an action is dispatched to the Redux store, `useSelector()` only forces a re-render if the selector result -appears to be different than the last result. The default comparison is a strict `===` reference -comparison. This is different than `connect()`, which uses shallow equality checks on the results of `mapState` calls -to determine if re-rendering is needed. This has several implications on how you should use `useSelector()`. - -With `mapState`, all individual fields were returned in a combined object. It didn't matter if the return object was -a new reference or not - `connect()` just compared the individual fields. With `useSelector()`, returning a new object -every time will _always_ force a re-render by default. If you want to retrieve multiple values from the store, you can: - -- Call `useSelector()` multiple times, with each call returning a single field value -- Use Reselect or a similar library to create a memoized selector that returns multiple values in one object, but - only returns a new object when one of the values has changed. -- Use the `shallowEqual` function from React-Redux as the `equalityFn` argument to `useSelector()`, like: - -```js -import { shallowEqual, useSelector } from 'react-redux' - -// Pass it as the second argument directly -const selectedData = useSelector(selectorReturningObject, shallowEqual) - -// or pass it as the `equalityFn` field in the options argument -const selectedData = useSelector(selectorReturningObject, { - equalityFn: shallowEqual, -}) -``` - -- Use a custom equality function as the `equalityFn` argument to `useSelector()`, like: - -```js -import { useSelector } from 'react-redux' - -// equality function -const customEqual = (oldValue, newValue) => oldValue === newValue - -// later -const selectedData = useSelector(selectorReturningObject, customEqual) -``` - -The optional comparison function also enables using something like Lodash's `_.isEqual()` or Immutable.js's comparison capabilities. - -### `useSelector` Examples - -Basic usage: - -```jsx -import React from 'react' -import { useSelector } from 'react-redux' - -export const CounterComponent = () => { - const counter = useSelector((state) => state.counter) - return
{counter}
-} -``` - -Using props via closure to determine what to extract: - -```jsx -import React from 'react' -import { useSelector } from 'react-redux' - -export const TodoListItem = (props) => { - const todo = useSelector((state) => state.todos[props.id]) - return
{todo.text}
-} -``` - -#### Using memoizing selectors - -When using `useSelector` with an inline selector as shown above, a new instance of the selector is created whenever the component is rendered. This works as long as the selector does not maintain any state. However, memoizing selectors (e.g. created via `createSelector` from `reselect`) do have internal state, and therefore care must be taken when using them. Below you can find typical usage scenarios for memoizing selectors. - -When the selector does only depend on the state, simply ensure that it is declared outside of the component so that the same selector instance is used for each render: - -```jsx -import React from 'react' -import { useSelector } from 'react-redux' -import { createSelector } from 'reselect' - -const selectNumCompletedTodos = createSelector( - (state) => state.todos, - (todos) => todos.filter((todo) => todo.completed).length, -) - -export const CompletedTodosCounter = () => { - const numCompletedTodos = useSelector(selectNumCompletedTodos) - return
{numCompletedTodos}
-} - -export const App = () => { - return ( - <> - Number of completed todos: - - - ) -} -``` - -The same is true if the selector depends on the component's props, but will only ever be used in a single instance of a single component: - -```jsx -import React from 'react' -import { useSelector } from 'react-redux' -import { createSelector } from 'reselect' - -const selectCompletedTodosCount = createSelector( - (state) => state.todos, - (_, completed) => completed, - (todos, completed) => - todos.filter((todo) => todo.completed === completed).length, -) - -export const CompletedTodosCount = ({ completed }) => { - const matchingCount = useSelector((state) => - selectCompletedTodosCount(state, completed), - ) - - return
{matchingCount}
-} - -export const App = () => { - return ( - <> - Number of done todos: - - - ) -} -``` - -However, when the selector is used in multiple component instances and depends on the component's props, you need to ensure that selector's memoization behavior is properly configured (see [here](https://reselect.js.org/faq/#can-i-share-a-selector-across-multiple-component-instances) for details). - -### Development mode checks - -`useSelector` runs some extra checks in development mode to watch for unexpected behavior. These checks do not run in production builds. - -:::info - -These checks were first added in v8.1.0 - -::: - -#### Selector result stability - -In development, the provided selector function is run an extra time with the same parameter during the first call to `useSelector`, and warns in the console if the selector returns a different result (based on the `equalityFn` provided). - -This is important, as **a selector that returns a different result reference when called again with the same inputs will cause unnecessary rerenders**. - -```ts -// this selector will return a new object reference whenever called, -// which causes the component to rerender after *every* action is dispatched -const { count, user } = useSelector((state) => ({ - count: state.count, - user: state.user, -})) -``` - -If a selector result is suitably stable (or the selector is memoized), it will not return a different result and no warning will be logged. - -By default, this will only happen when the selector is first called. You can configure the check in the Provider or at each `useSelector` call. - -```tsx title="Global setting via context" - - {children} - -``` - -```tsx title="Individual hook setting" -function Component() { - const count = useSelector(selectCount, { - devModeChecks: { stabilityCheck: 'never' }, - }) - // run once (default) - const user = useSelector(selectUser, { - devModeChecks: { stabilityCheck: 'once' }, - }) - // ... -} -``` - -#### Identity Function (`state => state`) Check - -:::danger Breaking Change! - -This was previously referred to as `noopCheck`. - -::: +### `useSignalSelector()` -In development, a check is conducted on the result returned by the selector. It warns in the console if the result is the same as the parameter passed in, i.e. the root state. +_added in v9.4.0_ -**A `useSelector` call returning the entire root state is almost always a mistake**, as it means the component will rerender whenever _anything_ in state changes. Selectors should be as granular as possible, like `state => state.some.nested.field`. +A fine-grained variant of `useSelector` that automatically tracks which parts of the state the selector reads, and only re-runs the selector when those parts change. Requires wrapping the app in [``](./SignalProvider.md) instead of ``. -```ts no-transpile -// BAD: this selector returns the entire state, meaning that the component will rerender unnecessarily -const { count, user } = useSelector((state) => state) - -// GOOD: instead, select only the state you need, calling useSelector as many times as needed -const count = useSelector((state) => state.count.value) -const user = useSelector((state) => state.auth.currentUser) -``` - -By default, this will only happen when the selector is first called. You can configure the check in the Provider or at each `useSelector` call. - -```tsx title="Global setting via context" - - {children} - -``` - -```tsx title="Individual hook setting" -function Component() { - const count = useSelector(selectCount, { - devModeChecks: { identityFunctionCheck: 'never' }, - }) - // run once (default) - const user = useSelector(selectUser, { - devModeChecks: { identityFunctionCheck: 'once' }, - }) - // ... -} -``` - -### Comparisons with `connect` - -There are some differences between the selectors passed to `useSelector()` and a `mapState` function: - -- The selector may return any value as a result, not just an object. -- The selector normally _should_ return just a single value, and not an object. If you do return an object or an array, be sure to use a memoized selector to avoid unnecessary re-renders. -- The selector function does _not_ receive an `ownProps` argument. However, props can be used through closure (see the examples above) or by using a curried selector. -- You can use the `equalityFn` option to customize the comparison behavior - -## `useDispatch()` - -```ts -import type { Dispatch } from 'redux' -const dispatch: Dispatch = useDispatch() -``` - -This hook returns a reference to the `dispatch` function from the Redux store. You may use it to dispatch actions as needed. - -#### Examples - -```jsx -import React from 'react' -import { useDispatch } from 'react-redux' - -export const CounterComponent = ({ value }) => { - const dispatch = useDispatch() - - return ( -
- {value} - -
- ) -} -``` - -When passing a callback using `dispatch` to a child component, you may sometimes want to memoize it with [`useCallback`](https://react.dev/reference/react/useCallback). _If_ the child component is trying to optimize render behavior using `React.memo()` or similar, this avoids unnecessary rendering of child components due to the changed callback reference. - -```jsx -import React, { useCallback } from 'react' -import { useDispatch } from 'react-redux' - -export const CounterComponent = ({ value }) => { - const dispatch = useDispatch() - const incrementCounter = useCallback( - () => dispatch({ type: 'increment-counter' }), - [dispatch], - ) - - return ( -
- {value} - -
- ) -} - -export const MyIncrementButton = React.memo(({ onIncrement }) => ( - -)) -``` - -:::info - -The `dispatch` function reference will be stable as long as the same store instance is being passed to the ``. -Normally, that store instance never changes in an application. - -However, the React hooks lint rules do not know that `dispatch` should be stable, and will warn that the `dispatch` variable -should be added to dependency arrays for `useEffect` and `useCallback`. The simplest solution is to do just that: - -```js -export const Todos = () => { - const dispatch = useDispatch() - - useEffect(() => { - dispatch(fetchTodos()) - // highlight-start - // Safe to add dispatch to the dependencies array - }, [dispatch]) - // highlight-end -} -``` - -::: - -## `useStore()` - -```ts -import type { Store } from 'redux' -const store: Store = useStore() -``` - -This hook returns a reference to the same Redux store that was passed in to the `` component. - -This hook should probably not be used frequently. Prefer `useSelector()` as your primary choice. However, this may be useful for less common scenarios that do require access to the store, such as replacing reducers. - -#### Examples - -```js -import React from 'react' -import { useStore } from 'react-redux' - -export const ExampleComponent = ({ value }) => { - const store = useStore() - - const onClick = () => { - // Not _recommended_, but safe - // This avoids subscribing to the state via `useSelector` - // Prefer moving this logic into a thunk instead - const numTodos = store.getState().todos.length - } - - // EXAMPLE ONLY! Do not do this in a real app. - // The component will not automatically update if the store state changes - return
{store.getState().todos.length}
-} -``` +**[Full `useSignalSelector` API reference →](./useSignalSelector.md)** ## Custom context @@ -469,75 +105,13 @@ export function MyProvider({ children }) { } ``` -## Usage Warnings - -### Stale Props and "Zombie Children" - -:::info - -The React-Redux hooks API has been production-ready since we released it in v7.1.0, and **we recommend using the hooks API as the default approach in your components**. However, there are a couple of edge cases that can occur, and **we're documenting those so that you can be aware of them**. - -In practice, these are a rare concern - we've received far more comments about these being in the docs than actual reports of these being a real problem in an app. - -::: - -One of the most difficult aspects of React Redux's implementation is ensuring that if your `mapStateToProps` function is defined as `(state, ownProps)`, it will be called with the "latest" props every time. Up through version 4, there were recurring bugs reported involving edge case situations, such as errors thrown from a `mapState` function for a list item whose data had just been deleted. - -Starting with version 5, React Redux has attempted to guarantee that consistency with `ownProps`. In version 7, that is implemented using a custom `Subscription` class internally in `connect()`, which forms a nested hierarchy. This ensures that connected components lower in the tree will only receive store update notifications once the nearest connected ancestor has been updated. However, this relies on each `connect()` instance overriding part of the internal React context, supplying its own unique `Subscription` instance to form that nesting, and rendering the `` with that new context value. - -With hooks, there is no way to render a context provider, which means there's also no nested hierarchy of subscriptions. Because of this, the "stale props" and "zombie child" issues may potentially re-occur in an app that relies on using hooks instead of `connect()`. - -Specifically, "stale props" means any case where: - -- a selector function relies on this component's props to extract data -- a parent component _would_ re-render and pass down new props as a result of an action -- but this component's selector function executes before this component has had a chance to re-render with those new props - -Depending on what props were used and what the current store state is, this _may_ result in incorrect data being returned from the selector, or even an error being thrown. - -"Zombie child" refers specifically to the case where: - -- Multiple nested connected components are mounted in a first pass, causing a child component to subscribe to the store before its parent -- An action is dispatched that deletes data from the store, such as a todo item -- The parent component _would_ stop rendering that child as a result -- However, because the child subscribed first, its subscription runs before the parent stops rendering it. When it reads a value from the store based on props, that data no longer exists, and if the extraction logic is not careful, this may result in an error being thrown. - -`useSelector()` tries to deal with this by catching all errors that are thrown when the selector is executed due to a store update (but not when it is executed during rendering). When an error occurs, the component will be forced to render, at which point the selector is executed again. This works as long as the selector is a pure function and you do not depend on the selector throwing errors. - -If you prefer to deal with this issue yourself, here are some possible options for avoiding these problems altogether with `useSelector()`: - -- Don't rely on props in your selector function for extracting data -- In cases where you do rely on props in your selector function _and_ those props may change over time, _or_ the data you're extracting may be based on items that can be deleted, try writing the selector functions defensively. Don't just reach straight into `state.todos[props.id].name` - read `state.todos[props.id]` first, and verify that it exists before trying to read `todo.name`. -- Because `connect` adds the necessary `Subscription` to the context provider and delays evaluating child subscriptions until the connected component has re-rendered, putting a connected component in the component tree just above the component using `useSelector` will prevent these issues as long as the connected component gets re-rendered due to the same store update as the hooks component. - -:::info +See [Accessing the Store](../using-react-redux/accessing-store.md) for more details on context usage, multiple stores, and accessing the store directly. -For a longer description of these scenarios, see: - -- ["Stale props and zombie children in Redux" by Kai Hao](https://kaihao.dev/posts/Stale-props-and-zombie-children-in-Redux) -- [A chat log that describes the problems in more detail](https://gist.github.com/markerikson/faac6ae4aca7b82a058e13216a7888ec) -- [issue #1179](https://github.com/reduxjs/react-redux/issues/1179) - -::: - -### Performance - -As mentioned earlier, by default `useSelector()` will do a reference equality comparison of the selected value when running the selector function after an action is dispatched, and will only cause the component to re-render if the selected value changed. However, unlike `connect()`, `useSelector()` does not prevent the component from re-rendering due to its parent re-rendering, even if the component's props did not change. - -If further performance optimizations are necessary, you may consider wrapping your function component in `React.memo()`: +## Usage Warnings -```jsx -const CounterComponent = ({ name }) => { - const counter = useSelector((state) => state.counter) - return ( -
- {name}: {counter} -
- ) -} +Because the hooks subscribe to the store individually rather than forming the nested subscription hierarchy that `connect` uses, there are a couple of edge cases around "stale props" and "zombie children" that can occur when selectors rely on props. In practice these are a rare concern - `useSelector` handles them automatically by catching selector errors during store updates and re-running the selector on the next render. -export const MemoizedCounterComponent = React.memo(CounterComponent) -``` +See [`useSelector`: Stale Props and "Zombie Children"](./useSelector.md#usage-warnings) for the full explanation. ## Hooks Recipes @@ -551,7 +125,7 @@ This hook was in our original alpha release, but removed in `v7.1.0-alpha.4`, ba That suggestion was based on "binding action creators" not being as useful in a hooks-based use case, and causing too much conceptual overhead and syntactic complexity. -You should probably prefer to call the [`useDispatch`](#usedispatch) hook in your components to retrieve a reference to `dispatch`, +You should probably prefer to call the [`useDispatch`](./useDispatch.md) hook in your components to retrieve a reference to `dispatch`, and manually call `dispatch(someActionCreator())` in callbacks and effects as needed. You may also use the Redux [`bindActionCreators`](https://redux.js.org/api/bindactioncreators) function in your own code to bind action creators, or "manually" bind them like `const boundAddTodo = (text) => dispatch(addTodo(text))`. diff --git a/docs/using-react-redux/accessing-store.md b/docs/using-react-redux/accessing-store.md index 6f4bed7bf..d2cc56288 100644 --- a/docs/using-react-redux/accessing-store.md +++ b/docs/using-react-redux/accessing-store.md @@ -30,7 +30,7 @@ React Redux's `` component uses `` to put ## Using the `useStore` Hook -The [`useStore` hook](../api/hooks.md#usestore) returns the current store instance from the default `ReactReduxContext`. If you truly need to access the store, this is the recommended approach. +The [`useStore` hook](../api/useStore.md) returns the current store instance from the default `ReactReduxContext`. If you truly need to access the store, this is the recommended approach. ## Providing Custom Context From 89f18fee8609daa46612523221d69ef3b2531c4a Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 16 Aug 2026 01:08:23 -0400 Subject: [PATCH 65/95] docs: add SignalProvider API page --- docs/api/Provider.md | 2 + docs/api/SignalProvider.md | 82 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 docs/api/SignalProvider.md diff --git a/docs/api/Provider.md b/docs/api/Provider.md index 28ae2cc39..bd1c93450 100644 --- a/docs/api/Provider.md +++ b/docs/api/Provider.md @@ -18,6 +18,8 @@ Since any React component in a React Redux app can be connected to the store, mo The [Hooks](./hooks.md) and [`connect`](./connect.md) APIs can then access the provided store instance via React's Context mechanism. +If you want to use the [`useSignalSelector`](./useSignalSelector.md) hook, use the drop-in [``](./SignalProvider.md) replacement instead. + ### Props ```ts diff --git a/docs/api/SignalProvider.md b/docs/api/SignalProvider.md new file mode 100644 index 000000000..760aaaf98 --- /dev/null +++ b/docs/api/SignalProvider.md @@ -0,0 +1,82 @@ +--- +id: SignalProvider +title: SignalProvider +sidebar_label: SignalProvider +hide_title: true +description: 'API > SignalProvider: a drop-in replacement for Provider that enables signal-based selector tracking' +--- + +  + +# `SignalProvider` + +_added in v9.4.0_ + +`` is a drop-in replacement for [``](./Provider.md) that enables fine-grained, signal-based state tracking for the [`useSignalSelector`](./useSignalSelector.md) hook. + +Like ``, it makes the Redux `store` available to any nested components. In addition, it maintains an internal graph of "path signals" that represent individual fields within the Redux state. On every dispatch, it diffs the previous and next state, and updates only the signals for the fields that actually changed. `useSignalSelector` uses that graph to re-run selectors only when a field they actually read has changed. + +```jsx +import { SignalProvider } from 'react-redux' + +const store = createStore(rootReducer) + +const root = ReactDOM.createRoot(document.getElementById('root')) +root.render( + + + , +) +``` + +:::tip + +All of the standard React Redux APIs - [`useSelector`](./useSelector.md), [`useDispatch`](./useDispatch.md), [`useStore`](./useStore.md), and [`connect`](./connect.md) - work unchanged inside a ``. They receive the plain Redux state and behave exactly as they do under ``. Only components that use `useSignalSelector` opt in to the fine-grained tracking behavior. + +::: + +## API Reference + +### Props + +`SignalProvider` accepts the same props as [``](./Provider.md): + +| Name | Description | +| :----------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `store` | The single Redux store in your application. | +| `children` | The top-level React elements in your component tree. | +| `context?` | An optional custom context instance, as with ``. | +| `serverState?` | An optional server state snapshot, used during initial hydration to keep the UI output consistent with the server-generated HTML. | +| `stabilityCheck?` | Global default for the `useSelector`/`useSignalSelector` [selector stability check](./useSelector.md#selector-result-stability) (development only). | +| `identityFunctionCheck?` | Global default for the [identity function check](./useSelector.md#identity-function-state--state-check) (development only). | + +## Usage Guide + +### Differences from `Provider` + +`` behaves like ``, with these differences: + +#### The state must be a plain object + +The root of your Redux state must be a plain JavaScript object, because the state diffing and path tracking operate on its keys. This is reflected in the types: `SignalProvider`'s state generic is constrained to `S extends object`, where `Provider` accepts any state type. + +Standard Redux usage with `combineReducers` or Redux Toolkit's `configureStore` always produces a plain object root, so this only matters if your root reducer returns a primitive, a class instance, or another non-plain value. Those setups should keep using `` and `useSelector`. + +#### Every dispatch runs a state diff + +Before notifying subscribers, `` compares the previous and next state and updates the changed path signals. This is a structural walk of the changed portions of the state, and adds some work to each dispatch in exchange for skipping selector re-runs in components. See the [`useSignalSelector` performance notes](./useSignalSelector.md#performance-characteristics) for the tradeoffs. + +#### The signal graph is keyed to the store + +Each `` owns one signal registry per store instance. Swapping the `store` prop starts over with a fresh registry, the same way `` resets its subscriptions. Multiple ``s with different stores (via custom contexts) keep fully independent signal graphs. + +### When should I use `SignalProvider`? + +Use `` if you want to adopt [`useSignalSelector`](./useSignalSelector.md) anywhere in your app. Since all the other React Redux APIs work unchanged, it is safe to swap in at the root and migrate components incrementally. + +If your app does not use `useSignalSelector`, there is no benefit to `` - the per-dispatch diff adds cost without anything consuming its results. Use the standard ``. + +## See Also + +- [`useSignalSelector`](./useSignalSelector.md): the hook that consumes the signal graph +- [`Provider`](./Provider.md): the standard store provider From 64737cf718c009dbfbed2425b5717998a04bdffe Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 16 Aug 2026 01:08:23 -0400 Subject: [PATCH 66/95] docs: add useSignalSelector API page --- docs/api/useSignalSelector.md | 197 ++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 docs/api/useSignalSelector.md diff --git a/docs/api/useSignalSelector.md b/docs/api/useSignalSelector.md new file mode 100644 index 000000000..6ba3f24c1 --- /dev/null +++ b/docs/api/useSignalSelector.md @@ -0,0 +1,197 @@ +--- +id: useSignalSelector +title: useSignalSelector +sidebar_label: useSignalSelector() +hide_title: true +description: 'API > useSignalSelector: fine-grained state selection with automatic dependency tracking' +--- + +  + +# `useSignalSelector()` + +```ts +const result: Selected = useSignalSelector( + selector: (state: RootState) => Selected, + options?: EqualityFn | UseSelectorOptions +) +``` + +_added in v9.4.0_ + +A fine-grained variant of [`useSelector`](./useSelector.md). It reads a value from the Redux store state using a selector function, exactly like `useSelector` - but it also automatically tracks _which fields of the state the selector actually read_, and only re-runs the selector when one of those fields changes. + +:::info Requires `SignalProvider` + +`useSignalSelector` only works inside a [``](./SignalProvider.md), which maintains the signal graph the hook depends on. Calling it under a standard `` throws an error. All other React Redux APIs (`useSelector`, `useDispatch`, `connect`) work unchanged under ``, so the two hooks can be mixed freely during a migration. + +::: + +```jsx +import React from 'react' +import { useSignalSelector } from 'react-redux' + +export const CounterComponent = () => { + // Re-runs only when `state.counter` changes - + // dispatches that change other parts of the state are skipped entirely + const counter = useSignalSelector((state) => state.counter) + return
{counter}
+} +``` + +With `useSelector`, every dispatched action re-runs every subscribed selector, and the results are compared to decide whether to re-render. With `useSignalSelector`, the selector receives a tracking proxy of the state. Each field access is recorded, and on later dispatches the selector is only re-run if one of the recorded fields actually changed. For apps with many mounted components, this can significantly reduce the work done per dispatch. + +## API Reference + +### Signature + +The signature is identical to [`useSelector`](./useSelector.md): + +```ts +type DevModeCheckFrequency = 'never' | 'once' | 'always' + +interface UseSelectorOptions { + equalityFn?: EqualityFn + devModeChecks?: { + stabilityCheck?: DevModeCheckFrequency + identityFunctionCheck?: DevModeCheckFrequency + } +} + +const result: Selected = useSignalSelector( + selector: (state: RootState) => Selected, + options?: EqualityFn | UseSelectorOptions +) +``` + +### Parameters + +Both parameters behave exactly as they do in [`useSelector`](./useSelector.md#parameters): + +| Name | Description | +| :--------- | :---------------------------------------------------------------------------------------------------------------------------------------------- | +| `selector` | A function that receives the entire Redux store state and returns the value this component needs. Must be [pure](./useSelector.md). | +| `options?` | Either an equality function (such as `shallowEqual`), or an options object with `equalityFn` and `devModeChecks` fields, as in `useSelector`. | + +The [development mode checks](./useSelector.md#development-mode-checks) (`stabilityCheck` and `identityFunctionCheck`) work the same way, and can be configured globally on `` or per hook call. + +### Returns + +The return value of the selector function. Returned values are always plain Redux state values - the tracking proxies used during selector evaluation never leak into your components (see [Proxies and identity](#proxies-and-identity)). + +## Usage Guide + +### How it works + +1. On the first run, the selector is called with a tracking proxy wrapping the state. Every field the selector reads (`state.todos`, `todo.completed`, and so on) is recorded as a dependency path. +2. On every dispatch, [``](./SignalProvider.md) diffs the previous and next state and marks the changed paths. +3. The selector re-runs only if one of its recorded paths changed. If none did, the hook returns the cached result without calling the selector at all. +4. Each run re-records dependencies from scratch, so selectors with conditional logic stay correct as their reads change. + +This is the same family of technique used by `proxy-memoize` and by fine-grained reactivity systems like Solid and Vue, applied to Redux state. + +### When to use it + +`useSignalSelector` helps most when: + +- Many components are mounted at once, each selecting small slices of a large state +- Your app dispatches frequently, and most dispatches are irrelevant to most components +- Selectors read narrow, deep paths (`state.entities.todos[id].completed`) + +It helps least (and can cost slightly more than `useSelector`) when most dispatches change state that most components read anyway, or when selectors read broad swaths of the state. Tracked selector evaluation itself costs roughly 3-4x an untracked run, so the win comes from the runs that are *skipped*, not from the runs themselves. + +Because `useSelector` and `useSignalSelector` coexist under one ``, you can adopt it incrementally in the components with the highest subscription counts. + +### Differences from `useSelector` + +The observable behavior - what your components render, when they re-render, what values they receive - matches `useSelector` in almost all cases. The differences fall into three groups. + +#### Hard constraints + +| Constraint | Explanation | +| :--- | :--- | +| **Requires ``** | Calling `useSignalSelector` under a plain `` throws. | +| **The state root must be a plain object** | Path tracking operates on the root object's keys. If the root is not a plain object, the hook still works, but falls back to untracked evaluation (behaving like `useSelector`). | +| **`state => state` never updates** | A selector that returns the entire root state records no field reads, so no dispatch will ever re-run it. This is already an anti-pattern (`useSelector`'s [identity function check](./useSelector.md#identity-function-state--state-check) warns about it), but with `useSignalSelector` it goes from "wasteful" to "broken": the component will not update. The dev-mode check catches this. | +| **Mutations inside `Map`, `Set`, `Date`, and class instances are not tracked** | Only plain objects and arrays get per-field tracking. Other object types are tracked by reference: replacing the instance triggers an update, but reading `myMap.get('key')` does not record a per-entry dependency. Since Redux state should be [immutably updated](https://redux.js.org/style-guide/#do-not-mutate-state) plain data anyway, this mostly matters for state produced by libraries. | + +#### Gotchas + +These are behaviors that differ from `useSelector` in ways you might notice, but that have straightforward workarounds - or need no workaround at all. + +##### Proxies and identity + +Inside the selector, nested objects you read from `state` are tracking proxies, not the raw state objects. The values are identical; the object identity is not. This matters only for `===` comparisons **inside the selector body** against object references obtained *outside* the current selector run: + +```ts +import { unwrap, useSignalSelector } from 'react-redux' + +const selected = useSignalSelector((state) => { + // ❌ `state.items` entries are proxies; `previouslySavedItem` is raw. + // This comparison is always false: + return state.items.find((item) => item === previouslySavedItem) +}) + +const selected = useSignalSelector((state) => { + // ✅ unwrap() resolves a proxy to its raw object: + return state.items.find((item) => unwrap(item) === previouslySavedItem) +}) +``` + +See [`unwrap`](./unwrap.md) for details. Comparisons between two values both read from `state` in the same selector run work correctly without unwrapping. + +**Proxies never escape the selector.** The hook unwraps the selector's return value before handing it to React, so your components, effects, equality functions, and dispatched actions always see plain Redux state. `console.log` of a selector result shows plain data. For the same reason, avoid *storing* a state object read during one selector run in an external variable for use later - the same rule as holding onto an Immer draft. Use `unwrap()` first if you need to do this. + +##### Memoized (Reselect) selectors + +Memoized selectors created with Reselect's `createSelector` work with `useSignalSelector`, following the same rules as with [`useSelector`](./useSelector.md#using-memoizing-selectors). Unchanged parts of the state keep stable proxy identities across selector runs, so Reselect's input comparisons behave the way they do with raw state: changed inputs produce cache misses and recalculation, unchanged inputs return the cached result. Cached result values are unwrapped the same way as direct returns. + +##### Coarse-grained fallback for broad selectors + +Selectors that enumerate the root state's keys (`Object.keys(state)`, spreading `{...state}`) or otherwise read the root without narrowing to specific fields cannot be tracked at a useful granularity. They still work correctly, but re-evaluate more often and set up their tracking eagerly, costing more at mount. Narrow selectors are both the best practice and the fast path. + +##### Aliased state objects + +If the same object instance is reachable via two different state paths (for example, an entity stored in two lookup tables), tracking attributes reads to the first path encountered. The consequence is only ever *extra* selector re-runs, never missed updates. + +##### Stale props and "zombie children" + +`useSignalSelector` handles the [stale props / zombie children scenarios](./useSelector.md#usage-warnings) with the same user-visible outcome as `useSelector`: an error thrown from a selector during a store update does not crash the dispatch; the component re-renders and the selector re-runs with the latest props, and only a persistent error propagates to your error boundary. The internal mechanism differs (there is no nested `Subscription` hierarchy under ``), but the guidance from the `useSelector` docs - prefer defensive reads like checking `state.todos[props.id]` before using it - applies unchanged. + +##### Dev-mode stability warning shows raw state + +When the [selector stability check](./useSelector.md#selector-result-stability) fires, the logged `state` argument is the raw state object, not the tracking proxy, so inspecting it in the console does not register phantom dependencies. The warning's meaning and configuration are identical to `useSelector`. + +#### Performance characteristics + +- **Tracked evaluation costs roughly 3-4x an untracked run.** The payoff is the dispatches where the selector does not run at all. Apps where most dispatches are irrelevant to most components come out well ahead; apps where every dispatch touches state that every component reads gain nothing and pay the diff cost inside each dispatch. +- **Iteration helpers are optimized for depth-1 reads.** Array scans like `find`, `filter`, `some`, `every`, `includes`, and `slice` are tracked at the whole-array level with per-column optimization: a callback reading `item.done` depends on that one column rather than every field of every element. Reads two levels deep inside a scan callback (`item.meta.done`), and methods like `map`, `forEach`, and `reduce`, fall back to depending on the array as a whole - still correct, just coarser. +- **Conditional selectors accumulate both branches.** A selector like `state.mode === 'a' ? state.a.x : state.b.y` re-records dependencies each run, but a component that alternates between branches will track whichever paths it has read while mounted. This widens the update surface slightly; it never causes missed updates. + +### Migrating an app with a bundler alias + +The `react-redux/signals` entry point re-exports the entire `react-redux` API, with `useSelector` aliased to `useSignalSelector` and `Provider` aliased to `SignalProvider`. This makes trying signals across a whole app a one-line bundler config change instead of a search-and-replace: + +```js title="vite.config.js" +export default defineConfig({ + resolve: { + alias: { + 'react-redux': 'react-redux/signals', + }, + }, +}) +``` + +The explicit names (`useSignalSelector`, `SignalProvider`, `unwrap`) are also exported from both entry points, so code written against either style keeps working. + +Before flipping the alias for a whole app, audit for the patterns listed above - particularly selectors returning the root state, identity comparisons against captured references inside selectors, and in-place reads of `Map`/`Set`/class-instance state. + +### TypeScript + +`useSignalSelector` supports the same pre-typed setup as `useSelector` via `useSignalSelector.withTypes()`. See [Usage with TypeScript](../using-react-redux/usage-with-typescript.md) for details on defining pre-typed hooks. + +## See Also + +- [`SignalProvider`](./SignalProvider.md): required to use this hook +- [`unwrap`](./unwrap.md): resolving tracking proxies for identity comparisons +- [`useSelector`](./useSelector.md): the standard selector hook this one mirrors From 81edf0e0aa0e78a089aa2d1794124c2f956b4339 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 16 Aug 2026 01:08:23 -0400 Subject: [PATCH 67/95] docs: add unwrap API page --- docs/api/unwrap.md | 75 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/api/unwrap.md diff --git a/docs/api/unwrap.md b/docs/api/unwrap.md new file mode 100644 index 000000000..3cd0877a2 --- /dev/null +++ b/docs/api/unwrap.md @@ -0,0 +1,75 @@ +--- +id: unwrap +title: unwrap +sidebar_label: unwrap() +hide_title: true +description: 'API > unwrap: resolving useSignalSelector tracking proxies to raw state objects' +--- + +  + +# `unwrap()` + +```ts +const rawValue: T = unwrap(value: T) +``` + +_added in v9.4.0_ + +Resolves a [`useSignalSelector`](./useSignalSelector.md) tracking proxy to the raw state object it wraps. Safe to call on any value: non-proxy values (including primitives and `null`) are returned unchanged. + +## API Reference + +### Parameters + +| Name | Description | +| :------ | :--------------------------------------- | +| `value` | The value to unwrap - a proxy or any other value. | + +### Returns + +The raw state object if `value` is a tracking proxy, otherwise `value` itself. + +## Usage Guide + +Inside a `useSignalSelector` selector, nested objects read from `state` are tracking proxies. A proxy is never `===` to the raw object it wraps, so identity comparisons against object references captured *outside* the current selector run always fail. `unwrap` resolves the proxy so the comparison works: + +```ts +import { unwrap, useSignalSelector } from 'react-redux' + +const selected = useSignalSelector((state) => { + // ❌ always false: `item` is a proxy, `currentItem` is a raw object + // return state.items.find((item) => item === currentItem) + + // ✅ compare raw object to raw object + return state.items.find((item) => unwrap(item) === currentItem) +}) +``` + +The other use case is intentionally keeping a reference to a state object outside the selector (in a module-level variable, a ref, or a dispatched action payload built inside the selector). Unwrap it first, so you store the raw object rather than a proxy tied to a previous selector run: + +```ts +let lastSeenUser = null + +const userName = useSignalSelector((state) => { + lastSeenUser = unwrap(state.auth.currentUser) + return state.auth.currentUser.name +}) +``` + +You do not need `unwrap` for: + +- **Selector return values.** The hook unwraps them automatically - components, equality functions, and effects always receive plain state values. +- **Comparisons between two values both read from `state` in the same selector run.** Proxy identities are consistent within a run. +- **Anything in `useSelector`.** The standard hook never sees proxies; `unwrap` is a pass-through there. + +:::caution + +Reading fields from an unwrapped object inside the selector bypasses dependency tracking - changes to those fields will not re-run the selector. Unwrap as late as possible, and only for identity comparisons or for storing a reference, not for general field access. + +::: + +## See Also + +- [`useSignalSelector`](./useSignalSelector.md), especially the [Proxies and identity](./useSignalSelector.md#proxies-and-identity) section +- [`SignalProvider`](./SignalProvider.md) From 1576484af9d2499bdfca390f776f9d0b1c499a79 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 16 Aug 2026 01:08:23 -0400 Subject: [PATCH 68/95] docs: recategorize API sidebar into Components/Hooks/Other Adds lowercase redirect aliases for the camelCase page URLs and marks batch as deprecated pending removal in the next major. --- docs/api/batch.md | 4 +++- website/_redirects | 7 +++++++ website/sidebars.ts | 27 ++++++++++++++++++++++++++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/api/batch.md b/docs/api/batch.md index 9b0beb0b8..77b407288 100644 --- a/docs/api/batch.md +++ b/docs/api/batch.md @@ -16,10 +16,12 @@ batch((fn: () => void)) _added in v7.0.0_ -:::info +:::caution Deprecated **If you're using React 18, you do not need to use the `batch` API**. React 18 automatically batches _all_ state updates, no matter where they're queued. +`batch` is now a no-op that immediately runs the callback, and **will be removed in the next major version of React Redux**. + ::: React's `unstable_batchedUpdates()` API allows any React updates in an event loop tick to be batched together into a single render pass. React already uses this internally for its own event handler callbacks. This API is actually part of the renderer packages like ReactDOM and React Native, not the React core itself. diff --git a/website/_redirects b/website/_redirects index 5de03acd3..562ca59f4 100644 --- a/website/_redirects +++ b/website/_redirects @@ -5,6 +5,13 @@ /docs/* /:splat +# Lowercase aliases for camelCase API page URLs +/api/useselector /api/useSelector +/api/usedispatch /api/useDispatch +/api/usestore /api/useStore +/api/usesignalselector /api/useSignalSelector +/api/signalprovider /api/SignalProvider + # Renamed pages /introduction/quick-start /introduction/getting-started /next/introduction/quick-started /introduction/getting-started diff --git a/website/sidebars.ts b/website/sidebars.ts index 0c0683a81..6b2ca5c6d 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -35,7 +35,32 @@ const sidebars: SidebarsConfig = { { type: 'category', label: 'API Reference', - items: ['api/provider', 'api/hooks', 'api/connect', 'api/batch'], + items: [ + { + type: 'category', + label: 'Components', + collapsed: false, + items: ['api/provider', 'api/SignalProvider', 'api/connect'], + }, + { + type: 'category', + label: 'Hooks', + collapsed: false, + link: { type: 'doc', id: 'api/hooks' }, + items: [ + 'api/useSelector', + 'api/useDispatch', + 'api/useStore', + 'api/useSignalSelector', + ], + }, + { + type: 'category', + label: 'Other', + collapsed: false, + items: ['api/unwrap', 'api/batch'], + }, + ], }, { type: 'category', From 693f543fa516bc59dc6e56b08d43324649bc5b3a Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 10 Sep 2026 15:14:34 +0200 Subject: [PATCH 69/95] docs: document that useSignalSelector rejects state mutations --- docs/api/SignalProvider.md | 4 ++-- docs/api/unwrap.md | 6 +++--- docs/api/useSelector.md | 2 +- docs/api/useSignalSelector.md | 25 +++++++++++++------------ 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/docs/api/SignalProvider.md b/docs/api/SignalProvider.md index 760aaaf98..84ca15694 100644 --- a/docs/api/SignalProvider.md +++ b/docs/api/SignalProvider.md @@ -41,8 +41,8 @@ All of the standard React Redux APIs - [`useSelector`](./useSelector.md), [`useD `SignalProvider` accepts the same props as [``](./Provider.md): -| Name | Description | -| :----------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------- | +| Name | Description | +| :----------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------- | | `store` | The single Redux store in your application. | | `children` | The top-level React elements in your component tree. | | `context?` | An optional custom context instance, as with ``. | diff --git a/docs/api/unwrap.md b/docs/api/unwrap.md index 3cd0877a2..038587c86 100644 --- a/docs/api/unwrap.md +++ b/docs/api/unwrap.md @@ -22,8 +22,8 @@ Resolves a [`useSignalSelector`](./useSignalSelector.md) tracking proxy to the r ### Parameters -| Name | Description | -| :------ | :--------------------------------------- | +| Name | Description | +| :------ | :------------------------------------------------ | | `value` | The value to unwrap - a proxy or any other value. | ### Returns @@ -32,7 +32,7 @@ The raw state object if `value` is a tracking proxy, otherwise `value` itself. ## Usage Guide -Inside a `useSignalSelector` selector, nested objects read from `state` are tracking proxies. A proxy is never `===` to the raw object it wraps, so identity comparisons against object references captured *outside* the current selector run always fail. `unwrap` resolves the proxy so the comparison works: +Inside a `useSignalSelector` selector, nested objects read from `state` are tracking proxies. A proxy is never `===` to the raw object it wraps, so identity comparisons against object references captured _outside_ the current selector run always fail. `unwrap` resolves the proxy so the comparison works: ```ts import { unwrap, useSignalSelector } from 'react-redux' diff --git a/docs/api/useSelector.md b/docs/api/useSelector.md index 6539479f9..48673632e 100644 --- a/docs/api/useSelector.md +++ b/docs/api/useSelector.md @@ -67,7 +67,7 @@ const result: Selected = useSelector( | Name | Description | | :--------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `selector` | A function that receives the entire Redux store state as its only argument, and returns the value this component needs from it. | +| `selector` | A function that receives the entire Redux store state as its only argument, and returns the value this component needs from it. | | `options?` | Either an equality function used to compare selector results (such as `shallowEqual`), or an options object containing an `equalityFn` field and a [`devModeChecks`](#development-mode-checks) field for configuring the development-mode checks below. | ### Returns diff --git a/docs/api/useSignalSelector.md b/docs/api/useSignalSelector.md index 6ba3f24c1..0f0469613 100644 --- a/docs/api/useSignalSelector.md +++ b/docs/api/useSignalSelector.md @@ -68,8 +68,8 @@ const result: Selected = useSignalSelector( Both parameters behave exactly as they do in [`useSelector`](./useSelector.md#parameters): -| Name | Description | -| :--------- | :---------------------------------------------------------------------------------------------------------------------------------------------- | +| Name | Description | +| :--------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | | `selector` | A function that receives the entire Redux store state and returns the value this component needs. Must be [pure](./useSelector.md). | | `options?` | Either an equality function (such as `shallowEqual`), or an options object with `equalityFn` and `devModeChecks` fields, as in `useSelector`. | @@ -98,7 +98,7 @@ This is the same family of technique used by `proxy-memoize` and by fine-grained - Your app dispatches frequently, and most dispatches are irrelevant to most components - Selectors read narrow, deep paths (`state.entities.todos[id].completed`) -It helps least (and can cost slightly more than `useSelector`) when most dispatches change state that most components read anyway, or when selectors read broad swaths of the state. Tracked selector evaluation itself costs roughly 3-4x an untracked run, so the win comes from the runs that are *skipped*, not from the runs themselves. +It helps least (and can cost slightly more than `useSelector`) when most dispatches change state that most components read anyway, or when selectors read broad swaths of the state. Tracked selector evaluation itself costs roughly 3-4x an untracked run, so the win comes from the runs that are _skipped_, not from the runs themselves. Because `useSelector` and `useSignalSelector` coexist under one ``, you can adopt it incrementally in the components with the highest subscription counts. @@ -108,12 +108,13 @@ The observable behavior - what your components render, when they re-render, what #### Hard constraints -| Constraint | Explanation | -| :--- | :--- | -| **Requires ``** | Calling `useSignalSelector` under a plain `` throws. | -| **The state root must be a plain object** | Path tracking operates on the root object's keys. If the root is not a plain object, the hook still works, but falls back to untracked evaluation (behaving like `useSelector`). | -| **`state => state` never updates** | A selector that returns the entire root state records no field reads, so no dispatch will ever re-run it. This is already an anti-pattern (`useSelector`'s [identity function check](./useSelector.md#identity-function-state--state-check) warns about it), but with `useSignalSelector` it goes from "wasteful" to "broken": the component will not update. The dev-mode check catches this. | -| **Mutations inside `Map`, `Set`, `Date`, and class instances are not tracked** | Only plain objects and arrays get per-field tracking. Other object types are tracked by reference: replacing the instance triggers an update, but reading `myMap.get('key')` does not record a per-entry dependency. Since Redux state should be [immutably updated](https://redux.js.org/style-guide/#do-not-mutate-state) plain data anyway, this mostly matters for state produced by libraries. | +| Constraint | Explanation | +| :----------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Requires ``** | Calling `useSignalSelector` under a plain `` throws. | +| **The state root must be a plain object** | Path tracking operates on the root object's keys. If the root is not a plain object, the hook still works, but falls back to untracked evaluation (behaving like `useSelector`). | +| **`state => state` never updates** | A selector that returns the entire root state records no field reads, so no dispatch will ever re-run it. This is already an anti-pattern (`useSelector`'s [identity function check](./useSelector.md#identity-function-state--state-check) warns about it), but with `useSignalSelector` it goes from "wasteful" to "broken": the component will not update. The dev-mode check catches this. | +| **Selectors must not mutate state** | Writing to the state from inside a selector (`state.items.sort()`, `state.foo = 1`, `delete state.bar`) throws a `TypeError` in development explaining which property the selector tried to change. `useSelector` silently allows these writes, which corrupt the store; `useSignalSelector` rejects them because the proxy sees every write. Use non-mutating alternatives (`state.items.slice().sort()`, `toSorted()`). | +| **Mutations inside `Map`, `Set`, `Date`, and class instances are not tracked** | Only plain objects and arrays get per-field tracking. Other object types are tracked by reference: replacing the instance triggers an update, but reading `myMap.get('key')` does not record a per-entry dependency. Since Redux state should be [immutably updated](https://redux.js.org/style-guide/#do-not-mutate-state) plain data anyway, this mostly matters for state produced by libraries. | #### Gotchas @@ -121,7 +122,7 @@ These are behaviors that differ from `useSelector` in ways you might notice, but ##### Proxies and identity -Inside the selector, nested objects you read from `state` are tracking proxies, not the raw state objects. The values are identical; the object identity is not. This matters only for `===` comparisons **inside the selector body** against object references obtained *outside* the current selector run: +Inside the selector, nested objects you read from `state` are tracking proxies, not the raw state objects. The values are identical; the object identity is not. This matters only for `===` comparisons **inside the selector body** against object references obtained _outside_ the current selector run: ```ts import { unwrap, useSignalSelector } from 'react-redux' @@ -140,7 +141,7 @@ const selected = useSignalSelector((state) => { See [`unwrap`](./unwrap.md) for details. Comparisons between two values both read from `state` in the same selector run work correctly without unwrapping. -**Proxies never escape the selector.** The hook unwraps the selector's return value before handing it to React, so your components, effects, equality functions, and dispatched actions always see plain Redux state. `console.log` of a selector result shows plain data. For the same reason, avoid *storing* a state object read during one selector run in an external variable for use later - the same rule as holding onto an Immer draft. Use `unwrap()` first if you need to do this. +**Proxies never escape the selector.** The hook unwraps the selector's return value before handing it to React, so your components, effects, equality functions, and dispatched actions always see plain Redux state. `console.log` of a selector result shows plain data. For the same reason, avoid _storing_ a state object read during one selector run in an external variable for use later - the same rule as holding onto an Immer draft. Use `unwrap()` first if you need to do this. ##### Memoized (Reselect) selectors @@ -152,7 +153,7 @@ Selectors that enumerate the root state's keys (`Object.keys(state)`, spreading ##### Aliased state objects -If the same object instance is reachable via two different state paths (for example, an entity stored in two lookup tables), tracking attributes reads to the first path encountered. The consequence is only ever *extra* selector re-runs, never missed updates. +If the same object instance is reachable via two different state paths (for example, an entity stored in two lookup tables), tracking attributes reads to the first path encountered. The consequence is only ever _extra_ selector re-runs, never missed updates. ##### Stale props and "zombie children" From 057817dd9714e7fb196428a4ff8c8de97a8a58b3 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 18 Sep 2026 22:14:58 -0400 Subject: [PATCH 70/95] Build each entry point separately so the main bundle stays self-contained With both entries in one Rolldown build, the shared code was hoisted into a src.* chunk and react-redux.mjs became a re-export shim without a sourcemap, which broke the publish-ci bundled-modules snapshot. --- tsdown.config.mts | 155 +++++++++++++++++++++++++++------------------- 1 file changed, 90 insertions(+), 65 deletions(-) diff --git a/tsdown.config.mts b/tsdown.config.mts index 605b5249b..756840159 100644 --- a/tsdown.config.mts +++ b/tsdown.config.mts @@ -26,64 +26,52 @@ if (process.env.NODE_ENV === 'production') { const tsconfig = 'tsconfig.build.json' -export default defineConfig((options): UserConfig[] => { - const commonOptions = { - entry: { - 'react-redux': 'src/index.ts', - 'react-redux-signals': 'src/index-signals.ts', - }, - sourcemap: true, - // `pnpm clean` already removes `dist/`; letting each of the seven builds - // clean would race them against each other. - clean: false, - hash: false, - target: ['esnext'], - tsconfig, - dts: false, - report: false, - ...options, - } satisfies UserConfig +// The main entry and the `react-redux/signals` entry share almost all of their +// code. Building them in the same config makes Rolldown hoist that code into a +// shared `src.*` chunk and leaves `react-redux.mjs` as a re-export shim, which +// changes the published main artifact and breaks the publish-ci snapshot of +// which file each bundler resolves. Each entry therefore gets its own build of +// every variant, at the cost of duplicating the shared code into the signals +// bundles. +const entries = { + 'react-redux': 'src/index.ts', + 'react-redux-signals': 'src/index-signals.ts', +} - return [ - // Standard ESM, embedded `process.env.NODE_ENV` checks - { - ...commonOptions, - name: 'Modern ESM', - format: ['esm'], - outExtensions: () => ({ js: '.mjs' }), - }, - { - ...commonOptions, - name: 'ESM for RSC', - entry: { - rsc: 'src/index-rsc.ts', - }, +type Variant = { + name: string + suffix: string + options: Partial +} + +const variants: Variant[] = [ + // Standard ESM, embedded `process.env.NODE_ENV` checks + { + name: 'Modern ESM', + suffix: '', + options: { format: ['esm'], outExtensions: () => ({ js: '.mjs' }), }, + }, - // Support Webpack 4 by pointing `"module"` to a file with a `.js` extension - // and optional chaining compiled away - { - ...commonOptions, - name: 'Legacy ESM, Webpack 4', - entry: { - 'react-redux.legacy-esm': 'src/index.ts', - 'react-redux-signals.legacy-esm': 'src/index-signals.ts', - }, + // Support Webpack 4 by pointing `"module"` to a file with a `.js` extension + // and optional chaining compiled away + { + name: 'Legacy ESM, Webpack 4', + suffix: '.legacy-esm', + options: { target: ['es2017'], format: ['esm'], outExtensions: () => ({ js: '.js' }), }, + }, - // Meant to be served up via CDNs like `unpkg`. - { - ...commonOptions, - name: 'Browser-ready ESM', - entry: { - 'react-redux.browser': 'src/index.ts', - 'react-redux-signals.browser': 'src/index-signals.ts', - }, + // Meant to be served up via CDNs like `unpkg`. + { + name: 'Browser-ready ESM', + suffix: '.browser', + options: { platform: 'browser', env: { NODE_ENV: 'production', @@ -92,13 +80,11 @@ export default defineConfig((options): UserConfig[] => { outExtensions: () => ({ js: '.mjs' }), minify: true, }, - { - ...commonOptions, - name: 'CJS Development', - entry: { - 'react-redux.development': 'src/index.ts', - 'react-redux-signals.development': 'src/index-signals.ts', - }, + }, + { + name: 'CJS Development', + suffix: '.development', + options: { env: { NODE_ENV: 'development', }, @@ -106,13 +92,11 @@ export default defineConfig((options): UserConfig[] => { outDir: './dist/cjs/', outExtensions: () => ({ js: '.cjs' }), }, - { - ...commonOptions, - name: 'CJS production', - entry: { - 'react-redux.production.min': 'src/index.ts', - 'react-redux-signals.production.min': 'src/index-signals.ts', - }, + }, + { + name: 'CJS production', + suffix: '.production.min', + options: { env: { NODE_ENV: 'production', }, @@ -124,12 +108,53 @@ export default defineConfig((options): UserConfig[] => { await writeCommonJSEntry() }, }, - { - ...commonOptions, - name: 'Type definitions', + }, + { + name: 'Type definitions', + suffix: '', + options: { format: ['esm'], dts: { emitDtsOnly: true }, outExtensions: () => ({ dts: '.d.ts' }), }, + }, +] + +export default defineConfig((options): UserConfig[] => { + const commonOptions = { + sourcemap: true, + // `pnpm clean` already removes `dist/`; letting each of the builds + // clean would race them against each other. + clean: false, + hash: false, + target: ['esnext'], + tsconfig, + dts: false, + report: false, + ...options, + } satisfies UserConfig + + const entryBuilds = Object.entries(entries).flatMap(([outName, source]) => + variants.map( + ({ name, suffix, options: variantOptions }): UserConfig => ({ + ...commonOptions, + name: `${name} (${outName})`, + entry: { [outName + suffix]: source }, + ...variantOptions, + }), + ), + ) + + return [ + ...entryBuilds, + { + ...commonOptions, + name: 'ESM for RSC', + entry: { + rsc: 'src/index-rsc.ts', + }, + format: ['esm'], + outExtensions: () => ({ js: '.mjs' }), + }, ] }) From 1eea3a0a823760e4730acdad4e11e0a21247dd2d Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 18 Sep 2026 22:51:39 -0400 Subject: [PATCH 71/95] Make the reactive system tree-shakeable from the main entry point Rolldown and Rollup cannot drop a pure call whose result is destructured at module scope. Keeping createReactiveSystem's result on one variable and reading it at the call sites lets an app that imports only the stock hooks shed alien-signals and the effect layer entirely (probe: 20.1 KB -> 10.7 KB). --- src/signals/reactiveSystem.ts | 134 +++++++++++++++++----------------- 1 file changed, 69 insertions(+), 65 deletions(-) diff --git a/src/signals/reactiveSystem.ts b/src/signals/reactiveSystem.ts index 5dc3e1e54..b088577b9 100644 --- a/src/signals/reactiveSystem.ts +++ b/src/signals/reactiveSystem.ts @@ -85,62 +85,66 @@ function isSignalNode(node: ReactiveNode): node is SignalNode { return 'currentValue' in node } -const { link, unlink, propagate, checkDirty, shallowPropagate } = - createReactiveSystem({ - update(node: ReactiveNode): boolean { - if (isComputedNode(node)) { - return updateComputed(node) - } - if (isSignalNode(node)) { - return updateSignal(node) - } - node.flags = Mutable - return true - }, - - notify(node: ReactiveNode): void { - let effect = node as EffectNode - let insertIndex = queuedLength - let firstInsertedIndex = insertIndex - for (;;) { - queued[insertIndex++] = effect - effect.flags &= ~Watching - const next = effect.subs?.sub as EffectNode | undefined - if (next === undefined || !(next.flags & Watching)) { - break - } - effect = next - } - queuedLength = insertIndex - while (firstInsertedIndex < --insertIndex) { - const left = queued[firstInsertedIndex] - queued[firstInsertedIndex++] = queued[insertIndex] - queued[insertIndex] = left +// Kept as a single object rather than destructured: bundlers cannot drop a +// pure call whose result is destructured at the top level, but they can drop +// `system` when nothing that reads it survives tree-shaking. That lets an app +// importing only the stock hooks from the main entry point shed the whole +// reactive system. +const system = /* @__PURE__ */ createReactiveSystem({ + update(node: ReactiveNode): boolean { + if (isComputedNode(node)) { + return updateComputed(node) + } + if (isSignalNode(node)) { + return updateSignal(node) + } + node.flags = Mutable + return true + }, + + notify(node: ReactiveNode): void { + let effect = node as EffectNode + let insertIndex = queuedLength + let firstInsertedIndex = insertIndex + for (;;) { + queued[insertIndex++] = effect + effect.flags &= ~Watching + const next = effect.subs?.sub as EffectNode | undefined + if (next === undefined || !(next.flags & Watching)) { + break } - }, + effect = next + } + queuedLength = insertIndex + while (firstInsertedIndex < --insertIndex) { + const left = queued[firstInsertedIndex] + queued[firstInsertedIndex++] = queued[insertIndex] + queued[insertIndex] = left + } + }, - unwatched(node: ReactiveNode): void { - if (isComputedNode(node)) { - if (node.depsTail !== undefined) { - node.flags = Mutable | Dirty - disposeAllDepsInReverse(node) - } - return + unwatched(node: ReactiveNode): void { + if (isComputedNode(node)) { + if (node.depsTail !== undefined) { + node.flags = Mutable | Dirty + disposeAllDepsInReverse(node) } - if (isSignalNode(node)) { - // The one behavioral addition over upstream, whose branch here - // is empty. Runs from inside `unlink`, which itself runs during - // effect re-runs and disposal, so `release` must not read or - // write any signal value. - const owner = node.ownerRegistry - if (owner !== undefined) { - owner.release(node.ownerPath, node) - } - return + return + } + if (isSignalNode(node)) { + // The one behavioral addition over upstream, whose branch here + // is empty. Runs from inside `unlink`, which itself runs during + // effect re-runs and disposal, so `release` must not read or + // write any signal value. + const owner = node.ownerRegistry + if (owner !== undefined) { + owner.release(node.ownerPath, node) } - stopEffect(node as EffectNode) - }, - }) + return + } + stopEffect(node as EffectNode) + }, +}) export class SignalNode implements ReactiveSignal, ReactiveNode { currentValue: T @@ -164,13 +168,13 @@ export class SignalNode implements ReactiveSignal, ReactiveNode { if (updateSignal(this)) { const subs = this.subs if (subs !== undefined) { - shallowPropagate(subs) + system.shallowPropagate(subs) } } } const sub = activeSub if (sub !== undefined) { - link(this, sub, cycle) + system.link(this, sub, cycle) } return this.currentValue } @@ -180,7 +184,7 @@ export class SignalNode implements ReactiveSignal, ReactiveNode { this.flags = Mutable | Dirty const subs = this.subs if (subs !== undefined) { - propagate(subs, !!runDepth) + system.propagate(subs, !!runDepth) if (!batchDepth) { flush() } @@ -207,13 +211,13 @@ export class ComputedNode implements ReactiveComputed, ReactiveNode { if ( flags & Dirty || (flags & Pending && - (checkDirty(this.deps!, this) || + (system.checkDirty(this.deps!, this) || ((this.flags = flags & ~Pending), false))) ) { if (updateComputed(this)) { const subs = this.subs if (subs !== undefined) { - shallowPropagate(subs) + system.shallowPropagate(subs) } } } else if (!flags) { @@ -228,7 +232,7 @@ export class ComputedNode implements ReactiveComputed, ReactiveNode { } const sub = activeSub if (sub !== undefined) { - link(this, sub, cycle) + system.link(this, sub, cycle) } return this.value as T } @@ -293,7 +297,7 @@ export function effect(fn: () => void | (() => void)): () => void { } const prevSub = setActiveSub(e) if (prevSub !== undefined) { - link(e, prevSub, 0) + system.link(e, prevSub, 0) prevSub.flags |= HasChildEffect } try { @@ -327,7 +331,7 @@ export function effectScope(fn: () => void): () => void { } const prevSub = setActiveSub(e) if (prevSub !== undefined) { - link(e, prevSub, 0) + system.link(e, prevSub, 0) prevSub.flags |= HasChildEffect } try { @@ -351,7 +355,7 @@ function unlinkChildEffects(sub: ReactiveNode): void { const prev = current.prevDep const dep = current.dep if (!isComputedNode(dep) && !isSignalNode(dep)) { - unlink(current, sub) + system.unlink(current, sub) } current = prev } @@ -382,7 +386,7 @@ function updateSignal(s: SignalNode): boolean { function run(e: EffectNode): void { const flags = e.flags - if (flags & Dirty || (flags & Pending && checkDirty(e.deps!, e))) { + if (flags & Dirty || (flags & Pending && system.checkDirty(e.deps!, e))) { if (flags & HasChildEffect) { unlinkChildEffects(e) } @@ -454,7 +458,7 @@ function stopEffect(e: EffectNode): void { disposeAllDepsInReverse(e) const sub = e.subs if (sub !== undefined) { - unlink(sub) + system.unlink(sub) } if (e.cleanup) { runCleanup(e) @@ -465,7 +469,7 @@ function disposeAllDepsInReverse(sub: ReactiveNode): void { let current = sub.depsTail while (current !== undefined) { const prev = current.prevDep - unlink(current, sub) + system.unlink(current, sub) current = prev } } @@ -474,6 +478,6 @@ function purgeDeps(sub: ReactiveNode): void { const depsTail = sub.depsTail let dep = depsTail !== undefined ? depsTail.nextDep : sub.deps while (dep !== undefined) { - dep = unlink(dep, sub) + dep = system.unlink(dep, sub) } } From 63fb0eb094ec63c717e80c58b3b9cceae93dcec7 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 18 Sep 2026 23:18:49 -0400 Subject: [PATCH 72/95] Add a CI example that checks signals code tree-shakes out of stock apps - examples/publish-ci/rr-tree-shaking: Vite app using only Provider, hooks, and connect from the main entry - check-tree-shaking.mjs fails the build if any signals identifiers survive in the output - added to the test-published-artifact-local matrix --- .github/workflows/test.yml | 2 +- .../publish-ci/rr-tree-shaking/.gitignore | 4 + .../rr-tree-shaking/check-tree-shaking.mjs | 64 ++ .../publish-ci/rr-tree-shaking/index.html | 11 + .../publish-ci/rr-tree-shaking/package.json | 23 + .../publish-ci/rr-tree-shaking/pnpm-lock.yaml | 966 ++++++++++++++++++ .../rr-tree-shaking/pnpm-workspace.yaml | 8 + .../publish-ci/rr-tree-shaking/src/main.tsx | 36 + .../publish-ci/rr-tree-shaking/tsconfig.json | 14 + .../rr-tree-shaking/vite.config.mts | 17 + 10 files changed, 1144 insertions(+), 1 deletion(-) create mode 100644 examples/publish-ci/rr-tree-shaking/.gitignore create mode 100644 examples/publish-ci/rr-tree-shaking/check-tree-shaking.mjs create mode 100644 examples/publish-ci/rr-tree-shaking/index.html create mode 100644 examples/publish-ci/rr-tree-shaking/package.json create mode 100644 examples/publish-ci/rr-tree-shaking/pnpm-lock.yaml create mode 100644 examples/publish-ci/rr-tree-shaking/pnpm-workspace.yaml create mode 100644 examples/publish-ci/rr-tree-shaking/src/main.tsx create mode 100644 examples/publish-ci/rr-tree-shaking/tsconfig.json create mode 100644 examples/publish-ci/rr-tree-shaking/vite.config.mts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 622a11845..9279303cb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -330,7 +330,7 @@ jobs: fail-fast: false matrix: node: ['24.x'] - example: ['rr-rsc-context'] + example: ['rr-rsc-context', 'rr-tree-shaking'] defaults: run: working-directory: ./examples/publish-ci/${{ matrix.example }} diff --git a/examples/publish-ci/rr-tree-shaking/.gitignore b/examples/publish-ci/rr-tree-shaking/.gitignore new file mode 100644 index 000000000..edeb8631f --- /dev/null +++ b/examples/publish-ci/rr-tree-shaking/.gitignore @@ -0,0 +1,4 @@ +/node_modules +/dist +*.tgz +*.tsbuildinfo diff --git a/examples/publish-ci/rr-tree-shaking/check-tree-shaking.mjs b/examples/publish-ci/rr-tree-shaking/check-tree-shaking.mjs new file mode 100644 index 000000000..7cf310337 --- /dev/null +++ b/examples/publish-ci/rr-tree-shaking/check-tree-shaking.mjs @@ -0,0 +1,64 @@ +// Scans the Vite build output and fails if any of the signals implementation +// survived tree-shaking in an app that only uses the stock React-Redux API. +// +// The signals code lives on the main entry point next to the stock exports, so +// every consumer relies on the bundler dropping it. That only works while the +// signals modules have no module-level side effects. A refactor that adds one +// (for example, destructuring the result of a top-level call) breaks it +// silently, and this check is what catches that. + +import { readdir, readFile } from 'node:fs/promises' +import path from 'node:path' + +const distDir = path.resolve(import.meta.dirname, 'dist', 'assets') + +// Identifiers that exist only in the signals implementation. The build runs +// unminified, so function and class names survive into the output. +const forbidden = [ + 'createReactiveSystem', + 'createPathSignalRegistry', + 'createTrackingProxy', + 'reconcileState', + 'SignalProvider', + 'useSignalSelector', + 'alien-signals', +] + +// Something the stock implementation always ships. If this is missing the +// build did not include react-redux at all and the forbidden check proves +// nothing. +const required = ['useSyncExternalStoreWithSelector', 'ReactReduxContext'] + +const files = (await readdir(distDir)).filter((f) => f.endsWith('.js')) +if (files.length === 0) { + console.error(`No JS output found in ${distDir}. Did \`vite build\` run?`) + process.exit(1) +} + +const output = ( + await Promise.all(files.map((f) => readFile(path.join(distDir, f), 'utf8'))) +).join('\n') + +const missing = required.filter((marker) => !output.includes(marker)) +if (missing.length > 0) { + console.error( + 'Build output does not look like a react-redux app. Missing markers:', + ) + for (const marker of missing) console.error(` - ${marker}`) + process.exit(1) +} + +const leaked = forbidden.filter((marker) => output.includes(marker)) +if (leaked.length > 0) { + console.error('Signals code leaked into a stock-only build. Found:') + for (const marker of leaked) console.error(` - ${marker}`) + console.error( + '\nSomething in src/signals now has a module-level side effect that bundlers cannot drop.', + ) + process.exit(1) +} + +const bytes = Buffer.byteLength(output) +console.log( + `Tree-shaking OK: ${files.length} file(s), ${bytes} bytes, no signals code present.`, +) diff --git a/examples/publish-ci/rr-tree-shaking/index.html b/examples/publish-ci/rr-tree-shaking/index.html new file mode 100644 index 000000000..31b806c5f --- /dev/null +++ b/examples/publish-ci/rr-tree-shaking/index.html @@ -0,0 +1,11 @@ + + + + + React-Redux tree-shaking check + + +
+ + + diff --git a/examples/publish-ci/rr-tree-shaking/package.json b/examples/publish-ci/rr-tree-shaking/package.json new file mode 100644 index 000000000..7e3f6d51e --- /dev/null +++ b/examples/publish-ci/rr-tree-shaking/package.json @@ -0,0 +1,23 @@ +{ + "name": "rr-tree-shaking", + "version": "0.1.0", + "private": true, + "type": "module", + "packageManager": "pnpm@11.23.0", + "scripts": { + "build": "vite build && node check-tree-shaking.mjs" + }, + "dependencies": { + "react": "19.2.8", + "react-dom": "19.2.8", + "react-redux": "^9.3.0", + "redux": "^5.0.1" + }, + "devDependencies": { + "@types/react": "19.2.18", + "@types/react-dom": "19.2.7", + "@vitejs/plugin-react": "^5.2.0", + "typescript": "5.9.3", + "vite": "^8.2.2" + } +} diff --git a/examples/publish-ci/rr-tree-shaking/pnpm-lock.yaml b/examples/publish-ci/rr-tree-shaking/pnpm-lock.yaml new file mode 100644 index 000000000..f009bc9c7 --- /dev/null +++ b/examples/publish-ci/rr-tree-shaking/pnpm-lock.yaml @@ -0,0 +1,966 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + react: + specifier: 19.2.8 + version: 19.2.8 + react-dom: + specifier: 19.2.8 + version: 19.2.8(react@19.2.8) + react-redux: + specifier: ^9.3.0 + version: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) + redux: + specifier: ^5.0.1 + version: 5.0.1 + devDependencies: + '@types/react': + specifier: 19.2.18 + version: 19.2.18 + '@types/react-dom': + specifier: 19.2.7 + version: 19.2.7(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^5.2.0 + version: 5.2.0(vite@8.3.0) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: ^8.2.2 + version: 8.3.0 + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.150.0': + resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==} + + '@rolldown/binding-android-arm-eabi@1.2.9': + resolution: {integrity: sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.9': + resolution: {integrity: sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.9': + resolution: {integrity: sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.9': + resolution: {integrity: sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.9': + resolution: {integrity: sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.9': + resolution: {integrity: sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.9': + resolution: {integrity: sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.9': + resolution: {integrity: sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.9': + resolution: {integrity: sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.9': + resolution: {integrity: sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.9': + resolution: {integrity: sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.9': + resolution: {integrity: sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.9': + resolution: {integrity: sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.9': + resolution: {integrity: sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.9': + resolution: {integrity: sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/react-dom@19.2.7': + resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + baseline-browser-mapping@2.11.25: + resolution: {integrity: sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.29.0: + resolution: {integrity: sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + electron-to-chromium@1.5.431: + resolution: {integrity: sha512-AAVihz2YwJeOdAynX8MUtqpvjY0gaiARp/7+r4kwqgzqXMrfG1qDN7vwT80uxAoOQRPcZH8t73EI+CA/1OJN8A==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.56: + resolution: {integrity: sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==} + engines: {node: '>=18'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + + rolldown@1.2.9: + resolution: {integrity: sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + update-browserslist-db@1.3.3: + resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vite@8.3.0: + resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.29.0 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@oxc-project/types@0.150.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.9': + optional: true + + '@rolldown/binding-android-arm64@1.2.9': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.9': + optional: true + + '@rolldown/binding-darwin-x64@1.2.9': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.9': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.9': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.9': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.9': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.9': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.9': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.9': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.3': {} + + '@rolldown/pluginutils@1.0.1': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/react-dom@19.2.7(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@types/use-sync-external-store@0.0.6': {} + + '@vitejs/plugin-react@5.2.0(vite@8.3.0)': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 8.3.0 + transitivePeerDependencies: + - supports-color + + baseline-browser-mapping@2.11.25: {} + + browserslist@4.29.0: + dependencies: + baseline-browser-mapping: 2.11.25 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.431 + node-releases: 2.0.56 + update-browserslist-db: 1.3.3(browserslist@4.29.0) + + caniuse-lite@1.0.30001810: {} + + convert-source-map@2.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: {} + + electron-to-chromium@1.5.431: {} + + escalade@3.2.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + ms@2.1.3: {} + + nanoid@3.3.19: {} + + node-releases@2.0.56: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.8 + use-sync-external-store: 1.7.0(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + redux: 5.0.1 + + react-refresh@0.18.0: {} + + react@19.2.8: {} + + redux@5.0.1: {} + + rolldown@1.2.9: + dependencies: + '@oxc-project/types': 0.150.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.9 + '@rolldown/binding-android-arm64': 1.2.9 + '@rolldown/binding-darwin-arm64': 1.2.9 + '@rolldown/binding-darwin-x64': 1.2.9 + '@rolldown/binding-freebsd-x64': 1.2.9 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.9 + '@rolldown/binding-linux-arm64-gnu': 1.2.9 + '@rolldown/binding-linux-arm64-musl': 1.2.9 + '@rolldown/binding-linux-ppc64-gnu': 1.2.9 + '@rolldown/binding-linux-s390x-gnu': 1.2.9 + '@rolldown/binding-linux-x64-gnu': 1.2.9 + '@rolldown/binding-linux-x64-musl': 1.2.9 + '@rolldown/binding-openharmony-arm64': 1.2.9 + '@rolldown/binding-win32-arm64-msvc': 1.2.9 + '@rolldown/binding-win32-x64-msvc': 1.2.9 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + typescript@5.9.3: {} + + update-browserslist-db@1.3.3(browserslist@4.29.0): + dependencies: + browserslist: 4.29.0 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-sync-external-store@1.7.0(react@19.2.8): + dependencies: + react: 19.2.8 + + vite@8.3.0: + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.9 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + yallist@3.1.1: {} diff --git a/examples/publish-ci/rr-tree-shaking/pnpm-workspace.yaml b/examples/publish-ci/rr-tree-shaking/pnpm-workspace.yaml new file mode 100644 index 000000000..68548cbee --- /dev/null +++ b/examples/publish-ci/rr-tree-shaking/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +# This example is an independent project, not part of the root pnpm project. +# +# pnpm finds a project's root by walking up until it finds a +# pnpm-workspace.yaml. Without this file, pnpm run from here walks up to the +# repo root and operates on the react-redux project instead of this example. +# This file stops that walk. + +nodeLinker: hoisted diff --git a/examples/publish-ci/rr-tree-shaking/src/main.tsx b/examples/publish-ci/rr-tree-shaking/src/main.tsx new file mode 100644 index 000000000..af14dd1cf --- /dev/null +++ b/examples/publish-ci/rr-tree-shaking/src/main.tsx @@ -0,0 +1,36 @@ +import { createRoot } from 'react-dom/client' +import { Provider, connect, useDispatch, useSelector } from 'react-redux' +import { createStore } from 'redux' + +// A stock React-Redux app: Provider, hooks, and connect, all from the main +// entry point. It never touches SignalProvider or useSignalSelector, so a +// correct build must not contain any of the signals implementation. + +interface State { + count: number +} + +function reducer(state: State = { count: 0 }, action: { type: string }) { + return action.type === 'increment' ? { count: state.count + 1 } : state +} + +const store = createStore(reducer) + +function Counter() { + const count = useSelector((s: State) => s.count) + const dispatch = useDispatch() + return ( + + ) +} + +const ConnectedLabel = connect((s: State) => ({ count: s.count }))( + ({ count }: { count: number }) => {count}, +) + +createRoot(document.getElementById('root')!).render( + + + + , +) diff --git a/examples/publish-ci/rr-tree-shaking/tsconfig.json b/examples/publish-ci/rr-tree-shaking/tsconfig.json new file mode 100644 index 000000000..4a7e9c42d --- /dev/null +++ b/examples/publish-ci/rr-tree-shaking/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["src", "vite.config.mts"] +} diff --git a/examples/publish-ci/rr-tree-shaking/vite.config.mts b/examples/publish-ci/rr-tree-shaking/vite.config.mts new file mode 100644 index 000000000..d1f36bf36 --- /dev/null +++ b/examples/publish-ci/rr-tree-shaking/vite.config.mts @@ -0,0 +1,17 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [react()], + build: { + // The check script looks for function names in the output. Minification + // would mangle them, so keep identifiers intact. Tree-shaking does not + // depend on minification, so this still measures what an app would ship. + minify: false, + // React itself is not what this example is checking. Leaving it out keeps + // the output small enough to inspect by hand when the check fails. + rollupOptions: { + external: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime'], + }, + }, +}) From c6ffabfd7e99149dfb1e8cd769712d63679a2320 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Fri, 18 Sep 2026 23:41:19 -0400 Subject: [PATCH 73/95] Skip node10 resolution in the attw check so subpath entries can be included --- .github/workflows/test.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9279303cb..4b524c33e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -213,8 +213,12 @@ jobs: run: pnpm install # Note: We currently expect "FalseCJS" failures for Node16 + `moduleResolution: "node16", + # `--profile node16` drops the node10 resolution check. The subpath + # entry points (`alternate-renderers`, `signals`) only resolve through + # the `exports` map, which node10 does not read, and we do not support + # Node 10 style resolution for them. - name: Run are-the-types-wrong - run: pnpm exec attw ./package.tgz --format table --ignore-rules false-cjs --exclude-entrypoints alternate-renderers + run: pnpm exec attw ./package.tgz --format table --ignore-rules false-cjs --profile node16 test-published-artifact: name: Test Published Artifact ${{ matrix.example }} From 76614c56d9a781eecaabf1fb4d5cd7dee849a019 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 22 Sep 2026 12:56:05 -0400 Subject: [PATCH 74/95] Add an npm dist-tag input to the publish workflow --- .github/workflows/publish.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 357d41529..88adccda3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,6 +4,17 @@ on: #release: # types: [published] workflow_dispatch: + inputs: + tag: + description: 'NPM dist-tag (latest, alpha, beta, next)' + required: true + default: 'latest' + type: choice + options: + - latest + - next + - alpha + - beta permissions: {} @@ -29,4 +40,4 @@ jobs: cache: 'pnpm' - run: pnpm install --frozen-lockfile - run: pnpm test - - run: npm publish --access public --provenance + - run: npm publish --access public --provenance --tag ${{ inputs.tag }} From 6dae195b50cd25cf7c23eeed366c96b0b458b533 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 22 Sep 2026 13:02:42 -0400 Subject: [PATCH 75/95] Move tree-shaking example scripts into scripts/ and add a bundle-size measurement --- .../publish-ci/rr-tree-shaking/.gitignore | 1 + .../publish-ci/rr-tree-shaking/package.json | 3 +- .../{ => scripts}/check-tree-shaking.mjs | 2 +- .../rr-tree-shaking/scripts/measure-size.mjs | 92 +++++++++++++++++++ 4 files changed, 96 insertions(+), 2 deletions(-) rename examples/publish-ci/rr-tree-shaking/{ => scripts}/check-tree-shaking.mjs (96%) create mode 100644 examples/publish-ci/rr-tree-shaking/scripts/measure-size.mjs diff --git a/examples/publish-ci/rr-tree-shaking/.gitignore b/examples/publish-ci/rr-tree-shaking/.gitignore index edeb8631f..a9654f244 100644 --- a/examples/publish-ci/rr-tree-shaking/.gitignore +++ b/examples/publish-ci/rr-tree-shaking/.gitignore @@ -2,3 +2,4 @@ /dist *.tgz *.tsbuildinfo +/.size diff --git a/examples/publish-ci/rr-tree-shaking/package.json b/examples/publish-ci/rr-tree-shaking/package.json index 7e3f6d51e..b7ebcf8cb 100644 --- a/examples/publish-ci/rr-tree-shaking/package.json +++ b/examples/publish-ci/rr-tree-shaking/package.json @@ -5,7 +5,8 @@ "type": "module", "packageManager": "pnpm@11.23.0", "scripts": { - "build": "vite build && node check-tree-shaking.mjs" + "build": "vite build && node scripts/check-tree-shaking.mjs", + "measure": "node scripts/measure-size.mjs" }, "dependencies": { "react": "19.2.8", diff --git a/examples/publish-ci/rr-tree-shaking/check-tree-shaking.mjs b/examples/publish-ci/rr-tree-shaking/scripts/check-tree-shaking.mjs similarity index 96% rename from examples/publish-ci/rr-tree-shaking/check-tree-shaking.mjs rename to examples/publish-ci/rr-tree-shaking/scripts/check-tree-shaking.mjs index 7cf310337..57eda7664 100644 --- a/examples/publish-ci/rr-tree-shaking/check-tree-shaking.mjs +++ b/examples/publish-ci/rr-tree-shaking/scripts/check-tree-shaking.mjs @@ -10,7 +10,7 @@ import { readdir, readFile } from 'node:fs/promises' import path from 'node:path' -const distDir = path.resolve(import.meta.dirname, 'dist', 'assets') +const distDir = path.resolve(import.meta.dirname, '..', 'dist', 'assets') // Identifiers that exist only in the signals implementation. The build runs // unminified, so function and class names survive into the output. diff --git a/examples/publish-ci/rr-tree-shaking/scripts/measure-size.mjs b/examples/publish-ci/rr-tree-shaking/scripts/measure-size.mjs new file mode 100644 index 000000000..43d6f4e76 --- /dev/null +++ b/examples/publish-ci/rr-tree-shaking/scripts/measure-size.mjs @@ -0,0 +1,92 @@ +// Builds the example app three ways with Vite (minified) and reports the size +// of the react-redux + redux portion of the output. React itself is external. +// +// stock - Provider + useSelector + useDispatch + connect +// signals - SignalProvider + useSignalSelector + useDispatch + connect +// alias - same source as `stock`, with 'react-redux' aliased to +// 'react-redux/signals' +// +// Run after installing the tarball under test: `pnpm measure` + +import { gzipSync } from 'node:zlib' +import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { build } from 'vite' +import react from '@vitejs/plugin-react' + +const here = path.resolve(import.meta.dirname, '..') + +const installed = await import('react-redux') +if (!('useSignalSelector' in installed)) { + console.error( + 'The installed react-redux has no signals exports. Install a build from the signals branch first:\n' + + ' pnpm remove react-redux && pnpm add ./package.tgz', + ) + process.exit(1) +} + +const stockSource = await readFile(path.join(here, 'src/main.tsx'), 'utf8') +const signalsSource = stockSource + .replace( + 'Provider, connect, useDispatch, useSelector', + 'SignalProvider, connect, useDispatch, useSignalSelector', + ) + .replaceAll('useSelector(', 'useSignalSelector(') + .replaceAll('', '
') + +const variants = [ + { name: 'stock', source: stockSource, alias: {} }, + { name: 'signals', source: signalsSource, alias: {} }, + { + name: 'alias', + source: stockSource, + alias: { 'react-redux': 'react-redux/signals' }, + }, +] + +const results = [] +for (const variant of variants) { + // Entries must live inside the project so bare imports resolve to its + // node_modules. + const work = path.join(here, '.size', variant.name) + await mkdir(work, { recursive: true }) + await writeFile(path.join(work, 'main.tsx'), variant.source) + const outDir = path.join(work, 'dist') + + await build({ + root: here, + logLevel: 'silent', + configFile: false, + plugins: [react()], + resolve: { alias: variant.alias }, + build: { + outDir, + emptyOutDir: true, + minify: true, + rollupOptions: { + input: path.join(work, 'main.tsx'), + external: [ + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-runtime', + ], + }, + }, + }) + + const assets = path.join(outDir, 'assets') + const files = (await readdir(assets)).filter((f) => f.endsWith('.js')) + const code = ( + await Promise.all(files.map((f) => readFile(path.join(assets, f)))) + ).join('') + results.push({ + variant: variant.name, + 'min (bytes)': Buffer.byteLength(code), + 'min+gz (bytes)': gzipSync(code).byteLength, + }) +} + +await rm(path.join(here, '.size'), { recursive: true, force: true }) +console.table(results) From beeec6850bfc02b3608e47e652f5b3e659c69020 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 22 Sep 2026 13:08:04 -0400 Subject: [PATCH 76/95] Report react-redux's own bundle cost separately in the size measurement --- .../rr-tree-shaking/scripts/measure-size.mjs | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/examples/publish-ci/rr-tree-shaking/scripts/measure-size.mjs b/examples/publish-ci/rr-tree-shaking/scripts/measure-size.mjs index 43d6f4e76..6f3c14b00 100644 --- a/examples/publish-ci/rr-tree-shaking/scripts/measure-size.mjs +++ b/examples/publish-ci/rr-tree-shaking/scripts/measure-size.mjs @@ -45,14 +45,15 @@ const variants = [ }, ] -const results = [] -for (const variant of variants) { +const reactExternals = ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime'] + +async function measure(variant, { externalizeReactRedux }) { // Entries must live inside the project so bare imports resolve to its // node_modules. const work = path.join(here, '.size', variant.name) await mkdir(work, { recursive: true }) await writeFile(path.join(work, 'main.tsx'), variant.source) - const outDir = path.join(work, 'dist') + const outDir = path.join(work, externalizeReactRedux ? 'dist-without' : 'dist-with') await build({ root: here, @@ -66,12 +67,9 @@ for (const variant of variants) { minify: true, rollupOptions: { input: path.join(work, 'main.tsx'), - external: [ - 'react', - 'react-dom', - 'react-dom/client', - 'react/jsx-runtime', - ], + external: externalizeReactRedux + ? [...reactExternals, /^react-redux(\/|$)/] + : reactExternals, }, }, }) @@ -81,10 +79,24 @@ for (const variant of variants) { const code = ( await Promise.all(files.map((f) => readFile(path.join(assets, f)))) ).join('') + return { min: Buffer.byteLength(code), gz: gzipSync(code).byteLength } +} + +// The app code and `redux` are identical whether or not react-redux is +// bundled, so the difference between the two builds is the react-redux cost +// alone, including its own dependencies (use-sync-external-store, +// alien-signals). Doing the subtraction on the gzipped totals is the closest +// available approximation of the gzipped cost. +const results = [] +for (const variant of variants) { + const withRR = await measure(variant, { externalizeReactRedux: false }) + const withoutRR = await measure(variant, { externalizeReactRedux: true }) results.push({ variant: variant.name, - 'min (bytes)': Buffer.byteLength(code), - 'min+gz (bytes)': gzipSync(code).byteLength, + 'react-redux min': withRR.min - withoutRR.min, + 'react-redux min+gz': withRR.gz - withoutRR.gz, + 'app total min': withRR.min, + 'app total min+gz': withRR.gz, }) } From 52478dc8c9a47a8873dc7a7e237bf0fefb6c0481 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 22 Sep 2026 13:46:43 -0400 Subject: [PATCH 77/95] Release 9.4.0-alpha.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5eb08e3b7..8bd4489c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-redux", - "version": "9.3.0", + "version": "9.4.0-alpha.0", "description": "Official React bindings for Redux", "keywords": [ "react", From 5b3e7f8dad169303aa28571f70f7bad24933b318 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 24 Sep 2026 12:58:21 +0200 Subject: [PATCH 78/95] Add tests for memoized-selector cache hits and conditional reads The six args-memoization tests fail in signals mode: the shared root proxy lets Reselect's argsMemoize short-circuit before any state property is read, so the hook records no dependencies. --- test/signals/edgeCasesReact.spec.tsx | 95 ++++++++ test/signals/reselect.spec.tsx | 318 +++++++++++++++++++++++++++ 2 files changed, 413 insertions(+) diff --git a/test/signals/edgeCasesReact.spec.tsx b/test/signals/edgeCasesReact.spec.tsx index 87c2e73cb..59c6fb51c 100644 --- a/test/signals/edgeCasesReact.spec.tsx +++ b/test/signals/edgeCasesReact.spec.tsx @@ -455,4 +455,99 @@ describe('edge cases: React integration', () => { expect(html).toContain('all') }) }) + + describe('conditional reads', () => { + // Dependencies are rebuilt from scratch on every evaluation, so a + // branch that was not read the first time still becomes a dependency + // as soon as the selector starts reading it. + it('tracks the branch that is read after a toggle flips', () => { + let selectorRuns = 0 + let renders = 0 + const selectByFilter = (s: TestState) => { + selectorRuns++ + return s.filter === 'all' + ? s.counters.counter1.value + : s.counters.counter2.value + } + function Value() { + renders++ + const value = useSignalSelector(selectByFilter) + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + expect(getByTestId('value').textContent).toBe('0') + expect(renders).toBe(1) + + // counter2 is not read yet: no evaluation, no render. + rtl.act(() => { + store.dispatch(countersSlice.actions.increment('counter2')) + }) + expect(getByTestId('value').textContent).toBe('0') + expect(renders).toBe(1) + + rtl.act(() => { + store.dispatch(filterSlice.actions.set('active')) + }) + expect(getByTestId('value').textContent).toBe('43') + expect(renders).toBe(2) + + // Now counter2 IS a dependency and counter1 no longer is. + const runsBefore = selectorRuns + rtl.act(() => { + store.dispatch(countersSlice.actions.increment('counter2')) + }) + expect(getByTestId('value').textContent).toBe('44') + expect(renders).toBe(3) + + rtl.act(() => { + store.dispatch(countersSlice.actions.increment('counter1')) + }) + expect(getByTestId('value').textContent).toBe('44') + expect(renders).toBe(3) + expect(selectorRuns).toBe(runsBefore + 1) + }) + + it('drops a dependency that an early return stops reading', () => { + let selectorRuns = 0 + function Value() { + const value = useSignalSelector((s: TestState) => { + selectorRuns++ + if (s.settings.theme === 'light') return 'light mode' + return `${s.settings.theme}/${s.settings.fontSize}` + }) + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + expect(getByTestId('value').textContent).toBe('dark/12') + + rtl.act(() => { + store.dispatch(settingsSlice.actions.setFontSize(14)) + }) + expect(getByTestId('value').textContent).toBe('dark/14') + + rtl.act(() => { + store.dispatch(settingsSlice.actions.setTheme('light')) + }) + expect(getByTestId('value').textContent).toBe('light mode') + + // fontSize is no longer read past the early return, so changing it + // must not evaluate the selector at all. + const runsBefore = selectorRuns + rtl.act(() => { + store.dispatch(settingsSlice.actions.setFontSize(16)) + }) + expect(getByTestId('value').textContent).toBe('light mode') + expect(selectorRuns).toBe(runsBefore) + }) + }) }) diff --git a/test/signals/reselect.spec.tsx b/test/signals/reselect.spec.tsx index d82b4ec4f..81181fdb5 100644 --- a/test/signals/reselect.spec.tsx +++ b/test/signals/reselect.spec.tsx @@ -410,3 +410,321 @@ describe('layered memoized selectors', () => { expect(getByTestId('todo-2').textContent).toBe('review code') }) }) + +describe('args-memoization cache hits', () => { + // Reselect memoizes the whole selector on its arguments (argsMemoize) + // before it even runs the input selectors. If two hook evaluations hand + // it the same state argument, the second is an args-cache hit: no input + // selector runs, no state property is read, and a tracking hook would + // record nothing. Every test here dispatches several times so that a + // hook that tracked correctly once and then went deaf still fails. + const selectSummary = () => + createSelector([(s: RootState) => s.counter.value], (value) => ({ + doubled: value * 2, + })) + + const incrementTimes = (store: ReturnType, n: number) => { + for (let i = 0; i < n; i++) { + rtl.act(() => { + store.dispatch({ type: 'increment' }) + }) + } + } + + it('a selector shared by two components keeps updating both', () => { + const store = makeStore() + const selectDoubled = selectSummary() + let rendersA = 0 + let rendersB = 0 + + function A() { + rendersA++ + return
{useSelector(selectDoubled).doubled}
+ } + function B() { + rendersB++ + return
{useSelector(selectDoubled).doubled}
+ } + + const { getByTestId } = rtl.render( + +
+ + , + ) + + for (const expected of ['2', '4', '6']) { + incrementTimes(store, 1) + expect(getByTestId('a').textContent).toBe(expected) + expect(getByTestId('b').textContent).toBe(expected) + } + expect(rendersA).toBe(4) + expect(rendersB).toBe(4) + }) + + it('a component mounted after the selector is already cached still updates', () => { + const store = makeStore() + const selectDoubled = selectSummary() + + function A() { + return
{useSelector(selectDoubled).doubled}
+ } + function B() { + return
{useSelector(selectDoubled).doubled}
+ } + function App({ showB }: { showB: boolean }) { + return ( + +
+ {showB && } + + ) + } + + const { getByTestId, rerender } = rtl.render() + incrementTimes(store, 1) + expect(getByTestId('a').textContent).toBe('2') + + // A has already evaluated selectDoubled against the current state. + rerender() + expect(getByTestId('b').textContent).toBe('2') + + for (const expected of ['4', '6', '8']) { + incrementTimes(store, 1) + expect(getByTestId('a').textContent).toBe(expected) + expect(getByTestId('b').textContent).toBe(expected) + } + }) + + it('an inline wrapper around a memoized selector keeps updating', () => { + // The inline arrow is a new function every render, so after each + // dispatch the hook re-evaluates against the SAME state a second time. + const store = makeStore() + const selectDoubled = selectSummary() + + function A() { + const doubled = useSelector((s: RootState) => selectDoubled(s).doubled) + return
{doubled}
+ } + + const { getByTestId } = rtl.render( + +
+ , + ) + for (const expected of ['2', '4', '6']) { + incrementTimes(store, 1) + expect(getByTestId('a').textContent).toBe(expected) + } + }) + + it('a parametrized (state, arg) memoized selector keeps updating', () => { + const store = makeStore() + let resultRuns = 0 + const selectScaled = createSelector( + [ + (s: RootState) => s.counter.value, + (_s: RootState, factor: number) => factor, + ], + (value, factor) => { + resultRuns++ + return { scaled: value * factor } + }, + ) + + function Scaled({ factor }: { factor: number }) { + const scaled = useSelector( + (s: RootState) => selectScaled(s, factor).scaled, + ) + return
{scaled}
+ } + + const { getByTestId } = rtl.render( + + + + , + ) + expect(getByTestId('x2').textContent).toBe('0') + expect(getByTestId('x3').textContent).toBe('0') + + for (const value of [1, 2, 3]) { + incrementTimes(store, 1) + expect(getByTestId('x2').textContent).toBe(String(value * 2)) + expect(getByTestId('x3').textContent).toBe(String(value * 3)) + } + // The result function is memoized on the input VALUES, so it runs + // once per (value, factor) pair regardless of how many times the + // wrapper selector was evaluated. + expect(resultRuns).toBe(8) + }) + + it('nested memoized input selectors keep updating', () => { + const store = makeStore() + const selectIncompleteCount = createSelector( + [(s: RootState) => s.todos], + (todos) => todos.filter((t) => !t.completed).length, + ) + const selectCounterValue = createSelector( + [(s: RootState) => s.counter], + (counter) => counter.value, + ) + const selectReport = createSelector( + [selectIncompleteCount, selectCounterValue], + (incomplete, value) => ({ + label: `${incomplete} open / ${value} clicks`, + }), + ) + + function Report() { + return
{useSelector(selectReport).label}
+ } + function Mirror() { + return
{useSelector(selectReport).label}
+ } + + const { getByTestId } = rtl.render( + + + + , + ) + expect(getByTestId('report').textContent).toBe('1 open / 0 clicks') + + incrementTimes(store, 2) + expect(getByTestId('report').textContent).toBe('1 open / 2 clicks') + expect(getByTestId('mirror').textContent).toBe('1 open / 2 clicks') + + rtl.act(() => { + store.dispatch({ type: 'toggle', id: 2 }) + }) + expect(getByTestId('report').textContent).toBe('2 open / 2 clicks') + expect(getByTestId('mirror').textContent).toBe('2 open / 2 clicks') + + incrementTimes(store, 1) + expect(getByTestId('report').textContent).toBe('2 open / 3 clicks') + expect(getByTestId('mirror').textContent).toBe('2 open / 3 clicks') + }) + + it('lruMemoize for both memoize and argsMemoize keeps updating', () => { + const store = makeStore() + const selectDoubled = createSelector( + [(s: RootState) => s.counter.value], + (value) => ({ doubled: value * 2 }), + { + memoize: lruMemoize, + argsMemoize: lruMemoize, + devModeChecks: { identityFunctionCheck: 'never' }, + }, + ) + + function A() { + const doubled = useSelector((s: RootState) => selectDoubled(s).doubled) + return
{doubled}
+ } + function B() { + return
{useSelector(selectDoubled).doubled}
+ } + + const { getByTestId } = rtl.render( + +
+ + , + ) + for (const expected of ['2', '4', '6']) { + incrementTimes(store, 1) + expect(getByTestId('a').textContent).toBe(expected) + expect(getByTestId('b').textContent).toBe(expected) + } + }) + + it('a selector memoized on a slice, not the root, stays correct and skips unrelated slices', () => { + // Here the cache key is `s.todos`, which the hook reads before calling + // the memoized helper, so that read is tracked even when the helper + // itself short-circuits. + const store = makeStore() + let resultRuns = 0 + const selectTexts = createSelector([(todos: Todo[]) => todos], (todos) => { + resultRuns++ + return todos.map((t) => t.text).join(',') + }) + + let renders = 0 + function Texts() { + renders++ + const texts = useSelector((s: RootState) => selectTexts(s.todos)) + return
{texts}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + expect(getByTestId('texts').textContent).toBe('write tests,review code') + expect(renders).toBe(1) + expect(resultRuns).toBe(1) + + incrementTimes(store, 2) + expect(renders).toBe(1) + expect(resultRuns).toBe(1) + + for (const [id, text] of [ + [1, 'ship it'], + [2, 'merge it'], + ] as const) { + rtl.act(() => { + store.dispatch({ type: 'editText', id, text }) + }) + } + expect(getByTestId('texts').textContent).toBe('ship it,merge it') + expect(renders).toBe(3) + expect(resultRuns).toBe(3) + }) + + it('calling the same memoized selector with raw state outside React does not break the hook', () => { + const store = makeStore() + const selectDoubled = selectSummary() + + function A() { + return
{useSelector(selectDoubled).doubled}
+ } + + const { getByTestId } = rtl.render( + +
+ , + ) + + for (const expected of ['2', '4', '6']) { + incrementTimes(store, 1) + // A thunk or listener reading through the same selector with the + // raw state populates a separate cache entry. + expect(selectDoubled(store.getState()).doubled).toBe(Number(expected)) + expect(getByTestId('a').textContent).toBe(expected) + } + }) + + it('documented limitation: a cache that never reads state cannot be tracked', () => { + // Same result in both implementations — stock useSelector returns the + // stale cached object too. Pinned so the behavior is deliberate. + const store = makeStore() + let cached: { doubled: number } | undefined + const selectOnce = (s: RootState) => { + if (cached === undefined) cached = { doubled: s.counter.value * 2 } + return cached + } + + function A() { + return
{useSelector(selectOnce).doubled}
+ } + const { getByTestId } = rtl.render( + +
+ , + ) + incrementTimes(store, 2) + expect(getByTestId('a').textContent).toBe('0') + }) +}) From fbb283540539b1144a9c7abcc88670bd8a49c20b Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 24 Sep 2026 13:04:18 +0200 Subject: [PATCH 79/95] Test mixed memoize/argsMemoize combinations and shallowEqual args --- test/signals/reselect.spec.tsx | 97 +++++++++++++++++++++++++++++++++- 1 file changed, 96 insertions(+), 1 deletion(-) diff --git a/test/signals/reselect.spec.tsx b/test/signals/reselect.spec.tsx index 81181fdb5..6a3418f42 100644 --- a/test/signals/reselect.spec.tsx +++ b/test/signals/reselect.spec.tsx @@ -15,7 +15,7 @@ import { configureStore } from '@reduxjs/toolkit' import * as rtl from '@testing-library/react' import React from 'react' -import { Provider, useSelector } from 'react-redux' +import { Provider, shallowEqual, useSelector } from 'react-redux' import { createSelector, lruMemoize, weakMapMemoize } from 'reselect' import { afterEach, describe, expect, it } from 'vitest' @@ -639,6 +639,101 @@ describe('args-memoization cache hits', () => { } }) + it.each([ + ['weakMapMemoize', 'lruMemoize', weakMapMemoize, lruMemoize], + ['lruMemoize', 'weakMapMemoize', lruMemoize, weakMapMemoize], + ] as const)( + 'memoize: %s with argsMemoize: %s keeps updating', + (_m, _a, memoize, argsMemoize) => { + const store = makeStore() + let resultRuns = 0 + const selectDoubled = createSelector( + [(s: RootState) => s.counter.value], + (value) => { + resultRuns++ + return { doubled: value * 2 } + }, + { + memoize, + argsMemoize, + devModeChecks: { identityFunctionCheck: 'never' }, + }, + ) + + function A() { + const doubled = useSelector((s: RootState) => selectDoubled(s).doubled) + return
{doubled}
+ } + function B() { + return
{useSelector(selectDoubled).doubled}
+ } + + const { getByTestId } = rtl.render( + +
+ + , + ) + for (const expected of ['2', '4', '6']) { + incrementTimes(store, 1) + expect(getByTestId('a').textContent).toBe(expected) + expect(getByTestId('b').textContent).toBe(expected) + } + // The result layer keys on the input value, so it runs once per + // distinct counter value no matter which args layer sits above it. + expect(resultRuns).toBe(4) + }, + ) + + it('lruMemoize args with shallowEqual keeps updating, at the cost of coarse tracking', () => { + // shallowEqual has to enumerate the root and read every top-level + // slice to compare two state arguments, so the hook depends on all of + // them. It re-evaluates on every dispatch but only re-renders when the + // derived value changes. + const store = makeStore() + let resultRuns = 0 + const selectDoubled = createSelector( + [(s: RootState) => s.counter.value], + (value) => { + resultRuns++ + return { doubled: value * 2 } + }, + { + memoize: lruMemoize, + argsMemoize: lruMemoize, + argsMemoizeOptions: { equalityCheck: shallowEqual }, + devModeChecks: { identityFunctionCheck: 'never' }, + }, + ) + + let renders = 0 + function A() { + renders++ + return
{useSelector(selectDoubled).doubled}
+ } + + const { getByTestId } = rtl.render( + +
+ , + ) + expect(renders).toBe(1) + + for (const expected of ['2', '4', '6']) { + incrementTimes(store, 1) + expect(getByTestId('a').textContent).toBe(expected) + } + expect(renders).toBe(4) + expect(resultRuns).toBe(4) + + rtl.act(() => { + store.dispatch({ type: 'toggle', id: 1 }) + }) + expect(getByTestId('a').textContent).toBe('6') + expect(renders).toBe(4) + expect(resultRuns).toBe(4) + }) + it('a selector memoized on a slice, not the root, stays correct and skips unrelated slices', () => { // Here the cache key is `s.todos`, which the hook reads before calling // the memoized helper, so that read is tracked even when the helper From 154319fac97a696022ef0c5135b2e4695dfdaeb6 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 24 Sep 2026 13:09:27 +0200 Subject: [PATCH 80/95] Create a fresh root tracking proxy on every evaluation The root proxy was cached by state identity like every other proxy, so two evaluations against the same state handed Reselect the same argument. Its argsMemoize layer then short-circuited before any input selector ran, no property was read, and the hook recorded no dependencies. Any hook that evaluated a memoized selector a second time against unchanged state (a shared selector, an inline wrapper after the render-phase swap, a (state, arg) lookup) stopped updating. Only the root skips the cache. Child proxies stay cached by target identity, so result functions still memoize on unchanged inputs. --- src/signals/trackingProxy.ts | 23 ++++++++++++++++++----- test/signals/reselect.spec.tsx | 25 +++++++++++++++---------- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index ffe8d1c27..f3197d17f 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -182,7 +182,9 @@ export function unwrap(value: T): T { * Child proxies are cached in `registry.proxyCache`, keyed by target * identity. The cache persists across evaluations and dispatches, so an * unchanged subtree yields the same proxy object every time — selector - * results built from those proxies stay referentially equal. + * results built from those proxies stay referentially equal. The root + * proxy is the exception: it is created fresh on every call so that + * caches keyed on the state argument cannot skip the selector body. * @param target - The frozen state object to wrap * @param parentPath - Dot-separated path to this object in the state tree * @param registry - Signal registry for dependency tracking @@ -207,9 +209,18 @@ export function createTrackingProxy( registry.leafTrackerHolder.current = leafTracker } - // Check proxy cache — reuse proxy if we've already wrapped this exact object - const cached = cache.get(target) - if (cached) return cached as T + // The root proxy is never cached: every evaluation gets a fresh one. + // Memoized selectors (Reselect's argsMemoize, WeakMap caches keyed on + // the state argument) short-circuit when handed an argument they have + // seen before, and a short-circuited call reads no state properties, so + // the evaluation would record no dependencies. A fresh root defeats + // that cache every time. Result functions stay memoized because their + // inputs are child proxies, which ARE cached by target identity. + const isRoot = parentPath === '' + if (!isRoot) { + const cached = cache.get(target) + if (cached) return cached as T + } // Use an unfrozen shell as the proxy target to avoid ES Proxy invariant // violations with frozen objects. The shell copies the target's prototype @@ -425,7 +436,9 @@ export function createTrackingProxy( }) as T // Cache by target identity — unchanged Immer subtrees reuse same proxy - cache.set(target, proxy) + if (!isRoot) { + cache.set(target, proxy) + } proxyPathMap.set(proxy as object, parentPath) proxyTargetMap.set(proxy as object, target) diff --git a/test/signals/reselect.spec.tsx b/test/signals/reselect.spec.tsx index 6a3418f42..575145d0f 100644 --- a/test/signals/reselect.spec.tsx +++ b/test/signals/reselect.spec.tsx @@ -2,15 +2,17 @@ * Reselect interop. Runs against both implementations via the * `react-redux` alias in vitest.config.mts. * - * The interesting interaction is in signals mode: `weakMapMemoize` - * (Reselect 5's default) caches on the state argument's identity, and - * the signals implementation passes a tracking proxy as that argument. - * The proxy for a given state object is cached and reused, so when the - * hook re-runs a memoized selector with an unchanged state, Reselect - * short-circuits WITHOUT touching any state properties. Dependency - * tracking must survive those cache-hit runs — a hook must never end up - * with an empty dependency set just because one of its evaluations was - * a memoization hit. + * The interesting interaction is in signals mode. Reselect memoizes a + * selector on its arguments (`argsMemoize`) before running any input + * selector, and the signals implementation passes a tracking proxy as + * the state argument. If two evaluations were handed the same proxy, the + * second would be an args-cache hit that reads no state properties, and + * the hook would record no dependencies. The implementation therefore + * hands every evaluation a fresh root proxy, so the args layer always + * misses, while child proxies stay cached by target identity so the + * result layer still memoizes on unchanged inputs. The tests here pin + * both halves: hooks keep updating, and result functions do not rerun + * for unrelated changes. */ import { configureStore } from '@reduxjs/toolkit' import * as rtl from '@testing-library/react' @@ -60,7 +62,10 @@ const makeStore = () => }, counter: (state: { value: number } = { value: 0 }, action: any) => action.type === 'increment' ? { value: state.value + 1 } : state, - filter: (state = { showCompleted: true }, action: any) => + filter: ( + state: { showCompleted: boolean } = { showCompleted: true }, + action: any, + ) => action.type === 'setFilter' ? { showCompleted: action.payload } : state, }, }) From 46b5e94c2d8ae3fddc5151e061f0dbf0027eead5 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 24 Sep 2026 13:17:20 +0200 Subject: [PATCH 81/95] Share top-level path keys across root proxies --- src/signals/pathSignalRegistry.ts | 7 +++++++ src/signals/trackingProxy.ts | 5 ++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/signals/pathSignalRegistry.ts b/src/signals/pathSignalRegistry.ts index 74e11ef0d..d47807efa 100644 --- a/src/signals/pathSignalRegistry.ts +++ b/src/signals/pathSignalRegistry.ts @@ -90,6 +90,11 @@ export interface PathSignalRegistry { /** Proxy cache for reusing proxies across evaluations (keyed by object identity). */ proxyCache: ProxyCache + /** Encoded path keys for top-level state properties. Root proxies are + * created fresh per evaluation, so they share this instead of each + * building its own. */ + rootPathKeyCache: Map + /** Holder for the leaf tracker of the evaluation currently running. * Proxies read this at trap time instead of closing over a tracker, * so cached proxies record into whichever evaluation is active. */ @@ -581,6 +586,8 @@ export function createPathSignalRegistry( proxyCache: proxyWeakMap, + rootPathKeyCache: new Map(), + leafTrackerHolder: { current: undefined }, getArrayMeta(arrayPath: string): ArrayMeta | undefined { diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index f3197d17f..99bb07d4d 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -236,7 +236,10 @@ export function createTrackingProxy( // Property keys are encoded so reserved path characters in state keys // (dots in RTKQ cache keys, literal '@@keys', etc.) can't collide with // other paths or meta segments. The cache amortizes the encoding. - const pathKeyCache = new Map() + // Root proxies are not reused, so they share the registry's cache. + const pathKeyCache = isRoot + ? registry.rootPathKeyCache + : new Map() function getPathKey(prop: string): string { let key = pathKeyCache.get(prop) if (key === undefined) { From f69830685db833b5bc8b7b62711efd6566daa1fa Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 24 Sep 2026 13:52:10 +0200 Subject: [PATCH 82/95] Return raw elements with identity dependencies from array methods - find/findLast/filter/slice hand back raw elements plus one identity dependency per element instead of tracking proxies - map is now overridden: scans through the recorder (column signals for primitive results), element results come back raw, tracking proxies returned from elsewhere (ids.map(id => entities[id])) are unwrapped with an identity dependency, and callbacks that embed the element in a fresh object fall back to raw elements under the coarse array signal - Reselect result functions fed by entity-adapter selectAll now run on plain arrays instead of paying a proxy trap per element read --- src/signals/arrayMethodOverrides.ts | 140 +++++++++++-- src/signals/trackingProxy.ts | 66 ++++--- test/signals/arrayMethodOverrides.spec.ts | 230 +++++++++++++--------- test/signals/reselect.spec.tsx | 142 ++++++++++++- test/signals/trackingProxy.spec.ts | 15 +- 5 files changed, 452 insertions(+), 141 deletions(-) diff --git a/src/signals/arrayMethodOverrides.ts b/src/signals/arrayMethodOverrides.ts index c8d27f2d6..fed2cd4b4 100644 --- a/src/signals/arrayMethodOverrides.ts +++ b/src/signals/arrayMethodOverrides.ts @@ -1,17 +1,35 @@ import { throwStateMutationError } from './mutationError' import type { PathSignalRegistry, StructureKind } from './pathSignalRegistry' -import { registerRecordingHolder, unwrap } from './trackingProxy' +import { + getElementPathKey, + getProxyPath, + getProxyTarget, + registerRecordingHolder, + unwrap, +} from './trackingProxy' /** * Non-mutating array methods that we override on the tracking proxy. * * The key optimization: callbacks scan elements WITHOUT per-element proxy - * creation or per-element signal registration. Only the RESULTS get proxied — - * so fine-grained signal dependencies are established only for the elements - * the selector actually uses. + * creation or per-element signal registration, and elements that leave + * the array through a method (find, filter, slice, map's `x => x`) leave + * as RAW objects with one identity dependency each on the element's path. * * Example: `state.items.find(i => i.id === 42)` scans 1000 items (fast), - * then returns a proxy for the one match (registers signals for that element only). + * then returns the raw match and depends on `items.{id:42}`. + * + * **Why raw elements:** state is immutable, so any change inside an + * element replaces the element and fires its identity signal. Tracking + * individual fields of an element after it has left the array would be + * more precise but costs a proxy trap plus a signal registration per + * read — and elements handed to downstream code (Reselect result + * functions, `.filter` on the returned array) are typically read + * wholesale, thousands of reads per evaluation. The identity dependency + * can over-run the selector (a change to a field it didn't read), never + * miss an update; the result equality check keeps over-runs from + * becoming renders. Direct index access (`items[0].name`) still returns + * a tracking proxy and keeps field-level precision. * * **Dependency tracking for scans:** * - Callback methods (find, filter, some, every, findIndex, ...) pass a @@ -30,13 +48,16 @@ import { registerRecordingHolder, unwrap } from './trackingProxy' * join, concat, slice, ...) always register the coarse array signal. * * Categories: - * - Subset operations (find, findLast, filter, slice): return proxied elements + * - Subset operations (find, findLast, filter, slice): return raw elements + * with identity dependencies + * - map: scans through the recorder; results that are elements (own or + * from another tracked object) come back raw with identity dependencies * - Primitive-returning (findIndex, indexOf, some, every, includes, etc.): return as-is * - Transform operations (concat, flat): return raw values (new structures, not subsets) - * - Pass-through (map, forEach, reduce, flatMap): NOT overridden — callbacks need tracking + * - Pass-through (forEach, reduce, flatMap): NOT overridden — callbacks need tracking */ -type SubsetMethod = 'find' | 'findLast' | 'filter' | 'slice' +type SubsetMethod = 'find' | 'findLast' | 'filter' | 'slice' | 'map' type PrimitiveMethod = | 'findIndex' | 'findLastIndex' @@ -59,6 +80,7 @@ const CALLBACK_METHODS = new Set([ 'find', 'findLast', 'filter', + 'map', 'findIndex', 'findLastIndex', 'some', @@ -66,11 +88,12 @@ const CALLBACK_METHODS = new Set([ ]) const OVERRIDDEN_METHODS = new Set([ - // Subset — return proxied results + // Subset — return raw elements with identity dependencies 'find', 'findLast', 'filter', 'slice', + 'map', // Primitive-returning 'findIndex', 'findLastIndex', @@ -237,6 +260,10 @@ const STRUCTURE_DEPS: Record< matched: ['append', 'insertOrReorder', 'remove'], missed: ['append', 'insertOrReorder', 'remove'], }, + map: { + matched: ['append', 'insertOrReorder', 'remove'], + missed: ['append', 'insertOrReorder', 'remove'], + }, some: { matched: ['remove'], missed: ['append', 'insertOrReorder'], @@ -286,13 +313,59 @@ function finalizeScanDeps( } } +/** + * Hand an element of this array to the caller: the raw value, plus an + * identity dependency on the element's path when it is an object. + * @param registry - Signal registry for dependency tracking + * @param parentPath - Path to the array in the state tree + * @param target - The raw frozen array + * @param index - Index of the element to emit + * @returns The raw element + */ +function emitElement( + registry: PathSignalRegistry, + parentPath: string, + target: readonly unknown[], + index: number, +): unknown { + const value = target[index] + if (value !== null && typeof value === 'object') { + registry + .getOrCreate(getElementPathKey(registry, parentPath, index, value), value) + .get() + } + return value +} + +/** + * Normalize a value returned from a `map` callback. A tracking proxy from + * elsewhere in the state tree (`ids.map(id => entities[id])`) comes back + * raw with an identity dependency on its path, so the caller's downstream + * reads run on plain objects. Anything else is returned unchanged. + * @param registry - Signal registry for dependency tracking + * @param value - The callback's return value + * @returns The value to place in the mapped array + */ +function emitMappedValue( + registry: PathSignalRegistry, + value: unknown, +): unknown { + if (value === null || typeof value !== 'object') return value + const raw = getProxyTarget(value) + if (raw === undefined) return value + const path = getProxyPath(value) + if (path !== undefined && path !== '') { + registry.getOrCreate(path, raw).get() + } + return raw +} + /** * Create an interceptor for an array method that operates on the raw frozen - * array, bypassing per-element proxy creation. Only results that the caller - * will actually use get wrapped in tracking proxies. + * array, bypassing per-element proxy creation. Elements that leave the + * array come back raw with identity dependencies (see module comment). * * @param target - The raw frozen array - * @param proxy - The tracking proxy wrapping this array (used to return proxied elements) * @param method - The method name being intercepted * @param registry - Signal registry for dependency tracking * @param parentPath - Path to this array in the state tree @@ -300,7 +373,6 @@ function finalizeScanDeps( */ export function createArrayMethodInterceptor( target: readonly unknown[], - proxy: object, method: string, registry: PathSignalRegistry, parentPath: string, @@ -315,7 +387,7 @@ export function createArrayMethodInterceptor( value: unknown, index: number, array: readonly unknown[], - ) => boolean + ) => unknown const recorder = createScanRecorder() let result: unknown @@ -325,12 +397,43 @@ export function createArrayMethodInterceptor( const matches: unknown[] = [] for (let i = 0; i < target.length; i++) { if (callback(presentElement(recorder, target[i]), i, target)) { - // Access through proxy to register signals for matching elements - matches.push((proxy as Record)[i]) + matches.push(emitElement(registry, parentPath, target, i)) } } result = matches matched = matches.length > 0 + } else if (m === 'map') { + const mapped: unknown[] = [] + // The recorder proxy is reused across elements, so a callback + // that embeds its element in a fresh object (`x => ({ item: x })`) + // would capture a recorder pointing at whichever element is + // scanned last. Once a callback returns a fresh object, redo that + // element and scan the rest with raw elements under the coarse + // array dependency. + let rawMode = false + for (let i = 0; i < target.length; i++) { + const element = target[i] + if (!rawMode) { + const out = callback(presentElement(recorder, element), i, target) + if (out === recorder.proxy) { + mapped[i] = emitElement(registry, parentPath, target, i) + continue + } + if ( + out === null || + typeof out !== 'object' || + getProxyTarget(out) !== undefined + ) { + mapped[i] = emitMappedValue(registry, out) + continue + } + rawMode = true + recorder.fallback = true + } + mapped[i] = emitMappedValue(registry, callback(element, i, target)) + } + result = mapped + matched = true } else if (FIND_METHODS.has(m)) { const isForward = m === 'find' const step = isForward ? 1 : -1 @@ -338,8 +441,7 @@ export function createArrayMethodInterceptor( result = undefined for (let i = start; i >= 0 && i < target.length; i += step) { if (callback(presentElement(recorder, target[i]), i, target)) { - // Return proxied element — registers signals for just this one - result = (proxy as Record)[i] + result = emitElement(registry, parentPath, target, i) break } } @@ -376,7 +478,7 @@ export function createArrayMethodInterceptor( const end = normalizeSliceIndex(rawEnd, target.length) const result: unknown[] = [] for (let i = start; i < end; i++) { - result.push((proxy as Record)[i]) + result.push(emitElement(registry, parentPath, target, i)) } return result } diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index 99bb07d4d..65a812681 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -167,6 +167,45 @@ export function unwrap(value: T): T { return value } +/** + * Path key for an object element of a tracked array: the identity path + * (`items.{id:42}`) when the array has a detectable key field, otherwise + * the index path (`items.3`). Detects and records the array's key field + * on first use. + * @param registry - Signal registry holding per-array metadata + * @param arrayPath - Path of the containing array + * @param index - The element's index (number or canonical index string) + * @param element - The raw element object + * @returns The element's path key + */ +export function getElementPathKey( + registry: PathSignalRegistry, + arrayPath: string, + index: number | string, + element: object, +): string { + if (!Array.isArray(element)) { + let meta = registry.getArrayMeta(arrayPath) + if (!meta) { + const keyField = findKeyField(element) + if (keyField) { + meta = { keyField, entityMap: new Map() } + registry.setArrayMeta(arrayPath, meta) + } + } + if (meta) { + const kv = getKeyValue(element, meta.keyField) + if (kv !== undefined) { + return buildIdentityPath(arrayPath, meta.keyField, kv) + } + } + } + return joinPath( + arrayPath, + typeof index === 'number' ? index : encodePathSegment(index), + ) +} + /** * Creates a read-only tracking proxy that wraps frozen Redux state. * @@ -269,7 +308,6 @@ export function createTrackingProxy( } return createArrayMethodInterceptor( target, - proxy, prop as string, registry, parentPath, @@ -306,28 +344,10 @@ export function createTrackingProxy( let pathKey = getPathKey(prop as string) if (isObjectOrArray(value)) { - // For array element access: check if parent array has identity-based tracking. - // If so, use the identity path (items.{id:42}) instead of index path (items.0). - if ( - Array.isArray(target) && - !Array.isArray(value) && - !isNaN(Number(prop)) - ) { - let meta = registry.getArrayMeta(parentPath) - if (!meta) { - // First time accessing this array's elements — try to detect key field - const keyField = findKeyField(value) - if (keyField) { - meta = { keyField, entityMap: new Map() } - registry.setArrayMeta(parentPath, meta) - } - } - if (meta) { - const kv = getKeyValue(value, meta.keyField) - if (kv !== undefined) { - pathKey = buildIdentityPath(parentPath, meta.keyField, kv) - } - } + // Array elements use the identity path (items.{id:42}) when the + // array has a key field, so signals survive reorders. + if (Array.isArray(target) && isIndexProp(prop as string)) { + pathKey = getElementPathKey(registry, parentPath, prop, value) } // Non-plain objects (Date, Map, Set, class instances): return the diff --git a/test/signals/arrayMethodOverrides.spec.ts b/test/signals/arrayMethodOverrides.spec.ts index fedb4cd8a..b058d4bd3 100644 --- a/test/signals/arrayMethodOverrides.spec.ts +++ b/test/signals/arrayMethodOverrides.spec.ts @@ -38,14 +38,14 @@ describe('Array method overrides on tracking proxy', () => { }) describe('find()', () => { - test('returns found item as proxy', () => { + test('returns the raw found item with an identity dependency', () => { const state = createTestData() - const { proxy } = setup(state) + const { proxy, registry } = setup(state) const found = proxy.items.find((item) => item.id === 3) - expect(found).toBeDefined() - expect(found!.value).toBe(30) - // Verify it's a proxy (has a path) - expect(getProxyPath(found)).toBeDefined() + expect(found).toBe(state.items[2]) + expect(getProxyPath(found)).toBeUndefined() + expect(registry.has('items.{id:3}')).toBe(true) + expect(registry.has('items.{id:2}')).toBe(false) }) test('returns undefined when not found', () => { @@ -132,7 +132,7 @@ describe('Array method overrides on tracking proxy', () => { }) describe('findLast()', () => { - test('returns last matching item as proxy', () => { + test('returns the raw last matching item with an identity dependency', () => { const state = deepFreeze({ items: [ { id: 1, type: 'A' }, @@ -140,11 +140,11 @@ describe('Array method overrides on tracking proxy', () => { { id: 3, type: 'A' }, ], }) - const { proxy } = setup(state) + const { proxy, registry } = setup(state) const found = proxy.items.findLast((item) => item.type === 'A') - expect(found).toBeDefined() - expect(found!.id).toBe(3) - expect(getProxyPath(found)).toBeDefined() + expect(found).toBe(state.items[2]) + expect(registry.has('items.{id:3}')).toBe(true) + expect(registry.has('items.{id:1}')).toBe(false) }) test('returns undefined when not found', () => { @@ -228,18 +228,13 @@ describe('Array method overrides on tracking proxy', () => { }) describe('filter()', () => { - test('returns filtered items as proxies', () => { + test('returns the raw matching items', () => { const state = createTestData() const { proxy } = setup(state) const filtered = proxy.items.filter((item) => item.value > 25) - expect(filtered).toHaveLength(3) - expect(filtered[0].id).toBe(3) - expect(filtered[1].id).toBe(4) - expect(filtered[2].id).toBe(5) - // Each result should be a proxy - for (const item of filtered) { - expect(getProxyPath(item)).toBeDefined() - } + expect(filtered).toEqual([state.items[2], state.items[3], state.items[4]]) + expect(filtered[0]).toBe(state.items[2]) + expect(getProxyPath(filtered[0])).toBeUndefined() }) test('callback receives scan recorder, not tracking proxies', () => { @@ -254,17 +249,16 @@ describe('Array method overrides on tracking proxy', () => { expect(unwrapped).toEqual([...state.items]) }) - test('only registers signals for matching elements', () => { + test('registers identity signals for matching elements only', () => { const state = createTestData() const { proxy, registry } = setup(state) const filtered = proxy.items.filter((item) => item.id === 3) - // Access a property on the result - const _val = filtered[0].value - - // Should have signal for the matching item, not others - expect(registry.has('items.{id:3}.value')).toBe(true) - expect(registry.has('items.{id:1}.value')).toBe(false) - expect(registry.has('items.{id:2}.value')).toBe(false) + // Reads on the raw result are not tracked; the element identity is + expect(filtered[0].value).toBe(30) + expect(registry.has('items.{id:3}')).toBe(true) + expect(registry.has('items.{id:3}.value')).toBe(false) + expect(registry.has('items.{id:1}')).toBe(false) + expect(registry.has('items.{id:2}')).toBe(false) }) test('filtered items give access to nested properties', () => { @@ -305,26 +299,21 @@ describe('Array method overrides on tracking proxy', () => { }) describe('slice()', () => { - test('returns sliced items as proxies', () => { + test('returns the raw sliced items', () => { const state = createTestData() const { proxy } = setup(state) const sliced = proxy.items.slice(1, 3) - expect(sliced).toHaveLength(2) - expect(sliced[0].id).toBe(2) - expect(sliced[1].id).toBe(3) - for (const item of sliced) { - expect(getProxyPath(item)).toBeDefined() - } + expect(sliced).toEqual([state.items[1], state.items[2]]) + expect(sliced[0]).toBe(state.items[1]) + expect(getProxyPath(sliced[0])).toBeUndefined() }) - test('slice with no arguments returns all as proxies', () => { + test('slice with no arguments returns a raw copy of all items', () => { const state = createTestData() const { proxy } = setup(state) const sliced = proxy.items.slice() - expect(sliced).toHaveLength(5) - for (const item of sliced) { - expect(getProxyPath(item)).toBeDefined() - } + expect(sliced).toEqual([...state.items]) + expect(sliced).not.toBe(state.items) }) test('slice with negative indices', () => { @@ -352,14 +341,14 @@ describe('Array method overrides on tracking proxy', () => { expect(sliced).toHaveLength(5) }) - test('only registers signals for sliced elements', () => { + test('registers identity signals for sliced elements only', () => { const state = createTestData() const { proxy, registry } = setup(state) - const sliced = proxy.items.slice(2, 3) // just item at index 2 (id: 3) - const _val = sliced[0].value + proxy.items.slice(2, 3) // just item at index 2 (id: 3) - expect(registry.has('items.{id:3}.value')).toBe(true) - expect(registry.has('items.{id:1}.value')).toBe(false) + expect(registry.has('items.{id:3}')).toBe(true) + expect(registry.has('items.{id:1}')).toBe(false) + expect(registry.has('items.{id:2}')).toBe(false) }) }) @@ -569,17 +558,88 @@ describe('Array method overrides on tracking proxy', () => { }) }) - describe('Non-overridden methods (pass-through)', () => { - test('map() callbacks receive proxied values', () => { + describe('map()', () => { + test('primitive results register a column signal, not element signals', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + const mapped = proxy.items.map((item) => item.value * 2) + expect(mapped).toEqual([20, 40, 60, 80, 100]) + expect(registry.has('items.{*}.value')).toBe(true) + expect(registry.has('items.{id:1}.value')).toBe(false) + expect(registry.has('items.{id:1}')).toBe(false) + }) + + test('identity mapping returns raw elements with identity dependencies', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + const mapped = proxy.items.map((item) => item) + expect(mapped).toEqual([...state.items]) + expect(mapped[0]).toBe(state.items[0]) + for (let id = 1; id <= 5; id++) { + expect(registry.has(`items.{id:${id}}`)).toBe(true) + } + }) + + test('tracking proxies returned from elsewhere come back raw with identity dependencies', () => { + const state = deepFreeze({ + ids: [2, 1], + entities: { + 1: { id: 1, name: 'one' }, + 2: { id: 2, name: 'two' }, + } as Record, + }) + const { proxy, registry } = setup(state) + const all = proxy.ids.map((id) => proxy.entities[id]) + expect(all).toEqual([state.entities[2], state.entities[1]]) + expect(all[0]).toBe(state.entities[2]) + expect(getProxyPath(all[0])).toBeUndefined() + expect(registry.has('entities.2')).toBe(true) + expect(registry.has('entities.1')).toBe(true) + // Primitive ids fall back to the coarse array signal + expect(registry.has('ids')).toBe(true) + }) + + test('fresh objects that embed the element get the raw element, not the recorder', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + const calls: number[] = [] + const mapped = proxy.items.map((item, i) => { + calls.push(i) + return { item, doubled: item.value * 2 } + }) + expect(mapped.map((m) => m.item)).toEqual([...state.items]) + expect(mapped[0].item).toBe(state.items[0]) + expect(mapped[4].item).toBe(state.items[4]) + expect(mapped.map((m) => m.doubled)).toEqual([20, 40, 60, 80, 100]) + // The first element is scanned twice (recorder, then raw); the rest once + expect(calls).toEqual([0, 0, 1, 2, 3, 4]) + // Embedded elements force the coarse array dependency + expect(registry.has('items')).toBe(true) + }) + + test('nested object reads fall back to the coarse array signal', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + const counts = proxy.items.map((item) => item.nested.count) + expect(counts).toEqual([1, 2, 3, 4, 5]) + expect(registry.has('items')).toBe(true) + expect(registry.has('items.{*}.nested')).toBe(false) + }) + + test('callback receives correct index and array arguments', () => { const state = createTestData() const { proxy } = setup(state) - const mapped = proxy.items.map((item) => { - // map is NOT overridden — callback should receive proxy - return item.value * 2 + const seen: number[] = [] + proxy.items.map((_item, index, arr) => { + seen.push(index) + expect(arr).toBe(state.items) + return index }) - expect(mapped).toEqual([20, 40, 60, 80, 100]) + expect(seen).toEqual([0, 1, 2, 3, 4]) }) + }) + describe('Non-overridden methods (pass-through)', () => { test('forEach() callbacks receive proxied values', () => { const state = createTestData() const { proxy } = setup(state) @@ -609,44 +669,37 @@ describe('Array method overrides on tracking proxy', () => { }) describe('Signal tracking precision', () => { - test('find() registers signals only for the result element', () => { + test('find() registers the column read by the callback plus the result identity', () => { const state = createTestData() const { proxy, registry } = setup(state) // Simulate what a selector does: find + read properties const item = proxy.items.find((i) => i.id === 3)! - const _label = item.value - const _nested = item.nested.count - - // The found item (id:3) should have signals - expect(registry.has('items.{id:3}.value')).toBe(true) - expect(registry.has('items.{id:3}.nested.count')).toBe(true) + expect(item.value).toBe(30) + expect(item.nested.count).toBe(3) - // Other items should NOT have signals - expect(registry.has('items.{id:1}.value')).toBe(false) - expect(registry.has('items.{id:2}.nested.count')).toBe(false) - expect(registry.has('items.{id:4}.value')).toBe(false) - expect(registry.has('items.{id:5}.value')).toBe(false) + expect(registry.debugPaths().sort()).toEqual( + ['items.{*}.id', 'items.{id:3}', 'items.@@insertOrReorder'].sort(), + ) }) - test('filter() registers signals only for matching elements', () => { + test('filter() registers the column read by the callback plus matching identities', () => { const state = createTestData() const { proxy, registry } = setup(state) const highValue = proxy.items.filter((i) => i.value > 30) - // Read properties to register - for (const item of highValue) { - const _v = item.value - } - - // Items 4 and 5 (value > 30) should have signals - expect(registry.has('items.{id:4}.value')).toBe(true) - expect(registry.has('items.{id:5}.value')).toBe(true) - - // Items 1, 2, 3 should NOT - expect(registry.has('items.{id:1}.value')).toBe(false) - expect(registry.has('items.{id:2}.value')).toBe(false) - expect(registry.has('items.{id:3}.value')).toBe(false) + expect(highValue.map((i) => i.value)).toEqual([40, 50]) + + expect(registry.debugPaths().sort()).toEqual( + [ + 'items.{*}.value', + 'items.{id:4}', + 'items.{id:5}', + 'items.@@append', + 'items.@@insertOrReorder', + 'items.@@remove', + ].sort(), + ) }) test('primitive-returning methods register no element signals', () => { @@ -683,18 +736,17 @@ describe('Array method overrides on tracking proxy', () => { // selectById pattern const found = proxy.find((i) => i.id === 42)! - const _value = found.value - const _label = found.label - - // Only 1 entity's signals should be registered - expect(registry.has('items.{id:42}.value')).toBe(true) - expect(registry.has('items.{id:42}.label')).toBe(true) - - // Spot-check other items are NOT registered - expect(registry.has('items.{id:0}.value')).toBe(false) - expect(registry.has('items.{id:41}.value')).toBe(false) - expect(registry.has('items.{id:43}.value')).toBe(false) - expect(registry.has('items.{id:99}.value')).toBe(false) + expect(found.value).toBe(420) + expect(found.label).toBe('Item 42') + + // One column signal for the scan, one identity signal for the match, + // and nothing for the other 99 entities + expect(registry.has('items.{*}.id')).toBe(true) + expect(registry.has('items.{id:42}')).toBe(true) + expect(registry.has('items.{id:42}.value')).toBe(false) + expect(registry.has('items.{id:41}')).toBe(false) + expect(registry.has('items.{id:43}')).toBe(false) + expect(registry.size()).toBe(3) }) }) diff --git a/test/signals/reselect.spec.tsx b/test/signals/reselect.spec.tsx index 575145d0f..324c4bbe3 100644 --- a/test/signals/reselect.spec.tsx +++ b/test/signals/reselect.spec.tsx @@ -14,7 +14,12 @@ * both halves: hooks keep updating, and result functions do not rerun * for unrelated changes. */ -import { configureStore } from '@reduxjs/toolkit' +import { + configureStore, + createEntityAdapter, + createSlice, +} from '@reduxjs/toolkit' +import { getProxyPath } from '@internal/signals/trackingProxy' import * as rtl from '@testing-library/react' import React from 'react' import { Provider, shallowEqual, useSelector } from 'react-redux' @@ -828,3 +833,138 @@ describe('args-memoization cache hits', () => { expect(getByTestId('a').textContent).toBe('0') }) }) + +describe('entity adapter selectors feeding result functions', () => { + // The derived-selectors benchmark shape: `selectAll` (ids.map(id => + // entities[id])) feeds result functions that filter the whole array. + // In signals mode the arrays must reach the result function as raw + // objects, or every element read becomes a proxy trap plus a signal + // registration, per component, per dispatch. + interface Post { + id: number + title: string + score: number + } + interface Comment { + id: number + postId: number + } + + const postsAdapter = createEntityAdapter() + const commentsAdapter = createEntityAdapter() + + const postsSlice = createSlice({ + name: 'posts', + initialState: postsAdapter.getInitialState(undefined, [ + { id: 1, title: 'one', score: 10 }, + { id: 2, title: 'two', score: 20 }, + { id: 3, title: 'three', score: 30 }, + ]), + reducers: { updateScore: postsAdapter.updateOne }, + }) + const commentsSlice = createSlice({ + name: 'comments', + initialState: commentsAdapter.getInitialState(undefined, [ + { id: 1, postId: 1 }, + { id: 2, postId: 1 }, + { id: 3, postId: 2 }, + ]), + reducers: { addComment: commentsAdapter.addOne }, + }) + + const makeEntityStore = () => + configureStore({ + reducer: { posts: postsSlice.reducer, comments: commentsSlice.reducer }, + }) + type EntityState = ReturnType['getState']> + + const { selectById: selectPostById } = postsAdapter.getSelectors( + (s: EntityState) => s.posts, + ) + const { selectAll: selectAllComments } = commentsAdapter.getSelectors( + (s: EntityState) => s.comments, + ) + + it('result functions receive raw arrays and every consumer keeps updating', () => { + const store = makeEntityStore() + let resultRuns = 0 + let sawProxy = false + const selectPostWithComments = createSelector( + [ + (s: EntityState, postId: number) => selectPostById(s, postId), + selectAllComments, + ], + (post, comments) => { + resultRuns++ + if ( + getProxyPath(comments) !== undefined || + comments.some((c) => getProxyPath(c) !== undefined) + ) { + sawProxy = true + } + return { + ...post, + commentCount: comments.filter((c) => c.postId === post.id).length, + } + }, + ) + + const renders: Record = { 1: 0, 2: 0, 3: 0 } + function PostCard({ postId }: { postId: number }) { + renders[postId]++ + const data = useSelector((s: EntityState) => + selectPostWithComments(s, postId), + ) + return ( +
+ {data.title}:{data.score}:{data.commentCount} +
+ ) + } + + const { getByTestId } = rtl.render( + + + + + , + ) + expect(getByTestId('post-1').textContent).toBe('one:10:2') + expect(getByTestId('post-2').textContent).toBe('two:20:1') + expect(getByTestId('post-3').textContent).toBe('three:30:0') + expect(sawProxy).toBe(false) + expect(renders).toEqual({ 1: 1, 2: 1, 3: 1 }) + resultRuns = 0 + + // Every post's result function depends on the comments array, so all + // three recompute and, because each returns a fresh object, all three + // re-render (same as stock). + rtl.act(() => { + store.dispatch(commentsSlice.actions.addComment({ id: 4, postId: 3 })) + }) + expect(getByTestId('post-3').textContent).toBe('three:30:1') + expect(resultRuns).toBe(3) + expect(renders).toEqual({ 1: 2, 2: 2, 3: 2 }) + expect(sawProxy).toBe(false) + resultRuns = 0 + + // A score change replaces one post entity: one recompute, one render. + rtl.act(() => { + store.dispatch( + postsSlice.actions.updateScore({ id: 2, changes: { score: 25 } }), + ) + }) + expect(getByTestId('post-2').textContent).toBe('two:25:1') + expect(resultRuns).toBe(1) + expect(renders).toEqual({ 1: 2, 2: 3, 3: 2 }) + resultRuns = 0 + + rtl.act(() => { + store.dispatch(commentsSlice.actions.addComment({ id: 5, postId: 2 })) + }) + expect(getByTestId('post-2').textContent).toBe('two:25:2') + expect(resultRuns).toBe(3) + expect(renders).toEqual({ 1: 3, 2: 4, 3: 3 }) + expect(sawProxy).toBe(false) + }) +}) diff --git a/test/signals/trackingProxy.spec.ts b/test/signals/trackingProxy.spec.ts index 17ba6d340..a25a0b46b 100644 --- a/test/signals/trackingProxy.spec.ts +++ b/test/signals/trackingProxy.spec.ts @@ -859,15 +859,12 @@ describe('unwrap', () => { (item: { id: number; name: string }) => unwrap(item) === rawItem, ) - expect(found).toBeDefined() - // find() returns a proxied result (lazy signal registration) - expect(found).not.toBe(state.items[1]) - expect(unwrap(found)).toBe(state.items[1]) - - // Accessing properties on the found proxy registers signals - const name = (found as { name: string }).name - expect(name).toBe('b') - expect(registry.has('items.{id:2}.name')).toBe(true) + // find() returns the raw element with an identity dependency; reads on + // it are not tracked individually + expect(found).toBe(state.items[1]) + expect(found!.name).toBe('b') + expect(registry.has('items.{id:2}')).toBe(true) + expect(registry.has('items.{id:2}.name')).toBe(false) }) }) From 176ebc3940f178a57eb4156d9bc9f9b740878464 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 24 Sep 2026 14:25:02 +0200 Subject: [PATCH 83/95] Add regression test for element-signal churn with memoized selectAll --- test/signals/depChurn.spec.ts | 240 ++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 test/signals/depChurn.spec.ts diff --git a/test/signals/depChurn.spec.ts b/test/signals/depChurn.spec.ts new file mode 100644 index 000000000..3bd1020d9 --- /dev/null +++ b/test/signals/depChurn.spec.ts @@ -0,0 +1,240 @@ +import { describe, it, expect } from 'vitest' +import { createSelector } from 'reselect' +import { alienEngine } from '../../src/signals/engine' +import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import type { PathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import { + createTrackingProxy, + getProxyPath, +} from '../../src/signals/trackingProxy' +import type { LeafObjectTracker } from '../../src/signals/trackingProxy' +import { reconcileState } from '../../src/signals/diff' + +// Regression guard for dependency churn with memoized collection selectors. +// +// Several hooks share `selectAllComments` (RTK entity-adapter `selectAll` +// shape: `ids.map(id => entities[id])`). Whichever hook happens to run the +// map on a Reselect cache miss is the only one that reads the elements. If +// that read registered one signal per element, the next time that hook +// re-ran for an unrelated reason it would get a cache hit, read no elements, +// and alien-signals would drop every element dependency — releasing all of +// them from the registry — only for the next comments change to recreate +// them all. With 1000 comments and a dispatch every 25 ms that teardown and +// rebuild dominated the derived-selectors benchmark. +// +// A mapped collection is a read of the whole container, so the dependency +// belongs on the container (`comments.entities`), which every hook holds +// stably. + +interface Comment { + id: number + postId: number + text: string +} +interface Post { + id: number + score: number +} +interface State { + posts: { ids: number[]; entities: Record } + comments: { ids: number[]; entities: Record } +} + +const NUM_POSTS = 5 +const NUM_COMMENTS = 50 +const NUM_HOOKS = 6 + +function makeState(): State { + const posts: State['posts'] = { ids: [], entities: {} } + const comments: State['comments'] = { ids: [], entities: {} } + for (let i = 1; i <= NUM_POSTS; i++) { + posts.ids.push(i) + posts.entities[i] = { id: i, score: 0 } + } + for (let i = 1; i <= NUM_COMMENTS; i++) { + comments.ids.push(i) + comments.entities[i] = { id: i, postId: (i % NUM_POSTS) + 1, text: 'c' + i } + } + return { posts, comments } +} + +function addComment(state: State, postId: number): State { + const id = state.comments.ids.length + 1 + return { + ...state, + comments: { + ids: [...state.comments.ids, id], + entities: { + ...state.comments.entities, + [id]: { id, postId, text: 'c' + id }, + }, + }, + } +} + +function updateScore(state: State, postId: number, score: number): State { + return { + ...state, + posts: { + ...state.posts, + entities: { + ...state.posts.entities, + [postId]: { ...state.posts.entities[postId], score }, + }, + }, + } +} + +function expectedCommentCount(state: State, postId: number): number { + return Object.values(state.comments.entities).filter( + (c) => c.postId === postId, + ).length +} + +const selectAllComments = createSelector( + [(s: State) => s.comments.ids, (s: State) => s.comments.entities], + (ids, entities) => ids.map((id) => entities[id]), +) +const selectPostById = (s: State, postId: number) => s.posts.entities[postId] + +const selectPostWithComments = createSelector( + [(s: State, postId: number) => selectPostById(s, postId), selectAllComments], + (post, comments) => ({ + ...post, + commentCount: comments.filter((c) => c.postId === post.id).length, + }), +) + +// Mirrors useSignalSelector's deep tier: a computed with leaf-object tracking +// plus an effect subscribed to it, so that dropping the last subscriber of a +// path signal releases it from the registry. +function mountHook( + getState: () => State, + selector: (s: State) => R, + registry: PathSignalRegistry, +) { + let evals = 0 + let last: R | undefined + const scope = alienEngine.createScope() + scope.run(() => { + const computed = alienEngine.computed(() => { + evals++ + const leafTracker: LeafObjectTracker = { + accessedObjects: new Map(), + traversedPaths: new Set(), + } + const proxy = createTrackingProxy( + getState(), + '', + registry, + registry.proxyCache, + leafTracker, + ) + const result = selector(proxy as State) + const proxyPath = getProxyPath(result) + if (proxyPath !== undefined) registry.getOrCreate(proxyPath, result).get() + for (const [objPath, rawValue] of leafTracker.accessedObjects) { + if (!leafTracker.traversedPaths.has(objPath) && objPath !== '') { + registry.getOrCreate(objPath, rawValue).get() + } + } + return result + }) + alienEngine.effect(() => { + last = computed.get() + }) + }) + return { + getEvals: () => evals, + getLast: () => last, + stop: () => scope.stop(), + } +} + +// Records which path signals the registry creates and releases. +function instrument(registry: PathSignalRegistry) { + let created: string[] = [] + let released: string[] = [] + const origGetOrCreate = registry.getOrCreate + const origRelease = registry.release + registry.getOrCreate = (path, value) => { + if (!registry.has(path)) created.push(path) + return origGetOrCreate(path, value) + } + registry.release = (path, node) => { + released.push(path) + return origRelease(path, node) + } + return { + drain() { + const snapshot = { created, released } + created = [] + released = [] + return snapshot + }, + } +} + +const isElementPath = (p: string) => /^comments\.entities\.\d+/.test(p) + +describe('dependency churn with memoized collection selectors', () => { + it('keeps element signals stable when a shared selectAll is memoized', () => { + const registry = createPathSignalRegistry(alienEngine) + const recorder = instrument(registry) + let state = makeState() + const getState = () => state + + const hookPostIds = Array.from( + { length: NUM_HOOKS }, + (_, i) => (i % NUM_POSTS) + 1, + ) + const hooks = hookPostIds.map((postId) => + mountHook( + getState, + (s) => selectPostWithComments(s, postId).commentCount, + registry, + ), + ) + + const mount = recorder.drain() + expect(mount.created.filter(isElementPath)).toEqual([]) + expect(registry.has('comments.entities')).toBe(true) + expect(registry.has('comments.ids')).toBe(true) + + const dispatch = (next: State) => { + const prev = state + state = next + const before = hooks.map((h) => h.getEvals()) + reconcileState(prev, next, registry, alienEngine) + const evalDeltas = hooks.map((h, i) => h.getEvals() - before[i]) + const { created, released } = recorder.drain() + hooks.forEach((h, i) => { + expect(h.getLast()).toBe(expectedCommentCount(state, hookPostIds[i])) + }) + return { evalDeltas, created, released } + } + + const commentsChanged = () => hooks.map(() => 1) + const onlyPost = (postId: number) => + hookPostIds.map((id) => (id === postId ? 1 : 0)) + + const steps = [ + { next: () => addComment(state, 1), evals: commentsChanged() }, + { next: () => addComment(state, 2), evals: commentsChanged() }, + { next: () => updateScore(state, 3, 10), evals: onlyPost(3) }, + { next: () => addComment(state, 3), evals: commentsChanged() }, + { next: () => updateScore(state, 1, 20), evals: onlyPost(1) }, + { next: () => addComment(state, 4), evals: commentsChanged() }, + { next: () => addComment(state, 5), evals: commentsChanged() }, + ] + + for (const step of steps) { + const { evalDeltas, created, released } = dispatch(step.next()) + expect(evalDeltas).toEqual(step.evals) + expect(created.filter(isElementPath)).toEqual([]) + expect(released.filter(isElementPath)).toEqual([]) + } + + hooks.forEach((h) => h.stop()) + }) +}) From c0bc59279a0849c403a0e5e230448961d02b8831 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 24 Sep 2026 14:28:40 +0200 Subject: [PATCH 84/95] Depend on the container, not each element, for mapped tracking proxies --- src/signals/arrayMethodOverrides.ts | 24 ++++++++++++++++++++--- test/signals/arrayMethodOverrides.spec.ts | 9 ++++++--- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/signals/arrayMethodOverrides.ts b/src/signals/arrayMethodOverrides.ts index fed2cd4b4..b2f876235 100644 --- a/src/signals/arrayMethodOverrides.ts +++ b/src/signals/arrayMethodOverrides.ts @@ -340,8 +340,19 @@ function emitElement( /** * Normalize a value returned from a `map` callback. A tracking proxy from * elsewhere in the state tree (`ids.map(id => entities[id])`) comes back - * raw with an identity dependency on its path, so the caller's downstream - * reads run on plain objects. Anything else is returned unchanged. + * raw so the caller's downstream reads run on plain objects. Anything else + * is returned unchanged. + * + * The dependency goes on the proxy's parent container, not the element. + * Mapping a collection reads the whole container, and under immutability + * any element change replaces the container too, so the container version + * fires in exactly the cases a per-element dependency would. Per-element + * dependencies were strictly worse: when several hooks share a memoized + * collection selector, only the hook that hits the Reselect cache miss + * reads the elements, and the next time it re-runs on a cache hit it reads + * none of them — alien-signals then drops every element dependency, the + * registry releases every element signal, and the next collection change + * recreates them all. * @param registry - Signal registry for dependency tracking * @param value - The callback's return value * @returns The value to place in the mapped array @@ -355,7 +366,14 @@ function emitMappedValue( if (raw === undefined) return value const path = getProxyPath(value) if (path !== undefined && path !== '') { - registry.getOrCreate(path, raw).get() + const dot = path.lastIndexOf('.') + // Object-valued signals hold version counters, so the seed value only + // needs to be an object; the raw element stands in for the container. + registry.getOrCreate(dot === -1 ? path : path.substring(0, dot), raw).get() + // The get trap recorded this element as a leaf-object candidate. The + // container dependency covers it, so keep the leaf tracker from adding + // a per-element identity signal on top. + registry.leafTrackerHolder.current?.traversedPaths.add(path) } return raw } diff --git a/test/signals/arrayMethodOverrides.spec.ts b/test/signals/arrayMethodOverrides.spec.ts index b058d4bd3..e34c5d8f4 100644 --- a/test/signals/arrayMethodOverrides.spec.ts +++ b/test/signals/arrayMethodOverrides.spec.ts @@ -580,7 +580,7 @@ describe('Array method overrides on tracking proxy', () => { } }) - test('tracking proxies returned from elsewhere come back raw with identity dependencies', () => { + test('tracking proxies returned from elsewhere come back raw with a dependency on their container', () => { const state = deepFreeze({ ids: [2, 1], entities: { @@ -593,8 +593,11 @@ describe('Array method overrides on tracking proxy', () => { expect(all).toEqual([state.entities[2], state.entities[1]]) expect(all[0]).toBe(state.entities[2]) expect(getProxyPath(all[0])).toBeUndefined() - expect(registry.has('entities.2')).toBe(true) - expect(registry.has('entities.1')).toBe(true) + // Mapping the collection depends on the container as a whole, not on + // each element: the container is replaced whenever any element is. + expect(registry.has('entities')).toBe(true) + expect(registry.has('entities.2')).toBe(false) + expect(registry.has('entities.1')).toBe(false) // Primitive ids fall back to the coarse array signal expect(registry.has('ids')).toBe(true) }) From 3be70bbf8a658dc2f6fc843e003afdb7dd093e2b Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 24 Sep 2026 14:49:46 +0200 Subject: [PATCH 85/95] Memoize repeated primitive reads within one evaluation and warn on hot loops --- src/signals/pathSignalRegistry.ts | 6 + src/signals/trackingProxy.ts | 56 ++++++++- test/signals/trackingProxy.spec.ts | 180 +++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 2 deletions(-) diff --git a/src/signals/pathSignalRegistry.ts b/src/signals/pathSignalRegistry.ts index d47807efa..b9f97c499 100644 --- a/src/signals/pathSignalRegistry.ts +++ b/src/signals/pathSignalRegistry.ts @@ -95,6 +95,11 @@ export interface PathSignalRegistry { * building its own. */ rootPathKeyCache: Map + /** Incremented each time a root tracking proxy is created, i.e. once + * per selector evaluation. Proxies use it to scope their last-read + * memo to the current evaluation. */ + evalEpoch: number + /** Holder for the leaf tracker of the evaluation currently running. * Proxies read this at trap time instead of closing over a tracker, * so cached proxies record into whichever evaluation is active. */ @@ -587,6 +592,7 @@ export function createPathSignalRegistry( proxyCache: proxyWeakMap, rootPathKeyCache: new Map(), + evalEpoch: 0, leafTrackerHolder: { current: undefined }, diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index 65a812681..bbf4febbe 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -31,6 +31,28 @@ function isIndexProp(prop: string): boolean { return c >= 48 && c <= 57 } +// Dev-mode check: how many consecutive memoized re-reads of one primitive +// path within a single evaluation trigger the "hoist this read" warning. +const REPEATED_READ_WARN_THRESHOLD = 100 + +const repeatedReadWarnings = new WeakMap>() + +function warnRepeatedRead(registry: PathSignalRegistry, pathKey: string) { + let warned = repeatedReadWarnings.get(registry) + if (!warned) { + warned = new Set() + repeatedReadWarnings.set(registry, warned) + } + if (warned.has(pathKey)) return + warned.add(pathKey) + console.warn( + `[react-redux] useSignalSelector: a selector read '${pathKey}' ` + + `${REPEATED_READ_WARN_THRESHOLD}+ times in a single evaluation. ` + + 'Reads through the tracked state proxy cost more than plain property ' + + 'reads — hoist the value into a local before looping.', + ) +} + /** * Maps proxy objects to their path keys. * Used by useSignalSelector to detect when a selector returns a proxy (object) @@ -256,11 +278,28 @@ export function createTrackingProxy( // that cache every time. Result functions stay memoized because their // inputs are child proxies, which ARE cached by target identity. const isRoot = parentPath === '' - if (!isRoot) { + if (isRoot) { + registry.evalEpoch++ + } else { const cached = cache.get(target) if (cached) return cached as T } + // Last-read memo for primitive leaves. Within one evaluation a repeated + // read of the same prop is idempotent: state is immutable so the value + // cannot change, the signal dependency is already linked, and the leaf + // tracker already marked this object as traversed. Selectors that loop + // over a collection while comparing against one proxied field + // (`items.filter(i => i.ownerId === user.id)`) hit this path once per + // element, so skipping the path-key/registry/signal lookups matters. + // The memo is scoped by registry.evalEpoch, not by tracker identity: + // useSignalSelector reuses one tracker across evaluations, and a stale + // hit on a later evaluation would silently drop the dependency. + let lastEpoch = -1 + let lastProp: string | undefined + let lastValue: unknown + let repeatCount = 0 + // Use an unfrozen shell as the proxy target to avoid ES Proxy invariant // violations with frozen objects. The shell copies the target's prototype // so that Array.isArray, instanceof, etc. work correctly. @@ -293,6 +332,15 @@ export function createTrackingProxy( // Symbols: read from actual target (iterator protocol, toStringTag, etc.) if (typeof prop === 'symbol') return Reflect.get(target, prop) + if (prop === lastProp && registry.evalEpoch === lastEpoch) { + if (process.env.NODE_ENV !== 'production') { + if (++repeatCount === REPEATED_READ_WARN_THRESHOLD) { + warnRepeatedRead(registry, getPathKey(prop)) + } + } + return lastValue + } + const value = (target as Record)[prop] const leafTracker = registry.leafTrackerHolder.current @@ -406,7 +454,11 @@ export function createTrackingProxy( // covered by diff's added-key handling firing the leaf path. registry.getOrCreate(pathKey, value).get() - // Return the actual value + lastEpoch = registry.evalEpoch + lastProp = prop as string + lastValue = value + repeatCount = 0 + return value }, diff --git a/test/signals/trackingProxy.spec.ts b/test/signals/trackingProxy.spec.ts index a25a0b46b..926263475 100644 --- a/test/signals/trackingProxy.spec.ts +++ b/test/signals/trackingProxy.spec.ts @@ -868,6 +868,186 @@ describe('unwrap', () => { }) }) +// ─── Repeated-read memo ─── +// Within one evaluation, re-reading the same primitive prop on the same +// proxy returns the memoized value without touching the registry. The memo +// must NOT survive into the next evaluation, or the dependency would be +// silently dropped when alien-signals re-records deps. + +describe('repeated-read memo', () => { + function mountComputed(registry: PathSignalRegistry, body: () => T) { + const scope = alienEngine.createScope() + let evals = 0 + const c = scope.run(() => + alienEngine.computed(() => { + evals++ + return body() + }), + ) + return { c, scope, getEvals: () => evals } + } + + it('serves repeated reads of a primitive from the memo without extra registry lookups', () => { + const state = Object.freeze({ user: Object.freeze({ id: 7 }) }) + const registry = makeRegistry() + const getOrCreate = vi.spyOn(registry, 'getOrCreate') + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + const user = proxy.user + const before = getOrCreate.mock.calls.length + for (let i = 0; i < 10; i++) { + expect(user.id).toBe(7) + } + // One lookup for the first read, none for the nine memo hits + expect(getOrCreate.mock.calls.length).toBe(before + 1) + }) + + it('re-links the dependency on the next evaluation even when the read is repeated', () => { + let state = { user: { id: 7 }, other: 0 } + const registry = makeRegistry() + const { c, scope, getEvals } = mountComputed(registry, () => { + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) + // Read `user.id` twice: the second read hits the memo in this evaluation + return proxy.user.id + proxy.user.id + proxy.other + }) + + expect(c.get()).toBe(14) + expect(getEvals()).toBe(1) + + // Change only `other`: the computed re-runs and reuses the same `user` + // child proxy (cached by target identity). alien-signals re-records + // deps on every run, so if the memo outlived the first evaluation the + // `user.id` read would be served without touching its signal and the + // dependency would be dropped here. + let next = { ...state, other: 1 } + reconcileState(state, next, registry, alienEngine) + state = next + expect(c.get()).toBe(15) + expect(getEvals()).toBe(2) + + // A `user.id` change must still be observed + next = { ...state, user: { id: 8 } } + reconcileState(state, next, registry, alienEngine) + state = next + expect(c.get()).toBe(17) + expect(getEvals()).toBe(3) + + scope.stop() + }) + + it('does not leak a memoized read across two computeds sharing a cached child proxy', () => { + let state = { user: { id: 7, name: 'a' } } + const registry = makeRegistry() + const a = mountComputed(registry, () => { + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) + return proxy.user.id + }) + const b = mountComputed(registry, () => { + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) + // Same cached `user` proxy, same prop, different evaluation: B must + // register its own dependency rather than reuse A's memo hit + return proxy.user.id + }) + + expect(a.c.get()).toBe(7) + expect(b.c.get()).toBe(7) + + const next = { user: { id: 8, name: 'a' } } + reconcileState(state, next, registry, alienEngine) + state = next + + expect(a.c.get()).toBe(8) + expect(b.c.get()).toBe(8) + expect(a.getEvals()).toBe(2) + expect(b.getEvals()).toBe(2) + + a.scope.stop() + b.scope.stop() + }) + + it('warns once per path in dev when a primitive is re-read 100+ times in one evaluation', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const state = Object.freeze({ + post: Object.freeze({ id: 3 }), + comments: Object.freeze( + Array.from({ length: 150 }, (_, i) => + Object.freeze({ postId: i % 5 }), + ), + ), + }) + const registry = makeRegistry() + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) + + // Loop shape from a Reselect result function: `post` is a proxied + // input, `comments` is raw + const post = proxy.post + const count = state.comments.filter((c) => c.postId === post.id).length + expect(count).toBe(30) + + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0][0]).toMatch(/read 'post\.id' 100\+ times/) + + // Same path again in a fresh evaluation: no second warning + const proxy2 = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) + const post2 = proxy2.post + state.comments.filter((c) => c.postId === post2.id) + expect(warn).toHaveBeenCalledTimes(1) + } finally { + warn.mockRestore() + } + }) + + it('does not warn for reads below the threshold or for alternating props', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const state = Object.freeze({ user: Object.freeze({ id: 1, role: 'x' }) }) + const registry = makeRegistry() + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) + const user = proxy.user + + for (let i = 0; i < 99; i++) user.id + for (let i = 0; i < 200; i++) { + user.id + user.role + } + expect(warn).not.toHaveBeenCalled() + } finally { + warn.mockRestore() + } + }) +}) + // ─── Array method dependency tracking ─── // These tests verify which signals are registered when using array methods // through the tracking proxy, and whether state changes trigger re-evaluation. From ab58b94f7518df67bf9fcd2168f1e5319a88fa1d Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 24 Sep 2026 15:18:36 +0200 Subject: [PATCH 86/95] docs: describe array-method results, Reselect interaction, and repeated-read memo --- docs/api/useSignalSelector.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/api/useSignalSelector.md b/docs/api/useSignalSelector.md index 0f0469613..28205b209 100644 --- a/docs/api/useSignalSelector.md +++ b/docs/api/useSignalSelector.md @@ -141,16 +141,27 @@ const selected = useSignalSelector((state) => { See [`unwrap`](./unwrap.md) for details. Comparisons between two values both read from `state` in the same selector run work correctly without unwrapping. +Elements _returned_ from array methods (`state.items.find(...)`, `state.items.filter(...)`, `state.items.map(x => x)`) are raw state objects, not proxies, so they compare correctly against outside references. Only the values you read directly off `state` and inside array method callbacks are proxied. + **Proxies never escape the selector.** The hook unwraps the selector's return value before handing it to React, so your components, effects, equality functions, and dispatched actions always see plain Redux state. `console.log` of a selector result shows plain data. For the same reason, avoid _storing_ a state object read during one selector run in an external variable for use later - the same rule as holding onto an Immer draft. Use `unwrap()` first if you need to do this. ##### Memoized (Reselect) selectors -Memoized selectors created with Reselect's `createSelector` work with `useSignalSelector`, following the same rules as with [`useSelector`](./useSelector.md#using-memoizing-selectors). Unchanged parts of the state keep stable proxy identities across selector runs, so Reselect's input comparisons behave the way they do with raw state: changed inputs produce cache misses and recalculation, unchanged inputs return the cached result. Cached result values are unwrapped the same way as direct returns. +Memoized selectors created with Reselect's `createSelector` work with `useSignalSelector`, following the same rules as with [`useSelector`](./useSelector.md#using-memoizing-selectors), including selectors shared between many components and selectors that take extra arguments. Cached result values are unwrapped the same way as direct returns. + +There is one difference in _how much_ memoization you get. Dependency tracking only works when the selector actually reads from the state proxy, so the hook hands each tracked run a fresh root proxy. Reselect's argument-level cache (`argsMemoize`, keyed on the `state` argument) therefore misses on every tracked run, and the **input selectors run every time**. The **result function still memoizes**: nested state objects keep stable identities across runs, so when the inputs have not changed, the cached result is returned without recomputing. In practice this means a memoized selector costs a little more per tracked run than under `useSelector`, and runs far less often. + +Two consequences follow from this: + +- Selectors that cache _without_ reading state on a cache hit (a hand-rolled `let cached` guard, for example) cannot be tracked and will not update. The same selector is also stale under `useSelector`, just less visibly. +- Inside a result function, the inputs are proxies. Reading one field of a proxied input repeatedly in a loop (`comments.filter(c => c.postId === post.id)`) costs a proxy trap per iteration. Repeated reads of the same field within one run are memoized, so this is usually cheap, but hoisting the value into a local (`const postId = post.id`) is cheaper still. In development, a selector that re-reads the same field 100+ times in one run logs a warning pointing at the path. ##### Coarse-grained fallback for broad selectors Selectors that enumerate the root state's keys (`Object.keys(state)`, spreading `{...state}`) or otherwise read the root without narrowing to specific fields cannot be tracked at a useful granularity. They still work correctly, but re-evaluate more often and set up their tracking eagerly, costing more at mount. Narrow selectors are both the best practice and the fast path. +Enumerating or spreading a _nested_ object (`{...state.user}`, `Object.keys(state.entities)`) is tracked precisely: the selector depends on the object's set of keys plus each field the spread copies. + ##### Aliased state objects If the same object instance is reachable via two different state paths (for example, an entity stored in two lookup tables), tracking attributes reads to the first path encountered. The consequence is only ever _extra_ selector re-runs, never missed updates. @@ -166,8 +177,8 @@ When the [selector stability check](./useSelector.md#selector-result-stability) #### Performance characteristics - **Tracked evaluation costs roughly 3-4x an untracked run.** The payoff is the dispatches where the selector does not run at all. Apps where most dispatches are irrelevant to most components come out well ahead; apps where every dispatch touches state that every component reads gain nothing and pay the diff cost inside each dispatch. -- **Iteration helpers are optimized for depth-1 reads.** Array scans like `find`, `filter`, `some`, `every`, `includes`, and `slice` are tracked at the whole-array level with per-column optimization: a callback reading `item.done` depends on that one column rather than every field of every element. Reads two levels deep inside a scan callback (`item.meta.done`), and methods like `map`, `forEach`, and `reduce`, fall back to depending on the array as a whole - still correct, just coarser. -- **Conditional selectors accumulate both branches.** A selector like `state.mode === 'a' ? state.a.x : state.b.y` re-records dependencies each run, but a component that alternates between branches will track whichever paths it has read while mounted. This widens the update surface slightly; it never causes missed updates. +- **Array methods track elements by identity, and scan callbacks by column.** `find`, `findLast`, `filter`, `slice`, and `map` return _raw_ elements, each with a dependency on that element's identity: replacing the element (which any immutable update to it does) re-runs the selector, but the individual fields you read off a returned element are not tracked separately. Scan callbacks (`find(item => item.done)`, `some`, `every`, `findIndex`, and so on) depend on the one column they read rather than every field of every element. Reads two levels deep inside a callback (`item.meta.done`), and methods that are not overridden (`forEach`, `reduce`, `flatMap`), fall back to depending on the array as a whole - still correct, just coarser. Direct index access (`state.items[0].name`) keeps full field-level precision. +- **Conditional selectors track only the branch they took.** Dependencies are re-recorded from scratch on every run, so a selector like `state.mode === 'a' ? state.a.x : state.b.y` depends on `mode` and on whichever of `a.x` or `b.y` it read most recently. Switching branches drops the old branch's paths. ### Migrating an app with a bundler alias From 57c53f49b843facec50c3949d2098ab15524be53 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Thu, 24 Sep 2026 15:23:10 +0200 Subject: [PATCH 87/95] Release 9.4.0-alpha.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8bd4489c9..d39093152 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-redux", - "version": "9.4.0-alpha.0", + "version": "9.4.0-alpha.1", "description": "Official React bindings for Redux", "keywords": [ "react", From 18556130d2f6cb5b04e894da44a4dc7109543b77 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 26 Sep 2026 12:43:32 +0200 Subject: [PATCH 88/95] Add tests for RTKQ custom React hooks module interop --- test/signals/rtkQueryInterop.spec.tsx | 737 ++++++++++++++++++ .../rtk-query-hooks-module.test-d.tsx | 41 + 2 files changed, 778 insertions(+) create mode 100644 test/signals/rtkQueryInterop.spec.tsx create mode 100644 test/typetests/rtk-query-hooks-module.test-d.tsx diff --git a/test/signals/rtkQueryInterop.spec.tsx b/test/signals/rtkQueryInterop.spec.tsx new file mode 100644 index 000000000..a85540f3b --- /dev/null +++ b/test/signals/rtkQueryInterop.spec.tsx @@ -0,0 +1,737 @@ +/** + * RTK Query opting into the signal hooks per API via + * `reactHooksModule({ hooks: { useSelector, useDispatch, useStore } })`. + * + * `useSelector` here comes from the aliased `react-redux` entry, so the + * same file runs against stock `useSelector` (default) and + * `useSignalSelector` (`TEST_IMPL=signals`). Render counts are asserted + * exactly and must match in both modes. Registry assertions only run in + * signals mode. + * + * `useDispatch`/`useStore` are the stock hooks in BOTH modes: the point + * of the per-API opt-in is that only the selector hook changes. + */ +import * as rtl from '@testing-library/react' +import React from 'react' +import { combineReducers, configureStore, createSlice } from '@reduxjs/toolkit' +import type { BaseQueryFn } from '@reduxjs/toolkit/query/react' +import { + buildCreateApi, + coreModule, + reactHooksModule, +} from '@reduxjs/toolkit/query/react' +import { Provider, useSelector } from 'react-redux' +import { useDispatch, useStore } from '../../src/index' +import { unwrap } from '../../src/signals/trackingProxy' +import { useSignalContext } from '../../src/signals/context' +import type { + PathSignalRegistry, + RegistryStats, +} from '../../src/signals/pathSignalRegistry' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const IS_SIGNALS = process.env.TEST_IMPL === 'signals' + +interface Post { + id: number + title: string + body: string +} + +interface User { + id: number + name: string +} + +interface Request { + url: string + method: string + body?: unknown +} + +function makeServer() { + const posts = new Map([ + [1, { id: 1, title: 'first', body: 'b1' }], + [2, { id: 2, title: 'second', body: 'b2' }], + [3, { id: 3, title: 'third', body: 'b3' }], + ]) + const users = new Map([[7, { id: 7, name: 'ann' }]]) + const requests: Request[] = [] + let gate: Promise | null = null + let openGate: (() => void) | null = null + + const baseQuery: BaseQueryFn = async ( + req, + ) => { + requests.push(req) + await Promise.resolve() + if (gate) await gate + const [, resource, rawId] = req.url.split('/') + if (resource === 'posts') { + if (rawId === undefined) { + return { data: Array.from(posts.values()) } + } + const id = Number(rawId) + const post = posts.get(id) + if (!post) return { error: { status: 404 } } + if (req.method === 'PATCH') { + const next = { ...post, ...(req.body as Partial) } + posts.set(id, next) + return { data: next } + } + return { data: post } + } + if (resource === 'users') { + const user = users.get(Number(rawId)) + return user ? { data: user } : { error: { status: 404 } } + } + return { error: { status: 404 } } + } + + function pause() { + gate = new Promise((resolve) => { + openGate = resolve + }) + } + function resume() { + openGate?.() + gate = null + openGate = null + } + function resumeOnce() { + const release = openGate + pause() + release?.() + } + + return { posts, users, requests, baseQuery, pause, resume, resumeOnce } +} + +function makeApi(baseQuery: BaseQueryFn) { + const createApi = buildCreateApi( + coreModule(), + reactHooksModule({ hooks: { useDispatch, useSelector, useStore } }), + ) + + return createApi({ + reducerPath: 'api', + baseQuery, + tagTypes: ['Post'], + endpoints: (build) => ({ + getPosts: build.query({ + query: () => ({ url: '/posts', method: 'GET' }), + providesTags: (result) => + result + ? [ + { type: 'Post', id: 'LIST' }, + ...result.map((p) => ({ type: 'Post' as const, id: p.id })), + ] + : [{ type: 'Post', id: 'LIST' }], + }), + getPost: build.query({ + query: (id) => ({ url: `/posts/${id}`, method: 'GET' }), + providesTags: (_r, _e, id) => [{ type: 'Post', id }], + }), + getUser: build.query({ + query: (id) => ({ url: `/users/${id}`, method: 'GET' }), + }), + updatePost: build.mutation({ + query: ({ id, ...body }) => ({ + url: `/posts/${id}`, + method: 'PATCH', + body, + }), + invalidatesTags: (_r, _e, { id }) => [{ type: 'Post', id }], + }), + }), + }) +} + +type Api = ReturnType + +const counterSlice = createSlice({ + name: 'counter', + initialState: { value: 0 }, + reducers: { + increment(state) { + state.value += 1 + }, + }, +}) + +function makeStore(api: Api) { + return configureStore({ + reducer: combineReducers({ + [api.reducerPath]: api.reducer, + counter: counterSlice.reducer, + }), + middleware: (gDM) => gDM().concat(api.middleware), + }) +} + +type AppStore = ReturnType + +const EMPTY_STATS: RegistryStats = { + signals: 0, + prefixCounts: 0, + prefixOnlyPaths: 0, + childIndex: 0, + arrayMetas: 0, + columnsByArray: 0, + structuresByArray: 0, + segmentSubs: 0, +} + +let capturedRegistry: PathSignalRegistry | null = null + +function CaptureRegistry() { + capturedRegistry = useSignalContext().registry + return null +} + +function requireRegistry(): PathSignalRegistry { + if (capturedRegistry === null) throw new Error('registry not captured') + return capturedRegistry +} + +const renders: Record = {} +function countRender(name: string) { + renders[name] = (renders[name] ?? 0) + 1 +} + +let server: ReturnType +let api: Api +let store: AppStore + +beforeEach(() => { + server = makeServer() + api = makeApi(server.baseQuery) + store = makeStore(api) + capturedRegistry = null + for (const key of Object.keys(renders)) delete renders[key] +}) + +afterEach(() => { + rtl.cleanup() + vi.restoreAllMocks() +}) + +function Root({ children }: { children: React.ReactNode }) { + return ( + + {IS_SIGNALS ? : null} + {children} + + ) +} + +function PostTitle({ id, skip = false }: { id: number; skip?: boolean }) { + countRender(`post${id}`) + const { data, isLoading, isFetching, isUninitialized } = + api.endpoints.getPost.useQuery(id, { skip }) + return ( +
+ {isUninitialized + ? 'uninitialized' + : isLoading + ? 'loading' + : `${data?.title ?? ''}${isFetching ? ' (refetching)' : ''}`} +
+ ) +} + +function UserName({ id }: { id: number }) { + countRender(`user${id}`) + const { data, isLoading } = api.endpoints.getUser.useQuery(id) + return ( +
{isLoading ? 'loading' : data?.name}
+ ) +} + +function PostTitleFromList({ id }: { id: number }) { + countRender(`list${id}`) + const { title, isLoading } = api.endpoints.getPosts.useQuery(undefined, { + selectFromResult: ({ data, isLoading }) => ({ + title: data?.find((p) => p.id === id)?.title, + isLoading, + }), + }) + return
{isLoading ? 'loading' : title}
+} + +function Counter() { + countRender('counter') + const value = useSelector( + (s: ReturnType) => s.counter.value, + ) + return
{value}
+} + +// configureStore's autoBatch enhancer defers subscriber notification for +// RTKQ's fulfilled/patched actions to a requestAnimationFrame tick, so a +// plain act() returns before the store notifies. Wait past two frames. +async function settle() { + await rtl.act(async () => { + await new Promise((r) => + requestAnimationFrame(() => requestAnimationFrame(() => r())), + ) + }) +} + +async function dispatchAndSettle(dispatch: () => unknown) { + await rtl.act(async () => { + dispatch() + }) + await settle() +} + +describe('RTK Query with reactHooksModule({ hooks: { useSelector } })', () => { + it('fetches on mount and re-renders once per phase (pending, fulfilled)', async () => { + server.pause() + rtl.render( + + + , + ) + // Mount, then the pending entry (dispatched from the subscription + // effect inside render's act) replaces the synthetic "uninitialized + // shown as pending" result: same flags, but requestId/startedTimeStamp + // appear, so shallowEqual sees a change. + expect(rtl.screen.getByTestId('post1').textContent).toBe('loading') + expect(renders.post1).toBe(2) + + await settle() + expect(renders.post1).toBe(2) + + server.resume() + await settle() + expect(rtl.screen.getByTestId('post1').textContent).toBe('first') + expect(renders.post1).toBe(3) + expect(server.requests).toHaveLength(1) + }) + + it('hands the component raw cache data, not a tracking proxy, with stable identity', async () => { + const seen: Array = [] + function Capture() { + const { data } = api.endpoints.getPost.useQuery(1) + seen.push(data) + return null + } + const { rerender } = rtl.render( + + + , + ) + await settle() + const first = seen.at(-1) + expect(first).toEqual({ id: 1, title: 'first', body: 'b1' }) + + const raw = api.endpoints.getPost.select(1)(store.getState()).data + expect(first).toBe(raw) + expect(unwrap(first)).toBe(first) + + // A parent-driven re-render hands back the same reference + rerender( + + + , + ) + expect(seen.at(-1)).toBe(first) + }) + + it('returns a stable result object across unrelated store updates', async () => { + const results: unknown[] = [] + function Capture() { + const result = api.endpoints.getPost.useQuery(1) + results.push(result) + return null + } + rtl.render( + + + , + ) + await settle() + const settled = results.at(-1) + const count = results.length + + await dispatchAndSettle(() => + store.dispatch(counterSlice.actions.increment()), + ) + await dispatchAndSettle(() => + store.dispatch( + api.util.updateQueryData('getUser', 7, () => ({ id: 7, name: 'x' })), + ), + ) + expect(results.length).toBe(count) + expect(results.at(-1)).toBe(settled) + }) + + describe('selectFromResult', () => { + it('re-renders only the subscriber whose picked element changed', async () => { + rtl.render( + + + + , + ) + await settle() + expect(rtl.screen.getByTestId('list1').textContent).toBe('first') + expect(rtl.screen.getByTestId('list2').textContent).toBe('second') + expect(server.requests).toHaveLength(1) + expect(renders.list1).toBe(3) + expect(renders.list2).toBe(3) + + await dispatchAndSettle(() => + store.dispatch( + api.util.updateQueryData('getPosts', undefined, (draft) => { + const p = draft.find((p) => p.id === 2) + if (p) p.title = 'second!' + }), + ), + ) + + expect(rtl.screen.getByTestId('list1').textContent).toBe('first') + expect(rtl.screen.getByTestId('list2').textContent).toBe('second!') + expect(renders.list1).toBe(3) + expect(renders.list2).toBe(4) + }) + + it('does not re-render when a field the selector did not pick changes', async () => { + function BodyOnly() { + countRender('body') + const { body } = api.endpoints.getPost.useQuery(1, { + selectFromResult: ({ data }) => ({ body: data?.body }), + }) + return
{body}
+ } + rtl.render( + + + , + ) + await settle() + expect(rtl.screen.getByTestId('body').textContent).toBe('b1') + const before = renders.body + + await dispatchAndSettle(() => + store.dispatch( + api.util.updateQueryData('getPost', 1, (draft) => { + draft.title = 'retitled' + }), + ), + ) + expect(renders.body).toBe(before) + + await dispatchAndSettle(() => + store.dispatch( + api.util.updateQueryData('getPost', 1, (draft) => { + draft.body = 'b1!' + }), + ), + ) + expect(rtl.screen.getByTestId('body').textContent).toBe('b1!') + expect(renders.body).toBe(before + 1) + }) + + it('accepts a selector that spreads and destructures the result', async () => { + let received: Record = {} + function Spread() { + countRender('spread') + const result = api.endpoints.getPost.useQuery(1, { + selectFromResult: ({ data, ...rest }) => ({ + ...rest, + title: data?.title, + hasBody: data !== undefined && 'body' in data, + }), + }) + received = result + return
{result.title}
+ } + rtl.render( + + + , + ) + await settle() + expect(rtl.screen.getByTestId('spread').textContent).toBe('first') + expect(received.hasBody).toBe(true) + expect(received.isSuccess).toBe(true) + expect('data' in received).toBe(false) + const before = renders.spread + + await dispatchAndSettle(() => + store.dispatch(counterSlice.actions.increment()), + ) + expect(renders.spread).toBe(before) + }) + + it('works with a stable selector defined outside the component', async () => { + const selectTitle = ({ + data, + isFetching, + }: { + data?: Post + isFetching: boolean + }) => ({ title: data?.title, isFetching }) + + function Stable() { + countRender('stable') + const { title } = api.endpoints.getPost.useQuery(1, { + selectFromResult: selectTitle, + }) + return
{title}
+ } + rtl.render( + + + , + ) + await settle() + expect(rtl.screen.getByTestId('stable').textContent).toBe('first') + expect(renders.stable).toBe(3) + }) + }) + + it('runs a mutation, invalidates, and refetches the dependent query', async () => { + let trigger: ((arg: { id: number; title: string }) => unknown) | null = null + function Editor() { + countRender('editor') + const [update, { isUninitialized, isLoading, isSuccess, data }] = + api.endpoints.updatePost.useMutation() + trigger = update + return ( +
+ {isUninitialized + ? 'idle' + : isLoading + ? 'saving' + : isSuccess + ? `saved:${data.title}` + : 'error'} +
+ ) + } + + rtl.render( + + + + + , + ) + await settle() + expect(rtl.screen.getByTestId('post1').textContent).toBe('first') + expect(rtl.screen.getByTestId('post2').textContent).toBe('second') + expect(rtl.screen.getByTestId('editor').textContent).toBe('idle') + expect(renders.editor).toBe(1) + const post1Before = renders.post1 + const post2Before = renders.post2 + + server.pause() + await rtl.act(async () => { + trigger!({ id: 1, title: 'renamed' }) + }) + await settle() + expect(rtl.screen.getByTestId('editor').textContent).toBe('saving') + expect(renders.editor).toBe(2) + + // Release the PATCH; the invalidation refetch of post 1 is gated again + server.resumeOnce() + await settle() + expect(rtl.screen.getByTestId('editor').textContent).toBe('saved:renamed') + expect(renders.editor).toBe(3) + expect(rtl.screen.getByTestId('post1').textContent).toBe( + 'first (refetching)', + ) + expect(renders.post1).toBe(post1Before + 1) + + server.resume() + await settle() + expect(rtl.screen.getByTestId('post1').textContent).toBe('renamed') + expect(renders.post1).toBe(post1Before + 2) + expect(rtl.screen.getByTestId('post2').textContent).toBe('second') + expect(renders.post2).toBe(post2Before) + expect(server.requests.map((r) => `${r.method} ${r.url}`)).toEqual([ + 'GET /posts/1', + 'GET /posts/2', + 'PATCH /posts/1', + 'GET /posts/1', + ]) + }) + + it('shares one cache entry between two subscribers', async () => { + function Second() { + countRender('second') + const { data } = api.endpoints.getPost.useQuery(1) + return
{data?.title}
+ } + rtl.render( + + + + , + ) + await settle() + expect(rtl.screen.getByTestId('post1').textContent).toBe('first') + expect(rtl.screen.getByTestId('second').textContent).toBe('first') + expect(server.requests).toHaveLength(1) + expect(renders.post1).toBe(3) + expect(renders.second).toBe(3) + + await dispatchAndSettle(() => + store.dispatch( + api.util.updateQueryData('getPost', 1, (draft) => { + draft.title = 'patched' + }), + ), + ) + expect(rtl.screen.getByTestId('post1').textContent).toBe('patched') + expect(rtl.screen.getByTestId('second').textContent).toBe('patched') + expect(renders.post1).toBe(4) + expect(renders.second).toBe(4) + }) + + it('does not request while skipped, then fetches once skip is lifted', async () => { + const { rerender } = rtl.render( + + + , + ) + expect(rtl.screen.getByTestId('post3').textContent).toBe('uninitialized') + await settle() + expect(server.requests).toHaveLength(0) + expect(renders.post3).toBe(1) + + server.pause() + rerender( + + + , + ) + // `queryThunk.pending` is autoBatched, so its store notification is + // deferred to a frame. The pending phase still renders inside + // `rerender`: React's post-commit snapshot check reads the current + // store state directly. + expect(rtl.screen.getByTestId('post3').textContent).toBe('loading') + expect(renders.post3).toBe(3) + expect(server.requests).toHaveLength(1) + await settle() + expect(renders.post3).toBe(3) + + server.resume() + await settle() + expect(rtl.screen.getByTestId('post3').textContent).toBe('third') + expect(renders.post3).toBe(4) + }) + + it('only re-renders the component whose cache entry changed', async () => { + rtl.render( + + + + + , + ) + await settle() + expect(rtl.screen.getByTestId('post1').textContent).toBe('first') + expect(rtl.screen.getByTestId('user7').textContent).toBe('ann') + const post1 = renders.post1 + const user7 = renders.user7 + const counter = renders.counter + + await dispatchAndSettle(() => + store.dispatch( + api.util.updateQueryData('getUser', 7, (draft) => { + draft.name = 'bob' + }), + ), + ) + expect(rtl.screen.getByTestId('user7').textContent).toBe('bob') + expect(renders.user7).toBe(user7 + 1) + expect(renders.post1).toBe(post1) + expect(renders.counter).toBe(counter) + + await dispatchAndSettle(() => + store.dispatch(counterSlice.actions.increment()), + ) + expect(renders.counter).toBe(counter + 1) + expect(renders.post1).toBe(post1) + expect(renders.user7).toBe(user7 + 1) + }) + + it('re-renders for the refetching and fulfilled phases of a same-value refetch', async () => { + rtl.render( + + + , + ) + await settle() + expect(rtl.screen.getByTestId('post1').textContent).toBe('first') + const before = renders.post1 + + server.pause() + await rtl.act(async () => { + store.dispatch(api.endpoints.getPost.initiate(1, { forceRefetch: true })) + }) + await settle() + expect(rtl.screen.getByTestId('post1').textContent).toBe( + 'first (refetching)', + ) + expect(renders.post1).toBe(before + 1) + + server.resume() + await settle() + expect(rtl.screen.getByTestId('post1').textContent).toBe('first') + expect(renders.post1).toBe(before + 2) + expect(server.requests).toHaveLength(2) + }) + + it.runIf(IS_SIGNALS)( + 'releases every path signal and coarse subscriber on unmount', + async () => { + const { unmount } = rtl.render( + + + + + , + ) + const registry = requireRegistry() + await settle() + expect(rtl.screen.getByTestId('post1').textContent).toBe('first') + expect(rtl.screen.getByTestId('user7').textContent).toBe('ann') + expect(rtl.screen.getByTestId('list2').textContent).toBe('second') + const live = registry.debugStats() + expect(live.signals).toBeGreaterThan(0) + + unmount() + + const after = registry.debugStats() + // Array metadata is retained by design (see registryLeak.spec.tsx) + expect({ ...after, arrayMetas: 0 }).toEqual(EMPTY_STATS) + }, + ) + + it.runIf(IS_SIGNALS)( + 'tracks only the queried cache entries, not the whole queries map', + async () => { + rtl.render( + + + + , + ) + const registry = requireRegistry() + await settle() + expect(rtl.screen.getByTestId('post1').textContent).toBe('first') + expect(rtl.screen.getByTestId('user7').textContent).toBe('ann') + const paths = registry.debugPaths() + expect(paths.some((p) => p.startsWith('api.queries.'))).toBe(true) + expect(paths).not.toContain('api.queries') + expect(paths).not.toContain('api') + expect(paths.some((p) => p.startsWith('api.subscriptions'))).toBe(false) + expect(paths.some((p) => p.startsWith('api.provided'))).toBe(false) + }, + ) +}) diff --git a/test/typetests/rtk-query-hooks-module.test-d.tsx b/test/typetests/rtk-query-hooks-module.test-d.tsx new file mode 100644 index 000000000..b2972c9e2 --- /dev/null +++ b/test/typetests/rtk-query-hooks-module.test-d.tsx @@ -0,0 +1,41 @@ +import { + buildCreateApi, + coreModule, + reactHooksModule, +} from '@reduxjs/toolkit/query/react' +import { useDispatch, useSelector, useStore } from '../../src/index' +import type { UseSelector } from '../../src/index' +import { createSignalSelectorHook, useSignalSelector } from '../../src/signals' +import type { UseSignalSelector } from '../../src/signals' + +type ModuleHooks = NonNullable< + NonNullable[0]>['hooks'] +> + +describe('type tests', () => { + test('useSignalSelector satisfies reactHooksModule hooks.useSelector', () => { + expectTypeOf(useSignalSelector).toMatchTypeOf() + expectTypeOf(useSignalSelector).toMatchTypeOf() + expectTypeOf>().toMatchTypeOf< + UseSelector<{ a: number }> + >() + expectTypeOf(createSignalSelectorHook()).toMatchTypeOf< + ModuleHooks['useSelector'] + >() + }) + + test('reactHooksModule accepts useSignalSelector alongside stock hooks', () => { + const createApi = buildCreateApi( + coreModule(), + reactHooksModule({ + hooks: { useDispatch, useSelector: useSignalSelector, useStore }, + }), + ) + expectTypeOf(createApi).toEqualTypeOf( + buildCreateApi( + coreModule(), + reactHooksModule({ hooks: { useDispatch, useSelector, useStore } }), + ), + ) + }) +}) From 8c18c18059abd153ead463e15be6c7ac30363368 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 26 Sep 2026 12:45:01 +0200 Subject: [PATCH 89/95] Fix store update and notification timing issues --- src/signals/useSignalSelector.ts | 36 ++++- test/signals/snapshotFreshness.spec.tsx | 204 ++++++++++++++++++++++++ 2 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 test/signals/snapshotFreshness.spec.tsx diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index d3848fd0c..89bf2be94 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -128,7 +128,9 @@ const useSignalSelectorImpl = ( let ungateable = false // State ref that probeSegments/currentResult were probed against. let probedState: unknown = null - // Freshness cache for unbuilt getSnapshot recomputes. + // State ref that currentResult was last computed against. getSnapshot + // compares it to store.getState() to catch dispatches whose listener + // notification hasn't arrived yet. let lastSnapshotState: unknown = null // Freshness cache for getServerSnapshot, keyed on the serverState ref. let lastServerState: unknown = null @@ -309,6 +311,7 @@ const useSignalSelectorImpl = ( pendingError = null try { const state = store.getState() as S & object + lastSnapshotState = state leafTracker.accessedObjects = new Map() leafTracker.traversedPaths = new Set() @@ -529,6 +532,24 @@ const useSignalSelectorImpl = ( throw pendingError } } + + // The store moved but its listeners haven't been notified yet + // (deferred notification, e.g. RTK's autoBatchEnhancer), so the + // signal effect hasn't refreshed currentResult. A render landing + // in that gap must still see current state, the same way stock + // useSelector reads store.getState() directly. Recompute against + // RAW state — no proxies, no dependency changes. When the + // notification arrives the effect re-evaluates, finds the value + // equal, and stays quiet. + const state = store.getState() + if (state !== lastSnapshotState) { + lastSnapshotState = state + const fresh = selectorRef.current(state as S) + if (!hasResult || !equalityFnRef.current(currentResult, fresh)) { + currentResult = fresh + hasResult = true + } + } return currentResult } @@ -636,9 +657,20 @@ const useSignalSelectorImpl = ( bridge.setSelector(selector) } + // React only re-checks the snapshot after commit when getSnapshot's + // identity changed. Stock useSelector gets that for free from + // useSyncExternalStoreWithSelector, which derives a new getSnapshot per + // selector identity; a store change made in a layout effect is then + // rendered synchronously instead of waiting for the store listener. + // Hand React a wrapper keyed on the selector so the two match. + const getSnapshot = useMemo( + () => () => bridge.getSnapshot(), + [bridge, selector], + ) + const selectedState = useSyncExternalStore( bridge.subscribe, - bridge.getSnapshot, + getSnapshot, bridge.getServerSnapshot, ) diff --git a/test/signals/snapshotFreshness.spec.tsx b/test/signals/snapshotFreshness.spec.tsx new file mode 100644 index 000000000..4a28c8508 --- /dev/null +++ b/test/signals/snapshotFreshness.spec.tsx @@ -0,0 +1,204 @@ +/** + * A store enhancer may notify listeners some time after `dispatch` + * returns (`autoBatchEnhancer` in RTK does this for every RTK Query + * lifecycle action). Any render that happens inside that gap must still + * read the CURRENT store state: `getSnapshot` is the source of truth for + * React, not the notification. Runs against both implementations. + */ +import * as rtl from '@testing-library/react' +import * as React from 'react' +import type { StoreEnhancer } from 'redux' +import { createStore } from 'redux' +import { Provider, useSelector } from 'react-redux' +import { useSelector as useStockSelector } from '../../src/index' +import { afterEach, describe, expect, it } from 'vitest' + +afterEach(() => { + rtl.cleanup() +}) + +interface State { + value: number + other: string +} + +const reducer = ( + state: State = { value: 0, other: 'x' }, + action: { type: string }, +): State => { + switch (action.type) { + case 'inc': + return { ...state, value: state.value + 1 } + case 'other': + return { ...state, other: state.other + '!' } + default: + return state + } +} + +// Notify store listeners on a later macrotask, like autoBatchEnhancer. +const deferNotifications: StoreEnhancer = (next) => (r, preloaded) => { + const store = next(r, preloaded) + const listeners = new Set<() => void>() + let scheduled = false + store.subscribe(() => { + if (scheduled) return + scheduled = true + setTimeout(() => { + scheduled = false + for (const l of listeners) l() + }, 0) + }) + return { + ...store, + subscribe(listener: () => void) { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + } +} + +const flushNotifications = () => + rtl.act(() => new Promise((r) => setTimeout(r, 5))) + +const selectValue = (s: State) => s.value + +function makeStore() { + return createStore(reducer, undefined, deferNotifications) +} + +// A hook that has already re-rendered from a store change is fully +// subscribed (in the signal implementation: promoted out of the coarse +// tier). The freshness question only matters once it's in that state. +async function warmUp(store: ReturnType) { + rtl.act(() => { + store.dispatch({ type: 'inc' }) + }) + await flushNotifications() + expect(store.getState().value).toBe(1) +} + +describe('snapshot freshness between dispatch and notification', () => { + it('a re-render inside the gap reads the current store state', async () => { + const store = makeStore() + const seen: number[] = [] + let forceParent: () => void = () => {} + + function Child() { + const value = useSelector(selectValue) + seen.push(value) + return
{value}
+ } + function Parent() { + const [, setN] = React.useState(0) + forceParent = () => setN((n) => n + 1) + return + } + + rtl.render( + + + , + ) + expect(rtl.screen.getByTestId('value').textContent).toBe('0') + await warmUp(store) + expect(rtl.screen.getByTestId('value').textContent).toBe('1') + + rtl.act(() => { + store.dispatch({ type: 'inc' }) + }) + expect(store.getState().value).toBe(2) + + rtl.act(() => { + forceParent() + }) + expect(rtl.screen.getByTestId('value').textContent).toBe('2') + + await flushNotifications() + expect(rtl.screen.getByTestId('value').textContent).toBe('2') + expect(seen.at(-1)).toBe(2) + }) + + it('stock and signal hooks in one tree never disagree within a render pass', async () => { + const store = makeStore() + const passes: Array<{ parent: number; child: number }> = [] + let current: { parent: number; child: number } | null = null + let forceParent: () => void = () => {} + + function Child() { + const child = useSelector(selectValue) + current = { parent: current?.parent ?? -1, child } + passes.push(current) + return null + } + function Parent() { + const [, setN] = React.useState(0) + forceParent = () => setN((n) => n + 1) + const parent = useStockSelector(selectValue) + current = { parent, child: -1 } + return + } + + rtl.render( + + + , + ) + + await warmUp(store) + + rtl.act(() => { + store.dispatch({ type: 'inc' }) + }) + rtl.act(() => { + forceParent() + }) + await flushNotifications() + + for (const p of passes) { + expect(p.child).toBe(p.parent) + } + expect(passes.at(-1)).toEqual({ parent: 2, child: 2 }) + }) + + it('a dispatch from a layout effect is visible to the post-commit snapshot check', async () => { + const store = makeStore() + const seen: number[] = [] + + function Comp({ fire }: { fire: boolean }) { + // Inline selector on purpose: a new selector identity each render + // is what makes React run its post-commit getSnapshot consistency + // check (RTK Query's hooks have this shape). + const value = useSelector((s: State) => s.value) + seen.push(value) + React.useLayoutEffect(() => { + if (fire) store.dispatch({ type: 'inc' }) + }, [fire]) + return null + } + + const { rerender } = rtl.render( + + + , + ) + await warmUp(store) + + rerender( + + + , + ) + // React re-checks getSnapshot after commit and re-renders + // synchronously if it moved; the value must not wait for the + // deferred notification. + expect(store.getState().value).toBe(2) + expect(seen.at(-1)).toBe(2) + + const before = seen.length + await flushNotifications() + expect(seen.length).toBe(before) + }) +}) From 16bc4a78e01dc235264530c034e23fba333d897f Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 26 Sep 2026 14:28:32 +0200 Subject: [PATCH 90/95] Rewrite quadratic unsubscribe test to avoid timing dependency --- test/hooks/useSelector.spec.tsx | 138 ++++++++++++++++++-------------- 1 file changed, 80 insertions(+), 58 deletions(-) diff --git a/test/hooks/useSelector.spec.tsx b/test/hooks/useSelector.spec.tsx index 61a2eb950..5d04c65a7 100644 --- a/test/hooks/useSelector.spec.tsx +++ b/test/hooks/useSelector.spec.tsx @@ -695,68 +695,90 @@ describe('React', () => { expect(renderedItems[0]).toBe(renderedItems[1]) }) - it('should have linear or better unsubscribe time, not quadratic', () => { - const reducer = (state: { n: number } = { n: 0 }, action: any) => - action.type === 'INC' ? { n: state.n + 1 } : state - const store = createStore(reducer) - const increment = () => ({ type: 'INC' }) - - const numChildren = 100000 - - function App() { - useSelector((s: { n: number }) => s.n) - const dispatch = useDispatch() - - const [children, setChildren] = useState(numChildren) - - const toggleChildren = () => - setChildren((c) => (c ? 0 : numChildren)) - - return ( -
- - - {[...Array(children).keys()].map((i) => ( - - ))} -
- ) - } - - function Child() { - useSelector((s: number) => s) - // Deliberately do not return any DOM here - we want to isolate the cost of - // unsubscribing, and tearing down thousands of JSDOM nodes is expensive and irrelevant - return null - } + it( + 'should have linear or better unsubscribe time, not quadratic', + // Six mounts of 10K components dominate the runtime and slow down + // several-fold when the suite runs in parallel; the unmount ratio + // below is what's actually asserted. + { timeout: 30_000 }, + () => { + const reducer = (state: { n: number } = { n: 0 }, action: any) => + action.type === 'INC' ? { n: state.n + 1 } : state + + // Neither child returns DOM: the point is the cost of + // unsubscribing, and tearing down thousands of JSDOM nodes + // would swamp it. + function SubscribedChild() { + useSelector((s: number) => s) + return null + } + function PlainChild() { + return null + } - const { getByText } = rtl.render( - - - , - ) + // Mount `numChildren` children, then unmount them all in one + // commit and return how long that commit took. + function measureUnmount( + numChildren: number, + Child: () => null, + ): number { + const store = createStore(reducer) + + function App() { + useSelector((s: { n: number }) => s.n) + const [children, setChildren] = useState(numChildren) + return ( +
+ + {[...Array(children).keys()].map((i) => ( + + ))} +
+ ) + } - const timeBefore = Date.now() + const { getByText, unmount } = rtl.render( + + + , + ) + const button = getByText('Toggle Children') + + const timeBefore = performance.now() + rtl.act(() => { + rtl.fireEvent.click(button) + }) + const elapsed = performance.now() - timeBefore + + unmount() + return elapsed + } - const button = getByText('Toggle Children') - rtl.act(() => { - rtl.fireEvent.click(button) - }) + // Unsubscribing used to cost O(N) per component (array scan in + // Subscription), so unmounting N subscribed children was + // quadratic. An absolute time budget can't detect that reliably: + // it depends on the machine and on how many other test workers + // are competing for the CPU. So compare against a baseline + // instead: the same commit tearing down the same number of + // children that never subscribed. That cancels React's own + // teardown cost and any CPU contention, leaving the cost the + // subscriptions add. Measured at 10K children: linear + // unsubscribe is ~3x the baseline, the old quadratic behavior + // ~40x. Min of three runs filters GC pauses and cold-JIT noise. + const N = 10_000 + const minOf = (fn: () => number) => { + let best = Infinity + for (let i = 0; i < 3; i++) best = Math.min(best, fn()) + return best + } + const plain = minOf(() => measureUnmount(N, PlainChild)) + const subscribed = minOf(() => measureUnmount(N, SubscribedChild)) - const timeAfter = Date.now() - const elapsedTime = timeAfter - timeBefore - - // Seeing an unexpected variation in elapsed time between React 18 and React 17 + the compat entry point. - // With 18, I see around 75ms with correct implementation on my machine, with 100K items. - // With 17 + compat, the same correct impl takes about 4200-5000ms. - // With the quadratic behavior, this is at least 13000ms (or worse!) under 18, and 22000ms+ with 17. - // The 13000ms time for 18 stays the same if I use the shim, so it must be a 17 vs 18 difference somehow, - // although I can't imagine why, and if I remove the `useSelector` calls both tests drop to ~50ms. - // So, we'll modify our expectations here depending on whether this is an 18 or 17 compat test, - // and give some buffer time to allow for variations in test machines. - const expectedMaxUnmountTime = IS_REACT_18 || IS_REACT_19 ? 500 : 7000 - expect(elapsedTime).toBeLessThan(expectedMaxUnmountTime) - }) + expect(subscribed / plain).toBeLessThan(10) + }, + ) it('keeps working when used inside a Suspense', async () => { let result: number | undefined From bd84bc65524be1485bdf78d942be98953dd57d68 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Mon, 28 Sep 2026 22:07:18 -0400 Subject: [PATCH 91/95] Force RTK import of R-R to resolve to local source --- vitest.config.mts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/vitest.config.mts b/vitest.config.mts index 09372baa5..93d3dfd5e 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -5,6 +5,18 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['test/setup.ts'], globals: true, + server: { + deps: { + // RTK's `query/react` entry imports `react-redux` at runtime. + // Externalized deps are loaded by Node, which never applies the + // alias below: the import fails (this repo IS react-redux, so it + // isn't installed), and any copy Node did find would be a second + // module instance with its own context, ignoring TEST_IMPL. + // Inlining runs RTK through Vite so it shares the aliased instance + // the tests use. + inline: [/@reduxjs\/toolkit/], + }, + }, alias: { // Which implementation the shared spec files run against. // TEST_DIST=1 -> the built package From 6781a721ebf7e5230f27226f48c3de47157012af Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Mon, 28 Sep 2026 22:07:58 -0400 Subject: [PATCH 92/95] Collapse enumeration reads into fewer signals --- src/signals/trackingProxy.ts | 202 ++++++- src/signals/useSignalSelector.ts | 26 +- test/signals/depChurn.spec.ts | 14 +- test/signals/edgeCases.spec.ts | 6 +- test/signals/enumerationCollapse.spec.tsx | 640 ++++++++++++++++++++++ test/signals/trackingProxy.spec.ts | 18 +- 6 files changed, 845 insertions(+), 61 deletions(-) create mode 100644 test/signals/enumerationCollapse.spec.tsx diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts index bbf4febbe..78e389ee1 100644 --- a/src/signals/trackingProxy.ts +++ b/src/signals/trackingProxy.ts @@ -102,6 +102,122 @@ export interface LeafObjectTracker { accessedObjects: Map /** Paths that had children accessed (i.e., were traversed, not leaves) */ traversedPaths: Set + /** Plain objects whose keys were enumerated this evaluation, with the + * dependency reads deferred until `finalizeDependencies` decides + * whether they collapse onto the object's own version signal. */ + enumerated: Map +} + +/** + * Deferred reads for one enumerated object. A spread (`{...obj}`), + * `Object.keys(obj).map(k => obj[k])`, `Object.values`, `Object.entries`, + * `shallowEqual`, and `JSON.stringify` all call `ownKeys` and then read + * every own key. Reading every own value of an immutably updated object + * depends on exactly the same thing as the object's identity, so once the + * evaluation ends and the reads are known to be complete, one version + * signal on the object replaces a signal per key plus the keys-meta + * signal. An incomplete enumeration (`Object.keys(obj).length`, or + * enumerating and reading a subset) replays the deferred reads instead. + */ +export interface EnumerationRecord { + target: object + keysRead: Set + leaves: Array<[pathKey: string, value: unknown]> + objects: Array<[pathKey: string, value: object]> +} + +/** + * Create an empty tracker for one selector evaluation. + * @returns A fresh LeafObjectTracker + */ +export function createLeafTracker(): LeafObjectTracker { + return { + accessedObjects: new Map(), + traversedPaths: new Set(), + enumerated: new Map(), + } +} + +/** + * Register the dependencies an evaluation deferred: collapse or replay + * each enumerated object, then read version signals for leaf objects + * (read but never traversed — identity-only usage). Must run inside the + * same reactive computation that ran the selector, after it returned. + * @param tracker - The tracker installed for the evaluation + * @param registry - Signal registry for dependency tracking + * @returns void + */ +export function finalizeDependencies( + tracker: LeafObjectTracker, + registry: PathSignalRegistry, +): void { + // Pass 1: decide which enumerations are complete. A collapsed object's + // version signal fires on any change beneath it, so every deferred read + // under a collapsed ancestor is redundant and gets skipped in pass 2 + // (JSON.stringify collapses each nested object; only the outermost + // needs a signal). Records are checked against ancestors, not order, + // because a child can be enumerated before its parent. + const collapsed: string[] = [] + for (const [objPath, record] of tracker.enumerated) { + tracker.traversedPaths.add(objPath) + if (isCompleteEnumeration(record)) { + collapsed.push(objPath) + } + } + const underCollapsed = (path: string): boolean => { + for (let i = 0; i < collapsed.length; i++) { + const c = collapsed[i] + if ( + path.length > c.length && + path.startsWith(c) && + path[c.length] === '.' + ) { + return true + } + } + return false + } + + // Pass 2: read one version signal per collapsed object; replay the + // deferred reads for incomplete enumerations. + for (const [objPath, record] of tracker.enumerated) { + if (underCollapsed(objPath)) continue + if (isCompleteEnumeration(record)) { + registry.getOrCreate(objPath, record.target).get() + continue + } + registry + .getOrCreate(keysMetaPath(objPath), Object.keys(record.target)) + .get() + for (let i = 0; i < record.leaves.length; i++) { + const [pathKey, value] = record.leaves[i] + registry.getOrCreate(pathKey, value).get() + } + for (let i = 0; i < record.objects.length; i++) { + const [pathKey, value] = record.objects[i] + registry.ensurePrefix(pathKey) + tracker.accessedObjects.set(pathKey, value) + } + } + + // Skip the root: its version signal is never fired by diff. + for (const [objPath, rawValue] of tracker.accessedObjects) { + if ( + objPath !== '' && + !tracker.traversedPaths.has(objPath) && + !underCollapsed(objPath) + ) { + registry.getOrCreate(objPath, rawValue).get() + } + } +} + +function isCompleteEnumeration(record: EnumerationRecord): boolean { + const ownKeys = Object.keys(record.target) + for (let i = 0; i < ownKeys.length; i++) { + if (!record.keysRead.has(ownKeys[i])) return false + } + return true } /** @@ -300,6 +416,15 @@ export function createTrackingProxy( let lastValue: unknown let repeatCount = 0 + // The enumeration record for this object is created by this proxy's own + // ownKeys trap, so the get trap can find it with an epoch compare instead + // of a Map lookup per read. Element scans over non-enumerated objects are + // the hot path and must not pay for the lookup. + let enumEpoch = -1 + let enumRecord: EnumerationRecord | undefined + const currentEnumeration = () => + enumEpoch === registry.evalEpoch ? enumRecord : undefined + // Use an unfrozen shell as the proxy target to avoid ES Proxy invariant // violations with frozen objects. The shell copies the target's prototype // so that Array.isArray, instanceof, etc. work correctly. @@ -333,6 +458,10 @@ export function createTrackingProxy( if (typeof prop === 'symbol') return Reflect.get(target, prop) if (prop === lastProp && registry.evalEpoch === lastEpoch) { + // A repeat read still counts toward a complete enumeration (a + // field read directly and then again via a spread). The first + // read already registered whatever dependency it needed. + currentEnumeration()?.keysRead.add(prop) if (process.env.NODE_ENV !== 'production') { if (++repeatCount === REPEATED_READ_WARN_THRESHOLD) { warnRepeatedRead(registry, getPathKey(prop)) @@ -407,21 +536,14 @@ export function createTrackingProxy( if (!Array.isArray(value) && !isPlainObject(value as object)) { if (leafTracker) { leafTracker.traversedPaths.add(parentPath) + // Counts toward a complete enumeration; the reference read + // stays immediate and is simply redundant if it collapses. + currentEnumeration()?.keysRead.add(prop as string) } registry.getOrCreate(pathKey, value).get() return value } - // Register in prefix index (for hasPrefix/diff tracking) but DON'T - // create a signal. This avoids allocating signals for intermediate - // objects that are only traversed, not read as terminal values. - registry.ensurePrefix(pathKey) - - // Mark parent as traversed (it has children being accessed) - if (leafTracker) { - leafTracker.traversedPaths.add(parentPath) - } - // Return cached child proxy (createTrackingProxy checks cache internally). // No tracker argument: the trap already reads the registry's // active-tracker holder, which this evaluation installed. @@ -432,16 +554,48 @@ export function createTrackingProxy( cache, ) - // Track this object access — may be a leaf (identity-only usage) + // Enumeration in progress on this object: defer the prefix and + // leaf-object bookkeeping to finalizeDependencies. + const enumeration = currentEnumeration() + if (enumeration !== undefined) { + if (!enumeration.keysRead.has(prop as string)) { + enumeration.keysRead.add(prop as string) + enumeration.objects.push([pathKey, value as object]) + } + return childProxy + } + + // Register in prefix index (for hasPrefix/diff tracking) but DON'T + // create a signal. This avoids allocating signals for intermediate + // objects that are only traversed, not read as terminal values. + registry.ensurePrefix(pathKey) + if (leafTracker) { + // Mark parent as traversed (it has children being accessed) + leafTracker.traversedPaths.add(parentPath) + // Track this object access — may be a leaf (identity-only usage) leafTracker.accessedObjects.set(pathKey, value as object) } return childProxy } - // Mark parent as traversed (it has children being accessed) if (leafTracker) { + // Enumeration in progress on this object: defer the leaf read to + // finalizeDependencies. + const enumeration = currentEnumeration() + if (enumeration !== undefined) { + if (!enumeration.keysRead.has(prop as string)) { + enumeration.keysRead.add(prop as string) + enumeration.leaves.push([pathKey, value]) + } + lastEpoch = registry.evalEpoch + lastProp = prop as string + lastValue = value + repeatCount = 0 + return value + } + // Mark parent as traversed (it has children being accessed) leafTracker.traversedPaths.add(parentPath) } @@ -462,11 +616,27 @@ export function createTrackingProxy( return value }, - // Track when selectors iterate keys (Object.keys, for...in, .map, .filter, etc.) + // Track when selectors iterate keys (Object.keys, for...in, spread, etc.) ownKeys(_obj) { - registry - .getOrCreate(keysMetaPath(parentPath), Reflect.ownKeys(target)) - .get() + const leafTracker = registry.leafTrackerHolder.current + // Plain non-root objects defer: if every own key gets read before + // the evaluation ends, one version signal on the object covers the + // keys and all the values. Arrays already collapse primitive element + // reads onto the array's version signal, and the root's version + // signal is never fired by diff, so both keep the immediate read. + if (leafTracker && !isRoot && !Array.isArray(target)) { + let record = leafTracker.enumerated.get(parentPath) + if (record === undefined) { + record = { target, keysRead: new Set(), leaves: [], objects: [] } + leafTracker.enumerated.set(parentPath, record) + } + enumRecord = record + enumEpoch = registry.evalEpoch + } else { + registry + .getOrCreate(keysMetaPath(parentPath), Reflect.ownKeys(target)) + .get() + } return Reflect.ownKeys(target) }, diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index 89bf2be94..200a00dce 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -12,9 +12,10 @@ import { useSignalContext, } from './context' import { + createLeafTracker, createTrackingProxy, + finalizeDependencies, getProxyPath, - type LeafObjectTracker, } from './trackingProxy' import { computed, effect, signal } from './reactiveSystem' import { untrackResult } from './untrack' @@ -142,10 +143,7 @@ const useSignalSelectorImpl = ( // The holder object is stable; its containers are swapped for fresh // ones at the start of each evaluation (a computed never re-enters // its own evaluation, so this is safe). - const leafTracker: LeafObjectTracker = { - accessedObjects: new Map(), - traversedPaths: new Set(), - } + const leafTracker = createLeafTracker() // Bumped when the component re-renders with a different selector // function (e.g., an inline selector closing over changed props). @@ -315,6 +313,7 @@ const useSignalSelectorImpl = ( leafTracker.accessedObjects = new Map() leafTracker.traversedPaths = new Set() + leafTracker.enumerated = new Map() const proxy = createTrackingProxy( state, @@ -336,19 +335,10 @@ const useSignalSelectorImpl = ( registry.getOrCreate(proxyPath, result).get() } - // Read version signals for leaf objects — objects that were accessed - // but never had their properties read. These are used for identity - // comparison (===) and need their ref-change signals tracked. - for (const [objPath, rawValue] of leafTracker.accessedObjects) { - if (!leafTracker.traversedPaths.has(objPath)) { - // This object was read but never traversed — it's a leaf. - // Read its version signal to track identity changes. - // Skip root path since root changes every dispatch. - if (objPath !== '') { - registry.getOrCreate(objPath, rawValue).get() - } - } - } + // Deferred reads: collapse complete enumerations onto object + // version signals, then track identity for leaf objects (read + // but never traversed, e.g. used only for `===`). + finalizeDependencies(leafTracker, registry) // Strip tracking proxies before the result crosses into React. // Must run AFTER the dependency reads above (they need the proxies diff --git a/test/signals/depChurn.spec.ts b/test/signals/depChurn.spec.ts index 3bd1020d9..1c3b465d5 100644 --- a/test/signals/depChurn.spec.ts +++ b/test/signals/depChurn.spec.ts @@ -4,10 +4,11 @@ import { alienEngine } from '../../src/signals/engine' import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' import type { PathSignalRegistry } from '../../src/signals/pathSignalRegistry' import { + createLeafTracker, createTrackingProxy, + finalizeDependencies, getProxyPath, } from '../../src/signals/trackingProxy' -import type { LeafObjectTracker } from '../../src/signals/trackingProxy' import { reconcileState } from '../../src/signals/diff' // Regression guard for dependency churn with memoized collection selectors. @@ -119,10 +120,7 @@ function mountHook( scope.run(() => { const computed = alienEngine.computed(() => { evals++ - const leafTracker: LeafObjectTracker = { - accessedObjects: new Map(), - traversedPaths: new Set(), - } + const leafTracker = createLeafTracker() const proxy = createTrackingProxy( getState(), '', @@ -133,11 +131,7 @@ function mountHook( const result = selector(proxy as State) const proxyPath = getProxyPath(result) if (proxyPath !== undefined) registry.getOrCreate(proxyPath, result).get() - for (const [objPath, rawValue] of leafTracker.accessedObjects) { - if (!leafTracker.traversedPaths.has(objPath) && objPath !== '') { - registry.getOrCreate(objPath, rawValue).get() - } - } + finalizeDependencies(leafTracker, registry) return result }) alienEngine.effect(() => { diff --git a/test/signals/edgeCases.spec.ts b/test/signals/edgeCases.spec.ts index faa8ab2eb..23ff681e9 100644 --- a/test/signals/edgeCases.spec.ts +++ b/test/signals/edgeCases.spec.ts @@ -14,15 +14,13 @@ import { describe, it, expect } from 'vitest' import { alienEngine } from '../../src/signals/engine' import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' import { + createLeafTracker, createTrackingProxy, - type LeafObjectTracker, } from '../../src/signals/trackingProxy' import { reconcileState } from '../../src/signals/diff' import { buildIdentityPath } from '../../src/signals/arrayKeys' -function createTracker(): LeafObjectTracker { - return { accessedObjects: new Map(), traversedPaths: new Set() } -} +const createTracker = createLeafTracker describe('edge cases: non-plain objects in state', () => { // Non-plain objects (Date, Map, Set, class instances) are opaque leaves: diff --git a/test/signals/enumerationCollapse.spec.tsx b/test/signals/enumerationCollapse.spec.tsx new file mode 100644 index 000000000..301a877bf --- /dev/null +++ b/test/signals/enumerationCollapse.spec.tsx @@ -0,0 +1,640 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import * as rtl from '@testing-library/react' +import { configureStore, createSlice } from '@reduxjs/toolkit' +import { Provider, useSelector, shallowEqual } from 'react-redux' +import { alienEngine } from '../../src/signals/engine' +import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import type { PathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import { + createLeafTracker, + createTrackingProxy, + finalizeDependencies, + getProxyPath, +} from '../../src/signals/trackingProxy' +import { reconcileState } from '../../src/signals/diff' +import { keysMetaPath } from '../../src/signals/arrayKeys' +import { useSignalContext } from '../../src/signals/context' + +const IS_SIGNALS = process.env.TEST_IMPL === 'signals' + +// Enumerating a plain object and then reading every own value (spread, +// Object.values/entries, JSON.stringify, shallowEqual, for...in) depends on +// exactly what the object's identity depends on under immutable updates. +// The tracking proxy defers those reads and collapses a complete +// enumeration onto the object's version signal instead of one signal per +// key plus the keys-meta signal. Incomplete enumerations replay the +// deferred reads so precision is unchanged. + +interface Address { + city: string + zip: string +} + +interface User { + name: string + age: number + address: Address + joined: Date +} + +interface Item { + id: number + text: string + done: boolean +} + +interface State { + user: User + other: User + counter: number + items: Item[] +} + +function makeState(): State { + return { + user: { + name: 'Alice', + age: 30, + address: { city: 'Springfield', zip: '11111' }, + joined: new Date(2020, 0, 1), + }, + other: { + name: 'Bob', + age: 41, + address: { city: 'Shelbyville', zip: '22222' }, + joined: new Date(2021, 0, 1), + }, + counter: 0, + items: [ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: true }, + ], + } +} + +function makeRegistry(): PathSignalRegistry { + return createPathSignalRegistry(alienEngine) +} + +function mount( + getState: () => State, + selector: (state: State) => R, + registry: PathSignalRegistry, +) { + let evals = 0 + const scope = alienEngine.createScope() + const computed = scope.run(() => + alienEngine.computed(() => { + evals++ + const leafTracker = createLeafTracker() + const proxy = createTrackingProxy( + getState(), + '', + registry, + registry.proxyCache, + leafTracker, + ) + const result = selector(proxy as State) + const proxyPath = getProxyPath(result) + if (proxyPath !== undefined) registry.getOrCreate(proxyPath, result).get() + finalizeDependencies(leafTracker, registry) + return result + }), + ) + return { computed, evals: () => evals, stop: () => scope.stop() } +} + +function harness(selector: (state: State) => R) { + let state = makeState() + const registry = makeRegistry() + const m = mount(() => state, selector, registry) + const dispatch = (recipe: (prev: State) => State) => { + const prev = state + state = recipe(prev) + reconcileState(prev, state, registry, alienEngine) + return m.computed.get() + } + return { registry, dispatch, ...m } +} + +const setUserName = (name: string) => (s: State) => ({ + ...s, + user: { ...s.user, name }, +}) +const setUserCity = (city: string) => (s: State) => ({ + ...s, + user: { ...s.user, address: { ...s.user.address, city } }, +}) +const setOtherName = (name: string) => (s: State) => ({ + ...s, + other: { ...s.other, name }, +}) +const bumpCounter = (s: State) => ({ ...s, counter: s.counter + 1 }) +const addUserKey = (s: State) => ({ + ...s, + user: { ...s.user, nickname: 'Al' } as User, +}) + +describe('enumeration collapse (unit)', () => { + describe('complete enumerations collapse onto the object version signal', () => { + const cases: Array<[string, (s: State) => unknown]> = [ + ['spread', (s) => ({ ...s.user })], + ['spread with extra field', (s) => ({ ...s.user, extra: 1 })], + ['Object.values', (s) => Object.values(s.user)], + ['Object.entries', (s) => Object.entries(s.user)], + ['Object.assign', (s) => Object.assign({}, s.user)], + ['JSON.stringify', (s) => JSON.stringify(s.user)], + [ + 'for...in reading each key', + (s) => { + const out: Record = {} + for (const k in s.user) out[k] = s.user[k as keyof User] + return out + }, + ], + [ + 'Object.keys().map(k => obj[k])', + (s) => Object.keys(s.user).map((k) => s.user[k as keyof User]), + ], + ] + + for (const [label, selector] of cases) { + it(`${label}: one signal for the object, none for its keys`, () => { + const h = harness(selector) + h.computed.get() + expect(h.evals()).toBe(1) + + const paths = h.registry.debugPaths() + expect(paths).toContain('user') + expect(paths).not.toContain(keysMetaPath('user')) + expect(paths).not.toContain('user.name') + expect(paths).not.toContain('user.age') + expect(paths).not.toContain('user.address') + expect(paths).not.toContain(keysMetaPath('user.address')) + expect(paths).not.toContain('user.address.city') + h.stop() + }) + + it(`${label}: re-runs on any change to the object, not on siblings`, () => { + const h = harness(selector) + h.computed.get() + + h.dispatch(bumpCounter) + expect(h.evals()).toBe(1) + h.dispatch(setOtherName('Robert')) + expect(h.evals()).toBe(1) + + h.dispatch(setUserName('Alicia')) + expect(h.evals()).toBe(2) + h.dispatch(setUserCity('Ogdenville')) + expect(h.evals()).toBe(3) + h.dispatch(addUserKey) + expect(h.evals()).toBe(4) + h.stop() + }) + } + }) + + it('spread result reflects the new values after re-run', () => { + const h = harness((s) => ({ ...s.user })) + expect(h.computed.get().name).toBe('Alice') + // No prefix registered for the child object: the diff stops at `user` + // and fires its version signal. + expect(h.registry.hasPrefix('user.address')).toBe(false) + const next = h.dispatch(setUserName('Alicia')) + expect(next.name).toBe('Alicia') + expect(next.address.city).toBe('Springfield') + h.stop() + }) + + it('non-plain child (Date) counts toward completeness', () => { + const h = harness((s) => ({ ...s.user })) + h.computed.get() + const paths = h.registry.debugPaths() + expect(paths).toContain('user') + expect(paths).not.toContain(keysMetaPath('user')) + // The Date reference read stays immediate (harmless duplicate). + expect(paths).toContain('user.joined') + + h.dispatch((s) => ({ + ...s, + user: { ...s.user, joined: new Date(2022, 5, 5) }, + })) + expect(h.evals()).toBe(2) + h.stop() + }) + + it('empty object spread collapses and fires when a key is added', () => { + let state = { ...makeState(), user: {} as User } + const registry = makeRegistry() + const m = mount( + () => state, + (s) => ({ ...s.user }), + registry, + ) + m.computed.get() + expect(registry.debugPaths()).toContain('user') + expect(registry.debugPaths()).not.toContain(keysMetaPath('user')) + + const prev = state + state = { ...state, user: { name: 'Zed' } as User } + reconcileState(prev, state, registry, alienEngine) + expect(m.computed.get()).toEqual({ name: 'Zed' }) + expect(m.evals()).toBe(2) + m.stop() + }) + + describe('incomplete enumerations replay the deferred reads', () => { + it('Object.keys().length: keys-meta signal only', () => { + const h = harness((s) => Object.keys(s.user).length) + expect(h.computed.get()).toBe(4) + + const paths = h.registry.debugPaths() + expect(paths).toContain(keysMetaPath('user')) + expect(paths).not.toContain('user.name') + expect(paths).not.toContain('user') + + h.dispatch(setUserName('Alicia')) + expect(h.evals()).toBe(1) + h.dispatch(setUserCity('Ogdenville')) + expect(h.evals()).toBe(1) + expect(h.dispatch(addUserKey)).toBe(5) + expect(h.evals()).toBe(2) + h.stop() + }) + + it('reading a subset of keys: keys-meta plus only the leaves read', () => { + const h = harness((s) => + Object.keys(s.user) + .filter((k) => k !== 'age' && k !== 'address' && k !== 'joined') + .map((k) => s.user[k as keyof User]), + ) + expect(h.computed.get()).toEqual(['Alice']) + + const paths = h.registry.debugPaths() + expect(paths).toContain(keysMetaPath('user')) + expect(paths).toContain('user.name') + expect(paths).not.toContain('user.age') + expect(paths).not.toContain('user') + + h.dispatch((s) => ({ ...s, user: { ...s.user, age: 31 } })) + expect(h.evals()).toBe(1) + h.dispatch(setUserCity('Ogdenville')) + expect(h.evals()).toBe(1) + h.dispatch(setUserName('Alicia')) + expect(h.evals()).toBe(2) + h.dispatch(addUserKey) + expect(h.evals()).toBe(3) + h.stop() + }) + + it('subset including an object child: child is a leaf-object identity dep', () => { + const h = harness((s) => + Object.keys(s.user) + .filter((k) => k === 'address') + .map((k) => s.user[k as keyof User]), + ) + h.computed.get() + + const paths = h.registry.debugPaths() + expect(paths).toContain(keysMetaPath('user')) + expect(paths).toContain('user.address') + expect(paths).not.toContain('user.address.city') + expect(paths).not.toContain('user') + + h.dispatch(setUserName('Alicia')) + expect(h.evals()).toBe(1) + h.dispatch(setUserCity('Ogdenville')) + expect(h.evals()).toBe(2) + h.stop() + }) + + it('subset including an object child that is then traversed: precise leaf dep', () => { + const h = harness((s) => + Object.keys(s.user) + .filter((k) => k === 'address') + .map((k) => (s.user[k as keyof User] as Address).city), + ) + expect(h.computed.get()).toEqual(['Springfield']) + + const paths = h.registry.debugPaths() + expect(paths).toContain('user.address.city') + expect(paths).not.toContain('user.address') + + h.dispatch((s) => ({ + ...s, + user: { ...s.user, address: { ...s.user.address, zip: '99999' } }, + })) + expect(h.evals()).toBe(1) + h.dispatch(setUserCity('Ogdenville')) + expect(h.evals()).toBe(2) + h.stop() + }) + }) + + describe('mixed access in one evaluation', () => { + it('spread plus a deeper read: collapse is a superset, deep read still tracked', () => { + const h = harness((s) => ({ ...s.user, city: s.user.address.city })) + h.computed.get() + + const paths = h.registry.debugPaths() + expect(paths).toContain('user') + expect(paths).toContain('user.address.city') + expect(paths).not.toContain('user.name') + + h.dispatch(setUserName('Alicia')) + expect(h.evals()).toBe(2) + h.dispatch(setUserCity('Ogdenville')) + expect(h.evals()).toBe(3) + h.dispatch(bumpCounter) + expect(h.evals()).toBe(3) + h.stop() + }) + + it('a plain field read before the spread still collapses', () => { + const h = harness((s) => { + const n = s.user.name + return { n, ...s.user } + }) + h.computed.get() + // The direct read registered `user.name` immediately; the repeat + // read during the spread still counts toward completeness. + const paths = h.registry.debugPaths() + expect(paths).toContain('user') + expect(paths).toContain('user.name') + expect(paths).not.toContain('user.age') + expect(paths).not.toContain(keysMetaPath('user')) + h.dispatch(setUserName('Alicia')) + expect(h.evals()).toBe(2) + h.dispatch(setUserCity('Ogdenville')) + expect(h.evals()).toBe(3) + h.stop() + }) + + it('keys().length then spread of the same object collapses (complete overall)', () => { + const h = harness((s) => ({ + count: Object.keys(s.user).length, + ...s.user, + })) + h.computed.get() + const paths = h.registry.debugPaths() + expect(paths).toContain('user') + expect(paths).not.toContain(keysMetaPath('user')) + h.dispatch(addUserKey) + expect(h.evals()).toBe(2) + h.stop() + }) + + it('shallowEqual that returns early stays precise (incomplete enumeration)', () => { + // shallowEqual enumerates both objects but stops at the first key + // that differs (`name`), so neither enumeration is complete. + const h = harness((s) => shallowEqual(s.user, s.other)) + expect(h.computed.get()).toBe(false) + + const paths = h.registry.debugPaths() + expect(paths).toContain(keysMetaPath('user')) + expect(paths).toContain(keysMetaPath('other')) + expect(paths).toContain('user.name') + expect(paths).toContain('other.name') + expect(paths).not.toContain('user') + expect(paths).not.toContain('other') + expect(paths).not.toContain('other.age') + + h.dispatch(bumpCounter) + expect(h.evals()).toBe(1) + h.dispatch((s) => ({ ...s, other: { ...s.other, age: 42 } })) + expect(h.evals()).toBe(1) + h.dispatch(setOtherName('Robert')) + expect(h.evals()).toBe(2) + h.stop() + }) + + it('shallowEqual over equal-shaped objects collapses both', () => { + let state = makeState() + state = { + ...state, + other: { ...state.other, address: { ...state.user.address } }, + } + const registry = makeRegistry() + const m = mount( + () => state, + (s) => shallowEqual(s.user.address, s.other.address), + registry, + ) + expect(m.computed.get()).toBe(true) + const paths = registry.debugPaths() + expect(paths).toContain('user.address') + expect(paths).toContain('other.address') + expect(paths).not.toContain('user.address.city') + expect(paths).not.toContain(keysMetaPath('user.address')) + m.stop() + }) + + it('nested enumeration under a collapsed parent adds no signals', () => { + // JSON.stringify enumerates `user` and then `user.address`. (Its + // `toJSON` probe on each object is an ordinary missing-key read and + // still registers a leaf for it; that is unrelated to enumeration.) + const h = harness((s) => JSON.stringify(s.user)) + h.computed.get() + const paths = h.registry.debugPaths() + expect(paths).toContain('user') + expect(paths).not.toContain('user.address') + expect(paths).not.toContain(keysMetaPath('user.address')) + expect(paths).not.toContain('user.address.city') + h.stop() + }) + + it('child enumerated before its parent is still covered by the parent', () => { + const h = harness((s) => { + const n = Object.keys(s.user.address).length + return { ...s.user, n } + }) + h.computed.get() + const paths = h.registry.debugPaths() + expect(paths).toContain('user') + expect(paths).not.toContain(keysMetaPath('user.address')) + expect(paths).not.toContain('user.address') + + h.dispatch(bumpCounter) + expect(h.evals()).toBe(1) + h.dispatch((s) => ({ + ...s, + user: { + ...s.user, + address: { ...s.user.address, country: 'US' } as Address, + }, + })) + expect(h.evals()).toBe(2) + h.stop() + }) + }) + + describe('exclusions', () => { + it('root spread keeps the immediate keys-meta read and per-key leaves', () => { + const h = harness((s) => ({ ...s })) + h.computed.get() + + const paths = h.registry.debugPaths() + expect(paths).toContain(keysMetaPath('')) + expect(paths).toContain('counter') + expect(paths).toContain('user') + expect(paths).not.toContain('') + + h.dispatch(bumpCounter) + expect(h.evals()).toBe(2) + h.dispatch(setUserName('Alicia')) + expect(h.evals()).toBe(3) + h.stop() + }) + + it('array spread keeps existing array tracking', () => { + const h = harness((s) => [...s.items]) + expect(h.computed.get()).toHaveLength(2) + + h.dispatch(bumpCounter) + expect(h.evals()).toBe(1) + h.dispatch((s) => ({ + ...s, + items: [...s.items, { id: 3, text: 'c', done: false }], + })) + expect(h.evals()).toBe(2) + h.dispatch((s) => ({ + ...s, + items: s.items.map((i) => (i.id === 1 ? { ...i, done: true } : i)), + })) + expect(h.evals()).toBe(3) + h.stop() + }) + + it('spreading array elements inside map collapses onto element identity', () => { + const h = harness((s) => s.items.map((i) => ({ ...i }))) + expect(h.computed.get()).toEqual(makeState().items) + + const paths = h.registry.debugPaths() + expect(paths.some((p) => p.endsWith('.text'))).toBe(false) + expect(paths.some((p) => p.endsWith('.done'))).toBe(false) + + h.dispatch(bumpCounter) + expect(h.evals()).toBe(1) + const next = h.dispatch((s) => ({ + ...s, + items: s.items.map((i) => (i.id === 2 ? { ...i, text: 'B' } : i)), + })) + expect(h.evals()).toBe(2) + expect(next[1].text).toBe('B') + h.stop() + }) + }) + + describe('across evaluations', () => { + it('switching from spread to a single field drops the object dep', () => { + let mode: 'spread' | 'name' = 'spread' + const h = harness((s) => + mode === 'spread' ? { ...s.user } : s.user.name, + ) + h.computed.get() + expect(h.registry.debugPaths()).toContain('user') + + mode = 'name' + h.dispatch(setUserName('Alicia')) + expect(h.evals()).toBe(2) + expect(h.registry.debugPaths()).toContain('user.name') + // Identity signal for `user` has no subscribers left and is released. + expect(h.registry.debugPaths()).not.toContain('user') + + h.dispatch(setUserCity('Ogdenville')) + expect(h.evals()).toBe(2) + h.dispatch(setUserName('Alison')) + expect(h.evals()).toBe(3) + h.stop() + }) + + it('switching from a single field to spread picks up the object dep', () => { + let mode: 'spread' | 'name' = 'name' + const h = harness((s) => + mode === 'spread' ? { ...s.user } : s.user.name, + ) + h.computed.get() + + mode = 'spread' + h.dispatch(setUserName('Alicia')) + expect(h.evals()).toBe(2) + expect(h.registry.debugPaths()).toContain('user') + expect(h.registry.debugPaths()).not.toContain('user.name') + + h.dispatch(setUserCity('Ogdenville')) + expect(h.evals()).toBe(3) + h.stop() + }) + }) +}) + +// ─── Hook-level behavior (both TEST_IMPL modes) ─── + +const slice = createSlice({ + name: 'root', + initialState: makeState(), + reducers: { + renameUser(s, a: { payload: string }) { + s.user.name = a.payload + }, + renameOther(s, a: { payload: string }) { + s.other.name = a.payload + }, + increment(s) { + s.counter++ + }, + }, +}) + +function makeStore() { + return configureStore({ reducer: slice.reducer }) +} + +describe('enumeration collapse (hook)', () => { + it('spread selector with shallowEqual: renders only when the object changes', () => { + const store = makeStore() + const renders = { user: 0 } + let registry: PathSignalRegistry | undefined + + function CaptureRegistry() { + registry = useSignalContext().registry + return null + } + + function UserCard() { + renders.user++ + const user = useSelector((s: State) => ({ ...s.user }), shallowEqual) + return
{user.name}
+ } + + rtl.render( + + {IS_SIGNALS ? : null} + + , + ) + expect(rtl.screen.getByTestId('name').textContent).toBe('Alice') + expect(renders.user).toBe(1) + + rtl.act(() => { + store.dispatch(slice.actions.increment()) + store.dispatch(slice.actions.renameOther('Robert')) + }) + expect(renders.user).toBe(1) + + rtl.act(() => { + store.dispatch(slice.actions.renameUser('Alicia')) + }) + expect(rtl.screen.getByTestId('name').textContent).toBe('Alicia') + expect(renders.user).toBe(2) + + if (IS_SIGNALS) { + const paths = registry!.debugPaths() + expect(paths).toContain('user') + expect(paths).not.toContain('user.name') + expect(paths).not.toContain(keysMetaPath('user')) + } + }) +}) diff --git a/test/signals/trackingProxy.spec.ts b/test/signals/trackingProxy.spec.ts index 926263475..0b847bad6 100644 --- a/test/signals/trackingProxy.spec.ts +++ b/test/signals/trackingProxy.spec.ts @@ -1,13 +1,14 @@ import { describe, it, expect, vi } from 'vitest' import { + createLeafTracker, createTrackingProxy, + finalizeDependencies, getProxyPath, unwrap, } from '../../src/signals/trackingProxy' import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' import { reconcileState } from '../../src/signals/diff' import { alienEngine } from '../../src/signals/engine' -import type { LeafObjectTracker } from '../../src/signals/trackingProxy' import type { PathSignalRegistry } from '../../src/signals/pathSignalRegistry' function makeRegistry(): PathSignalRegistry { @@ -1109,10 +1110,7 @@ describe('array method dependency tracking', () => { callCount++ const state = getState() - const leafTracker: LeafObjectTracker = { - accessedObjects: new Map(), - traversedPaths: new Set(), - } + const leafTracker = createLeafTracker() const proxy = createTrackingProxy( state, @@ -1129,14 +1127,8 @@ describe('array method dependency tracking', () => { registry.getOrCreate(proxyPath, result).get() } - // Leaf object tracking (same as useSignalSelector) - for (const [objPath, rawValue] of leafTracker.accessedObjects) { - if (!leafTracker.traversedPaths.has(objPath)) { - if (objPath !== '') { - registry.getOrCreate(objPath, rawValue).get() - } - } - } + // Deferred enumeration + leaf object tracking (same as useSignalSelector) + finalizeDependencies(leafTracker, registry) return result }), From 995a4d95f7f80353849443f6b48187647b82a8ac Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Mon, 28 Sep 2026 22:17:20 -0400 Subject: [PATCH 93/95] Ensure RTKQ lastResult doesn't end up as a stale proxy --- src/signals/useSignalSelector.ts | 24 +++-- test/signals/rtkQueryInterop.spec.tsx | 133 ++++++++++++++++++++++++ test/signals/snapshotFreshness.spec.tsx | 49 ++++++++- 3 files changed, 196 insertions(+), 10 deletions(-) diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts index 200a00dce..b81564d5b 100644 --- a/src/signals/useSignalSelector.ts +++ b/src/signals/useSignalSelector.ts @@ -491,14 +491,14 @@ const useSignalSelectorImpl = ( if (!built) { // Deferred: no effect is watching yet. Serve fresh values for // dispatches in the render→subscribe gap (and wildcard waits) - // by recomputing against RAW state — no proxies, no signals, - // no untracking needed. Cached per state ref; equalityFn - // preserves the previous result's identity for equal-but-new - // references. A throw propagates into render — stock parity. + // by recomputing against RAW state, with no signals. Cached per + // state ref; equalityFn preserves the previous result's identity + // for equal-but-new references. A throw propagates into render — + // stock parity. const state = store.getState() if (state !== lastSnapshotState) { lastSnapshotState = state - const fresh = selectorRef.current(state as S) + const fresh = untrackResult(selectorRef.current(state as S)) if (!hasResult || !equalityFnRef.current(currentResult, fresh)) { currentResult = fresh hasResult = true @@ -528,13 +528,19 @@ const useSignalSelectorImpl = ( // signal effect hasn't refreshed currentResult. A render landing // in that gap must still see current state, the same way stock // useSelector reads store.getState() directly. Recompute against - // RAW state — no proxies, no dependency changes. When the - // notification arrives the effect re-evaluates, finds the value - // equal, and stays quiet. + // RAW state with no dependency changes. When the notification + // arrives the effect re-evaluates, finds the value equal, and + // stays quiet. + // + // Raw state does not guarantee a proxy-free result: a selector can + // close over values captured during an earlier tracked evaluation + // (RTK Query's `lastValue` ref holds the previous default result, + // whose `data` is a tracking proxy when `selectFromResult` is + // used) and return them. Untrack here as on the tracked path. const state = store.getState() if (state !== lastSnapshotState) { lastSnapshotState = state - const fresh = selectorRef.current(state as S) + const fresh = untrackResult(selectorRef.current(state as S)) if (!hasResult || !equalityFnRef.current(currentResult, fresh)) { currentResult = fresh hasResult = true diff --git a/test/signals/rtkQueryInterop.spec.tsx b/test/signals/rtkQueryInterop.spec.tsx index a85540f3b..1b5fe1c28 100644 --- a/test/signals/rtkQueryInterop.spec.tsx +++ b/test/signals/rtkQueryInterop.spec.tsx @@ -493,6 +493,139 @@ describe('RTK Query with reactHooksModule({ hooks: { useSelector } })', () => { }) }) + // While a new arg loads, RTKQ feeds the previous default result back into + // its selector as `lastResult` and keeps showing that result's `data`. + // With selectFromResult, that previous default result is captured inside + // the selector run, so under signals its `data` is the tracking proxy + // from an earlier evaluation. These check it never reaches the component + // and that behavior matches stock. + describe('arg change with selectFromResult', () => { + const seen: Array = [] + const pickPost = ({ + data, + isFetching, + }: { + data?: Post + isFetching: boolean + }) => ({ post: data, isFetching }) + + function Picker({ id, stable }: { id: number; stable: boolean }) { + countRender('picker') + const { post, isFetching } = api.endpoints.getPost.useQuery(id, { + selectFromResult: stable + ? pickPost + : ({ data, isFetching }) => ({ post: data, isFetching }), + }) + seen.push(post) + return ( +
+ {post?.title ?? ''} + {isFetching ? ' (fetching)' : ''} +
+ ) + } + + // mount, pending, fulfilled | new arg, its pending | fulfilled, back to 1 + const PICKER_RENDERS = { before: 3, whileLoading: 5, final: 7 } + + const rawPost = (id: number) => + api.endpoints.getPost.select(id)(store.getState()).data + + beforeEach(() => { + seen.length = 0 + }) + + describe.each([ + ['inline', false], + ['stable', true], + ])('%s selector', (_name, stable) => { + it('keeps the previous raw data while the new arg loads, then switches', async () => { + const { rerender } = rtl.render( + + + , + ) + await settle() + expect(rtl.screen.getByTestId('picker').textContent).toBe('first') + const post1 = rawPost(1) + expect(seen.at(-1)).toBe(post1) + const before = renders.picker + + server.pause() + rerender( + + + , + ) + await settle() + expect(rtl.screen.getByTestId('picker').textContent).toBe( + 'first (fetching)', + ) + expect(seen.at(-1)).toBe(post1) + expect(unwrap(seen.at(-1))).toBe(seen.at(-1)) + const whileLoading = renders.picker + + server.resume() + await settle() + expect(rtl.screen.getByTestId('picker').textContent).toBe('second') + expect(seen.at(-1)).toBe(rawPost(2)) + expect(unwrap(seen.at(-1))).toBe(seen.at(-1)) + expect(renders.picker).toBe(whileLoading + 1) + + // Back to the cached arg: immediate, no request, raw data + rerender( + + + , + ) + await settle() + expect(rtl.screen.getByTestId('picker').textContent).toBe('first') + expect(seen.at(-1)).toBe(post1) + expect(server.requests).toHaveLength(2) + expect({ before, whileLoading, final: renders.picker }).toEqual( + PICKER_RENDERS, + ) + }) + + it('ignores patches to the previous entry while the new arg loads', async () => { + const { rerender } = rtl.render( + + + , + ) + await settle() + server.pause() + rerender( + + + , + ) + await settle() + const post1 = seen.at(-1) + const whileLoading = renders.picker + + // The shown data is the previous result object, not a live read of + // entry 1, so patching entry 1 changes nothing on screen. + await dispatchAndSettle(() => + store.dispatch( + api.util.updateQueryData('getPost', 1, (draft) => { + draft.title = 'patched' + }), + ), + ) + expect(rtl.screen.getByTestId('picker').textContent).toBe( + 'first (fetching)', + ) + expect(seen.at(-1)).toBe(post1) + expect(renders.picker).toBe(whileLoading) + + server.resume() + await settle() + expect(rtl.screen.getByTestId('picker').textContent).toBe('second') + }) + }) + }) + it('runs a mutation, invalidates, and refetches the dependent query', async () => { let trigger: ((arg: { id: number; title: string }) => unknown) | null = null function Editor() { diff --git a/test/signals/snapshotFreshness.spec.tsx b/test/signals/snapshotFreshness.spec.tsx index 4a28c8508..aa51fe20b 100644 --- a/test/signals/snapshotFreshness.spec.tsx +++ b/test/signals/snapshotFreshness.spec.tsx @@ -20,10 +20,11 @@ afterEach(() => { interface State { value: number other: string + item: { label: string } } const reducer = ( - state: State = { value: 0, other: 'x' }, + state: State = { value: 0, other: 'x', item: { label: 'a' } }, action: { type: string }, ): State => { switch (action.type) { @@ -201,4 +202,50 @@ describe('snapshot freshness between dispatch and notification', () => { await flushNotifications() expect(seen.length).toBe(before) }) + + it('a value kept from an earlier evaluation reaches the component raw', async () => { + const store = makeStore() + const seen: Array = [] + let forceParent: () => void = () => {} + + // Holds on to an object from a previous run and returns it later, + // like RTK Query's `lastResult`. A value captured during a tracked + // evaluation is a proxy. + let kept: State['item'] | undefined + const selectKept = (s: State) => { + if (s.value < 2) kept = s.item + return kept + } + + function Child() { + const item = useSelector(selectKept) + seen.push(item) + return null + } + function Parent() { + const [, setN] = React.useState(0) + forceParent = () => setN((n) => n + 1) + return + } + + rtl.render( + + + , + ) + await warmUp(store) + + rtl.act(() => { + store.dispatch({ type: 'inc' }) + }) + rtl.act(() => { + forceParent() + }) + await flushNotifications() + + const raw = store.getState().item + for (const item of seen) { + expect(item).toBe(raw) + } + }) }) From 3bc417654b933cc1e7c801a61cbccd6fa2a9e958 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Mon, 28 Sep 2026 23:04:52 -0400 Subject: [PATCH 94/95] Bump RTK dev dep --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- pnpm-workspace.yaml | 3 +++ 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index d39093152..8055cfd61 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", - "@reduxjs/toolkit": "^2.12.0", + "@reduxjs/toolkit": "^2.13.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.10.0", "@testing-library/react": "^16.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 255f0985b..440b4de71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,8 +22,8 @@ importers: specifier: ^0.18.5 version: 0.18.5 '@reduxjs/toolkit': - specifier: ^2.12.0 - version: 2.12.0(react@19.2.8) + specifier: ^2.13.0 + version: 2.13.0(react@19.2.8) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -414,8 +414,8 @@ packages: '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} - '@reduxjs/toolkit@2.12.0': - resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} + '@reduxjs/toolkit@2.13.0': + resolution: {integrity: sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==} peerDependencies: react: ^16.9.0 || ^17.0.0 || ^18 || ^19 react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 @@ -1938,7 +1938,7 @@ snapshots: dependencies: quansync: 1.0.0 - '@reduxjs/toolkit@2.12.0(react@19.2.8)': + '@reduxjs/toolkit@2.13.0(react@19.2.8)': dependencies: '@standard-schema/spec': 1.1.0 '@standard-schema/utils': 0.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1f2e354a4..dd80e995c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,3 +22,6 @@ autoInstallPeers: false # is `allowBuilds` (a map); pnpm 10's `onlyBuiltDependencies` list is ignored. allowBuilds: esbuild: true + +minimumReleaseAgeExclude: + - '@reduxjs/toolkit@2.13.0' From 457f0b3689fd74f9c4dfb310f634207e9f078306 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Mon, 28 Sep 2026 23:06:28 -0400 Subject: [PATCH 95/95] Release 9.4.0-alpha.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8055cfd61..12edd77bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-redux", - "version": "9.4.0-alpha.1", + "version": "9.4.0-alpha.2", "description": "Official React bindings for Redux", "keywords": [ "react",