From 140afcfb92e0cb7d19a6765fff40e1ed1997ea59 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 24 Oct 2026 14:02:29 -0400 Subject: [PATCH 01/18] fix(normalizr): Keep memo WeakMaps one level deep WeakDependencyMap stored each dependency as a Link holding its own WeakMap, so a result with N dependencies built N nested WeakMaps. Hermes resolves WeakMap entries in the stop-the-world end of every old-gen collection by rescanning every entry slot until a pass marks nothing new, so that nesting cost N passes over every WeakMap entry in the app. A 3,736-symbol result joined to streaming tickers froze a React Native app for 0.5-3s per collection, growing over time. Entries now key only on the first dependency and keep the rest as an array compared by identity, with the newest two kept per argsKey bucket. Replaying Hermes's marking on a heap snapshot: 7,474 passes and 251M entry visits per GC become 4 passes and 90k visits. Cached reads are also 50-87% faster in the normalizr and core benchmarks. Adds examples/benchmark ephemerons.js to measure this on any engine. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/memo-shallow-weakmaps.md | 10 + examples/benchmark/README.md | 29 ++ examples/benchmark/ephemerons.js | 313 ++++++++++++++++++ examples/benchmark/package.json | 1 + .../src/__tests__/WeakDependencyMap.test.ts | 37 +++ .../normalizr/src/memo/WeakDependencyMap.ts | 154 +++++---- 6 files changed, 471 insertions(+), 73 deletions(-) create mode 100644 .changeset/memo-shallow-weakmaps.md create mode 100644 examples/benchmark/ephemerons.js diff --git a/.changeset/memo-shallow-weakmaps.md b/.changeset/memo-shallow-weakmaps.md new file mode 100644 index 000000000000..798d4525040c --- /dev/null +++ b/.changeset/memo-shallow-weakmaps.md @@ -0,0 +1,10 @@ +--- +'@data-client/normalizr': patch +'@data-client/core': patch +'@data-client/react': patch +'@data-client/vue': patch +--- + +Fix multi-second freezes on React Native (Hermes) when large results read streaming entities + +On Hermes, an app that kept reading a large result while its entities updated could freeze for 0.5–3s at a time, and the freezes grew the longer it ran. An example is `useSuspense()` on a few thousand entities that join live prices. Each freeze was a garbage collection pause caused by memoization. Cached reads no longer slow down Hermes's garbage collector, and they are faster on every platform. diff --git a/examples/benchmark/README.md b/examples/benchmark/README.md index 9eb74c466976..e4d67acc73d1 100644 --- a/examples/benchmark/README.md +++ b/examples/benchmark/README.md @@ -80,6 +80,35 @@ counts (minor/major/other) and total GC pause during the loop, and retained heap after a full GC (should be ~0; nonzero indicates actual retention). Local-only — not tracked in CI. +### WeakMap (ephemeron) pressure + +Memoization keys cached values on entities through `WeakMap`s. How much a +collection costs for those depends on the engine, so measure the worst case +with: + +```bash +yarn start:ephemerons [filter] +``` + +Each scenario takes a V8 heap snapshot and replays Hermes's marking loop on +it. It reports the live `WeakMap` entries, how many marking passes Hermes +would need, and their product, which is the number of entry visits in one +Hermes old-generation pause. It also reports V8's own full-GC time. The +scenarios mirror a live market screen: a 3,736-symbol endpoint whose symbols +join streaming tickers, and a 100-row trade tape that prepends prints. +Local-only — not tracked in CI. + +How engines resolve `WeakMap` entries during a major collection: + +| Engine | Algorithm | Where it runs | Cost for N nested entries | +| --- | --- | --- | --- | +| Hermes (React Native) | Repeats a scan of every `WeakMap` entry slot in the heap until a pass marks nothing new (`HadesGC::markWeakMapEntrySlots`). No key → value index. | Stop-the-world, on the JS thread, at the end of every old-gen collection | passes ≈ N + 1, each visiting every slot: quadratic | +| V8 (Node, Chrome) | The same fixpoint for at most `--ephemeron-fixpoint-iterations` (default 10) passes, then a linear algorithm over a key → values map (`MarkTransitiveClosureLinear` in `mark-compact.cc`) | Major GC; most marking is concurrent | linear | + +So a structure that V8 collects in milliseconds can stall Hermes for seconds: +one pass per level of nesting, and every pass rescans every `WeakMap` entry in +the app, not only the memo's own. Keep `WeakMap` nesting shallow. + ### Profiling For opt/deopt investigation: diff --git a/examples/benchmark/ephemerons.js b/examples/benchmark/ephemerons.js new file mode 100644 index 000000000000..0095532d0d7d --- /dev/null +++ b/examples/benchmark/ephemerons.js @@ -0,0 +1,313 @@ +/** + * WeakMap (ephemeron) pressure from memoized denormalization. + * + * Hermes resolves WeakMap entries in the stop-the-world end of every + * old-generation collection (`HadesGC::markWeakMapEntrySlots`): it rescans + * every WeakMap entry slot in the heap, repeating until a pass marks nothing + * new. A value reachable only through N nested WeakMap entries needs N + * passes, so one collection costs passes x slots entry visits, on the JS + * thread. + * + * V8 (Node, Chrome) runs the same fixpoint for at most + * `--ephemeron-fixpoint-iterations` (10) passes, then switches to a linear + * key -> values algorithm, so its pause stays small. To get the Hermes cost + * on any engine, each scenario takes a V8 heap snapshot and replays Hermes's + * marking loop on it. + * + * Each scenario mirrors a live screen: a large endpoint whose entities join + * frequently written entities, or a short list that streams new rows. + * + * Local-only; not part of CI benchmark tracking. Run with: + * yarn workspace example-benchmark start:ephemerons [filter] + */ +import { readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import vm from 'node:vm'; +import v8 from 'v8'; + +v8.setFlagsFromString('--expose_gc'); +const gc = vm.runInNewContext('gc'); + +// Count what the library creates. Installed before the bundle loads so its +// `new WeakMap()` calls see it. +const NativeWeakMap = WeakMap; +let created = 0; +let entriesSet = 0; +class CountingWeakMap extends NativeWeakMap { + constructor(entries) { + super(entries); + created++; + } + + set(key, value) { + entriesSet++; + return super.set(key, value); + } +} +globalThis.WeakMap = CountingWeakMap; + +const { Entity, normalize, MemoCache } = await import('./dist/index.js'); + +const EPHEMERON = + /part of key \(.*? @(\d+)\) -> value \(.*? @(\d+)\) pair in WeakMap \(table @(\d+)\)/; + +/** + * Replays Hermes's old-gen marking on a V8 heap snapshot of the live heap. + * + * Marks everything strongly reachable from the roots, then, like + * `markWeakMapEntrySlots`, scans every WeakMap entry and marks values whose + * map and key are marked, draining strong references after each pass, until + * a pass marks nothing. Returns the entry count, passes, and entry visits. + */ +function replayHermesMarking() { + const snap = JSON.parse(readSnapshot()); + const { node_fields, edge_fields, edge_types } = snap.snapshot.meta; + const NF = node_fields.length; + const EF = edge_fields.length; + const { nodes, edges, strings } = snap; + const nodeCount = nodes.length / NF; + const edgeCountField = node_fields.indexOf('edge_count'); + const idField = node_fields.indexOf('id'); + const toField = edge_fields.indexOf('to_node'); + const nameField = edge_fields.indexOf('name_or_index'); + const types = edge_types[0]; + const weak = types.indexOf('weak'); + const named = new Set( + ['context', 'property', 'internal', 'shortcut'].map(t => types.indexOf(t)), + ); + + const firstEdge = new Uint32Array(nodeCount + 1); + const indexById = new Map(); + for (let i = 0, e = 0; i < nodeCount; i++) { + firstEdge[i] = e; + e += nodes[i * NF + edgeCountField] * EF; + indexById.set(nodes[i * NF + idField], i); + } + firstEdge[nodeCount] = edges.length; + + // Ephemeron pairs show up as labelled edges; they are not strong. + const ephemeronEdge = new Uint8Array(edges.length / EF); + const entries = new Map(); + for (let e = 0; e < edges.length; e += EF) { + const type = edges[e]; + if (!named.has(type)) continue; + const m = EPHEMERON.exec(strings[edges[e + nameField]]); + if (!m) continue; + ephemeronEdge[e / EF] = 1; + entries.set(`${m[3]}:${m[1]}`, [ + indexById.get(+m[3]), + indexById.get(+m[1]), + indexById.get(+m[2]), + ]); + } + const slots = [...entries.values()].filter(s => + s.every(i => i !== undefined), + ); + + const marked = new Uint8Array(nodeCount); + const stack = []; + const drain = () => { + while (stack.length) { + const n = stack.pop(); + for (let e = firstEdge[n]; e < firstEdge[n + 1]; e += EF) { + if (edges[e] === weak || ephemeronEdge[e / EF]) continue; + const to = edges[e + toField] / NF; + if (!marked[to]) { + marked[to] = 1; + stack.push(to); + } + } + } + }; + marked[0] = 1; + stack.push(0); + drain(); + let passes = 0; + let newlyMarked; + do { + passes++; + newlyMarked = false; + for (const [table, key, value] of slots) { + if (marked[table] && marked[key] && !marked[value]) { + marked[value] = 1; + stack.push(value); + newlyMarked = true; + } + } + drain(); + } while (newlyMarked); + return { slots: slots.length, passes, visits: passes * slots.length }; +} + +function readSnapshot() { + const file = v8.writeHeapSnapshot( + join(tmpdir(), `ephemerons-${process.pid}.heapsnapshot`), + ); + try { + return readFileSync(file, 'utf8'); + } finally { + rmSync(file); + } +} + +// --- schemas shaped like native-binance's market data ---------------------- + +class Ticker extends Entity { + symbol = ''; + last = 0; + eventTime = 0; + pk() { + return this.symbol; + } + + static key = 'Ticker'; +} + +class MarketSymbol extends Entity { + symbol = ''; + baseAsset = ''; + quoteAsset = ''; + ticker = undefined; + pk() { + return this.symbol; + } + + static key = 'Symbol'; + static schema = { ticker: Ticker }; + static process(input) { + return { ...input, ticker: input.symbol }; + } +} + +class PlainSymbol extends Entity { + symbol = ''; + baseAsset = ''; + quoteAsset = ''; + pk() { + return this.symbol; + } + + static key = 'PlainSymbol'; +} + +class Trade extends Entity { + a = 0; + p = 0; + pk() { + return `${this.a}`; + } + + static key = 'Trade'; +} + +const SYMBOLS = 3736; +const TICKERS_PER_TICK = 500; +const TICKS = 60; +const TAPE = 100; +const PRINTS_PER_FLUSH = 3; +const FLUSHES = 600; + +const symbolRows = Array.from({ length: SYMBOLS }, (_, i) => ({ + symbol: `S${i}USDT`, + baseAsset: `S${i}`, + quoteAsset: 'USDT', +})); +const tickerRows = (tick, count) => + Array.from({ length: count }, (_, i) => ({ + symbol: `S${(tick * 7919 + i * 31) % SYMBOLS}USDT`, + last: tick + i, + eventTime: tick, + })); + +/** One reader of a 3,736-symbol endpoint while tickers stream (one write per tick). */ +function exchangeInfo(symbolSchema, streaming) { + const memo = new MemoCache(); + const schema = { symbols: [symbolSchema] }; + let store = normalize(schema, { symbols: symbolRows }); + const input = store.result; + store = normalize([Ticker], tickerRows(0, SYMBOLS), [], store); + for (let tick = 1; tick <= TICKS; tick++) { + if (streaming) { + store = normalize( + [Ticker], + tickerRows(tick, TICKERS_PER_TICK), + [], + store, + ); + } + memo.denormalize(schema, input, store.entities); + } + // The store keeps the endpoint's normalized result alive, and the memo is keyed by it. + return { memo, store, input }; +} + +/** A 100-row tape that prepends prints on each flush. */ +function tradeTape() { + const memo = new MemoCache(); + let store = normalize([Trade], []); + let rows = []; + let next = 0; + for (let flush = 0; flush < FLUSHES; flush++) { + const prints = []; + for (let i = 0; i < PRINTS_PER_FLUSH; i++) + prints.unshift({ a: next, p: next++ }); + rows = [...prints, ...rows].slice(0, TAPE); + store = normalize([Trade], rows, [], store); + memo.denormalize([Trade], store.result, store.entities); + } + return { memo, store }; +} + +const scenarios = [ + { + name: `exchangeInfo: ${SYMBOLS} symbols join tickers, ${TICKERS_PER_TICK} tickers x ${TICKS} ticks`, + fn: () => exchangeInfo(MarketSymbol, true), + }, + { + name: `exchangeInfo: ${SYMBOLS} symbols, no join, ${TICKS} reads`, + fn: () => exchangeInfo(PlainSymbol, false), + }, + { + name: `trade tape: ${TAPE} rows, ${PRINTS_PER_FLUSH} prints x ${FLUSHES} flushes`, + fn: tradeTape, + }, +].filter(s => !process.argv[2] || s.name.includes(process.argv[2])); + +const rows = []; +const baseline = replayHermesMarking(); +rows.push({ + scenario: 'baseline (Node and the bundle, nothing cached)', + 'WeakMaps made': created, + 'entries set': entriesSet, + 'live entries': baseline.slots, + passes: baseline.passes, + 'Hermes visits/GC': baseline.visits, +}); +for (const { name, fn } of scenarios) { + gc(); + created = 0; + entriesSet = 0; + const start = performance.now(); + globalThis.kept = fn(); + const ms = performance.now() - start; + // One full collection with the run's garbage still in the heap. + const gcStart = performance.now(); + gc(); + const gcMs = performance.now() - gcStart; + const { slots, passes, visits } = replayHermesMarking(); + rows.push({ + scenario: name, + 'WeakMaps made': created, + 'entries set': entriesSet, + 'live entries': slots, + passes, + // Live slots only. Hermes also scans slots whose keys died since its + // last old-gen collection, up to passes x 'entries set'. + 'Hermes visits/GC': visits, + time: `${ms.toFixed(0)} ms`, + 'V8 full GC': `${gcMs.toFixed(1)} ms`, + }); + globalThis.kept = undefined; +} +console.table(rows); diff --git a/examples/benchmark/package.json b/examples/benchmark/package.json index b18f26c56b20..df5897f5eeb8 100644 --- a/examples/benchmark/package.json +++ b/examples/benchmark/package.json @@ -14,6 +14,7 @@ "build": "webpack --mode=production --target=node --env readable", "start": "NODE_ENV=production node --allow-natives-syntax ./index.js", "start:memory": "NODE_ENV=production node ./memory.js", + "start:ephemerons": "NODE_ENV=production node ./ephemerons.js", "start:trace": "yarn run start --trace_opt --trace_deopt", "start:deopt": "NODE_ENV=production npx dexnode --out v8.log --redirect-code-traces-to=/tmp/codetrace --allow-natives-syntax ./index.js" }, diff --git a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts index 21e945ed469e..ca4d939f822e 100644 --- a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts +++ b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts @@ -91,6 +91,43 @@ describe('WeakDependencyMap', () => { expect(wem.get({}, getEntity)[0]).toBeUndefined(); }); + it('hits and misses on a long dependency list', () => { + const items = Array.from({ length: 5000 }, (_, i) => ({ i })); + const big = Object.fromEntries(items.map((v, i) => [`${i}`, v])); + const deps = [ + depA, + ...items.map((entity, i) => ({ path: { key: 'D', pk: `${i}` }, entity })), + ]; + const wem = new WeakDependencyMap(); + wem.set(deps, 'all'); + + expect(wem.get(a, MemoPolicy.getEntities({ ...state, D: big }))[0]).toBe( + 'all', + ); + const changed = { ...big, '4999': { i: 4999 } }; + expect( + wem.get(a, MemoPolicy.getEntities({ ...state, D: changed }))[0], + ).toBeUndefined(); + }); + + it('keeps the previous value when the store flips back', () => { + const wem = new WeakDependencyMap(); + const b2 = [4, 5, 6]; + const b3 = [7, 8, 9]; + const withB = (entity: object) => + MemoPolicy.getEntities({ ...state, B: { '2': entity } }); + wem.set([depA, depB], 'b'); + wem.set([depA, { ...depB, entity: b2 }], 'b2'); + expect(wem.get(a, withB(b))[0]).toBe('b'); + expect(wem.get(a, withB(b2))[0]).toBe('b2'); + + // only the newest two per bucket stay + wem.set([depA, { ...depB, entity: b3 }], 'b3'); + expect(wem.get(a, withB(b3))[0]).toBe('b3'); + expect(wem.get(a, withB(b2))[0]).toBe('b2'); + expect(wem.get(a, withB(b))[0]).toBeUndefined(); + }); + it('considers empty key list invalid', () => { const wem = new WeakDependencyMap(); expect(() => wem.set([], 'whatever')).toThrowErrorMatchingInlineSnapshot( diff --git a/packages/normalizr/src/memo/WeakDependencyMap.ts b/packages/normalizr/src/memo/WeakDependencyMap.ts index 2d32c4daf78e..a0345d5a0277 100644 --- a/packages/normalizr/src/memo/WeakDependencyMap.ts +++ b/packages/normalizr/src/memo/WeakDependencyMap.ts @@ -4,29 +4,31 @@ import { UNDEF } from '../denormalize/UNDEF.js'; * via `typeof === 'function'`. */ export type KeyFn = (args: readonly any[]) => string | undefined; -/** Sentinel string used in place of `undefined` for string-keyed deps so we - * can perform Map lookups consistently. */ +/** Sentinel string used in place of `undefined` for string-keyed deps so + * equal `argsKey` results compare equal. */ const UNDEF_KEY = '\0'; /** Maps a (ordered) list of dependencies to a value. * * Useful as a memoization cache for flat/normalized stores. * - * Object dependencies are weakly referenced (via `WeakMap`), allowing - * automatic garbage collection when the dependency is no longer used. - * String-keyed dependencies (used by `argsKey`) sit on a `Map` keyed by the - * value returned from `path(args)`, branching on a stable function reference. + * Only the first dependency is a `WeakMap` key, so a cached value is + * collected with it. The rest are stored on the entry and compared by + * identity on `get`. Keeping one level of `WeakMap` matters: garbage + * collectors resolve nested `WeakMap`s one level per marking pass, and + * Hermes rescans every `WeakMap` entry in the heap on each pass, so a chain + * as long as the dependency list stalls every old-generation collection. + * + * String-keyed dependencies (used by `argsKey`) split entries into buckets + * by the value `path(args)` returns, so each bucket keeps its own value. */ export default class WeakDependencyMap< Path, K extends object = object, V = any, > { - private readonly next = new WeakMap>(); - private nextPath: Path | KeyFn | undefined = undefined; - /** Sticky: true once any function-typed (`argsKey`) dep has been stored. - * Lets `get` pick the entity-only fast path when no schema in this map - * uses `argsKey` — avoids a polymorphic `typeof` branch per walk step. */ + private readonly next = new WeakMap[]>(); + /** Sticky: true once any function-typed (`argsKey`) dep has been stored. */ private hasStr = false; get( @@ -34,39 +36,14 @@ export default class WeakDependencyMap< getDependency: GetDependency, args: readonly any[] = [], ) { - let curLink = this.next.get(entity); - if (!curLink) return EMPTY; - if (this.hasStr) return this._getMixed(curLink, getDependency, args); - while (curLink.nextPath) { - // we cannot perform lookups with `undefined`, so we use a special object to represent undefined - const nextDependency = getDependency(curLink.nextPath as Path) ?? UNDEF; - const nextLink = curLink.next.get(nextDependency as any); - if (!nextLink) return EMPTY; - curLink = nextLink; - } - // curLink exists, but has no path - so must have a value - return [curLink.value, curLink.journey] as readonly [V, Path[]]; - } - - /** Slow path: dep chain may interleave entity and `argsKey`-style deps. */ - private _getMixed( - curLink: Link, - getDependency: GetDependency, - args: readonly any[], - ) { - while (curLink.nextPath) { - let nextLink: Link | undefined; - if (typeof curLink.nextPath === 'function') { - const keyValue = (curLink.nextPath as KeyFn)(args) ?? UNDEF_KEY; - nextLink = curLink.nextStr?.get(keyValue); - } else { - const nextDependency = getDependency(curLink.nextPath as Path) ?? UNDEF; - nextLink = curLink.next.get(nextDependency as any); - } - if (!nextLink) return EMPTY; - curLink = nextLink; + const entries = this.next.get(entity); + if (!entries) return EMPTY; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (matches(entry, getDependency, args)) + return [entry.value, entry.journey] as readonly [V, Path[]]; } - return [curLink.value, curLink.journey] as readonly [V, Path[]]; + return EMPTY; } set( @@ -82,34 +59,44 @@ export default class WeakDependencyMap< journey?: Path[], ) { if (dependencies.length < 1) throw new KeySize(); - let curLink: Link = this as any; - for (const dep of dependencies) { - let nextLink: Link | undefined; + const paths = new Array(dependencies.length - 1); + const keys = new Array(dependencies.length - 1); + let bucket = ''; + for (let i = 1; i < dependencies.length; i++) { + const dep = dependencies[i]; + paths[i - 1] = dep.path; if (typeof dep.path === 'function') { this.hasStr = true; - if (!curLink.nextStr) curLink.nextStr = new Map(); const k = (dep.path as KeyFn)(args) ?? UNDEF_KEY; - nextLink = curLink.nextStr.get(k); - if (!nextLink) { - nextLink = new Link(); - curLink.nextStr.set(k, nextLink); - } + keys[i - 1] = k; + bucket += `${k}\u0001`; } else { - nextLink = curLink.next.get(dep.entity as K); - if (!nextLink) { - nextLink = new Link(); - // void members are represented as a symbol so we can lookup - curLink.next.set((dep.entity ?? UNDEF) as K, nextLink); - } + keys[i - 1] = dep.entity ?? UNDEF; } - curLink.nextPath = dep.path as any; - curLink = nextLink; } - // in case there used to be more - curLink.nextPath = undefined; - curLink.value = value; - // we could recompute this on get, but it would have a cost and we optimize for `get` - curLink.journey = journey ?? (dependencies.map(d => d.path) as Path[]); + const root = (dependencies[0].entity ?? UNDEF) as K; + const entry: Entry = { + paths, + keys, + bucket, + value, + // we could recompute this on get, but it would have a cost and we optimize for `get` + journey: journey ?? (dependencies.map(d => d.path) as Path[]), + }; + const entries = this.next.get(root); + if (!entries) { + this.next.set(root, [entry]); + return; + } + entries.unshift(entry); + // Keep the newest few per bucket, so a store that flips back (an + // optimistic update rolled back) still hits. Older ones only pin stale + // entities. + let kept = 0; + for (let i = 0; i < entries.length; i++) { + if (entries[i].bucket !== bucket) continue; + if (++kept > VARIANTS) entries.splice(i--, 1); + } } /** True once any `argsKey`-style dep has been written. Consumers can use @@ -119,6 +106,23 @@ export default class WeakDependencyMap< } } +function matches( + entry: Entry, + getDependency: GetDependency, + args: readonly any[], +): boolean { + const { paths, keys } = entry; + for (let i = 0; i < paths.length; i++) { + const path = paths[i]; + if (typeof path === 'function') { + if (((path as KeyFn)(args) ?? UNDEF_KEY) !== keys[i]) return false; + } else if ((getDependency(path as Path) ?? UNDEF) !== keys[i]) { + return false; + } + } + return true; +} + export type GetDependency = ( lookup: Path, ) => K | undefined; @@ -130,14 +134,18 @@ export interface Dep { const EMPTY = [undefined, undefined] as const; -/** Link in a chain */ -class Link { - next = new WeakMap>(); - /** Lazily allocated branch for string-keyed (`argsKey`) deps. */ - nextStr: Map> | undefined = undefined; - nextPath: Path | KeyFn | undefined = undefined; - value: V | undefined = undefined; - journey: Path[] = []; +/** Entries kept per (first dependency, `argsKey` bucket). */ +const VARIANTS = 2; + +/** One cached value: the dependencies after the first, and what they resolved to. */ +interface Entry { + readonly paths: (Path | KeyFn)[]; + /** Entity reference (or `UNDEF`) for entity paths; `argsKey` result for function paths. */ + readonly keys: unknown[]; + /** `argsKey` results joined, so entries for other args stay cached. */ + readonly bucket: string; + readonly value: V; + readonly journey: Path[]; } class KeySize extends Error { From 3c43ac120bfa6a4122eb7211b9db6e6c6486d59a Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 24 Oct 2026 14:05:01 -0400 Subject: [PATCH 02/18] docs(blog): Note the Hermes memo GC fix Co-Authored-By: Claude Opus 5.5 (1M context) --- website/blog/2026-10-03-v0.19-batch-set.md | 1 + 1 file changed, 1 insertion(+) diff --git a/website/blog/2026-10-03-v0.19-batch-set.md b/website/blog/2026-10-03-v0.19-batch-set.md index 333ed9e206fd..69b02abb29a7 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -56,6 +56,7 @@ Coding agents also get a Claude Code plugin, Vue skills and llms.txt; see - Polling keeps the schema of endpoints given [pollFrequency](/rest/api/RestEndpoint#pollfrequency) with `.extend()`, so a polled [useLive()](/docs/api/useLive) list updates right away from mutations and optimistic updates instead of waiting for the next poll ([#4437](https://github.com/reactive/data-client/pull/4437)) - [DataProvider](/docs/api/DataProvider) only warns that it doesn't update during SSR when something fetches while the server renders, instead of on every server render, so pages that load data after mount (like Expo Router's static web output) stay quiet ([#4449](https://github.com/reactive/data-client/pull/4449)) - Server rendering with React 19 no longer logs "Uncaught suspense" when nothing suspended; React renders and discards fallbacks of large boundaries, and the warning now waits for a fallback that actually shows ([#4450](https://github.com/reactive/data-client/pull/4450)) +- React Native apps that keep reading large results while entities stream no longer freeze for seconds at a time; memoization had made each Hermes garbage collection rescan its `WeakMap`s once per cached dependency, and cached reads are now faster on every platform ([#4459](https://github.com/reactive/data-client/pull/4459)) - Vue `createDataClient()` is deprecated, since it's internal to [DataClientPlugin](/vue/api/DataClientPlugin); install the plugin with `app.use(DataClientPlugin, options)` instead of calling it directly ([#4193](https://github.com/reactive/data-client/pull/4193)) - Vue `renderDataCompose()`'s `waitForNextUpdate()` is deprecated, since it gives up silently after 1 second and lets a test pass while still suspended; `await result` instead, or `await allSettled()` after a change, which now also waits for fetches a prop change starts ([unit testing composables](/vue/guides/unit-testing-composables#renderdatacompose-api), [#4221](https://github.com/reactive/data-client/pull/4221)) From 9256ad90d0cd0ee1189ae10e6327e0523d15b158 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 24 Oct 2026 14:51:18 -0400 Subject: [PATCH 03/18] fix(normalizr): Keep re-read memo entries through pending transitions While a transition is pending, React keeps rendering the committed store between retries that each build new entities. Keeping only the two newest entries let those retries evict the committed result, so it was recomputed where the old chain hit. Now a full bucket drops the previous newest entry if nothing read it again, else the least recently used one, and hits move to the front. argsKey results now branch on a Map, so a read under one of many argument sets only checks that set's entries. Adds a ReactDOM test that counts memo misses on entity versions a render already read under a pending transition, and examples/benchmark/memo-tradeoffs.js for the worst cases (store flips, retained memory, many argsKey sets). Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/benchmark/memo-tradeoffs.js | 264 ++++++++++++++++++ examples/benchmark/package.json | 1 + .../src/__tests__/WeakDependencyMap.test.ts | 15 + .../normalizr/src/memo/WeakDependencyMap.ts | 96 ++++--- .../src/__tests__/concurrent-memo.web.tsx | 236 ++++++++++++++++ 5 files changed, 579 insertions(+), 33 deletions(-) create mode 100644 examples/benchmark/memo-tradeoffs.js create mode 100644 packages/react/src/__tests__/concurrent-memo.web.tsx diff --git a/examples/benchmark/memo-tradeoffs.js b/examples/benchmark/memo-tradeoffs.js new file mode 100644 index 000000000000..3502479d1bef --- /dev/null +++ b/examples/benchmark/memo-tradeoffs.js @@ -0,0 +1,264 @@ +/** + * Worst cases for the memo cache: where caching less could cost reads or memory. + * + * - store flips: one result read against K store snapshots in turn, as when + * overlapping optimistic updates or several pending transitions each render + * a different snapshot. Reports how many reads hit and time per read. + * - retained memory: heap the memo holds beyond the store itself, after a run + * of updates. Entity churn (every row replaced) is the worst case. + * - argsKey buckets: one list read under N different `argsKey` values (a + * portfolio app switching between N portfolios), all cached. + * + * Local-only; not part of CI benchmark tracking. Run with: + * yarn workspace example-benchmark start:memo-tradeoffs [filter] + */ +import vm from 'node:vm'; +import v8 from 'v8'; + +import { Entity, normalize, initialState, MemoCache } from './dist/index.js'; +import { StockSchema, buildStockData } from './schemas.js'; + +v8.setFlagsFromString('--expose_gc'); +const gc = vm.runInNewContext('gc'); +const filter = process.argv[2]; + +function fullGC() { + gc(); + gc(); +} +const heapUsed = () => v8.getHeapStatistics().used_heap_size; + +/** µs per call, after warmup, over at least `ms` of wall time. */ +function timeit(fn, ms = 400) { + for (let i = 0; i < 20; i++) fn(i); + let n = 0; + const start = performance.now(); + let now = start; + while (now - start < ms) { + for (let j = 0; j < 20; j++) fn(n++); + now = performance.now(); + } + return ((now - start) * 1000) / n; +} + +const rows = []; +const want = name => !filter || name.includes(filter); + +// --- store flips ------------------------------------------------------------- + +const ROWS = 700; +const stockArgs = [{ portfolio: 'A' }]; +const meta = (i = 0) => ({ + fetchedAt: 1 + i, + date: 1 + i, + expiresAt: Infinity, +}); +const base = normalize( + StockSchema, + buildStockData(ROWS), + stockArgs, + initialState, + meta(), +); + +/** Snapshot i changes one row's price, like one more optimistic update. */ +function snapshots(k) { + const out = [base]; + for (let i = 1; i < k; i++) { + const prev = out[i - 1]; + const id = `s-${i % ROWS}`; + out.push({ + ...prev, + entities: { + ...prev.entities, + Stock: { + ...prev.entities.Stock, + [id]: { ...prev.entities.Stock[id], name: `rename ${i}` }, + }, + }, + }); + } + return out; +} + +for (const k of [1, 2, 3, 4, 8]) { + const name = `store flips: ${ROWS}-row list read against ${k} snapshots in turn`; + if (!want(name)) continue; + const states = snapshots(k); + const memo = new MemoCache(); + const last = new Array(k); + let hits = 0; + let reads = 0; + const read = i => { + const s = i % k; + const { data } = memo.denormalize( + StockSchema, + base.result, + states[s].entities, + stockArgs, + ); + if (data === last[s]) hits++; + last[s] = data; + reads++; + }; + // fill every snapshot once so later reads can hit + for (let i = 0; i < k; i++) read(i); + hits = 0; + reads = 0; + const us = timeit(read); + rows.push({ + scenario: name, + 'µs/read': us.toFixed(1), + 'hit rate': `${((hits / reads) * 100).toFixed(0)}%`, + }); +} + +// --- retained memory ---------------------------------------------------------- + +class Ticker extends Entity { + symbol = ''; + last = 0; + pk() { + return this.symbol; + } + + static key = 'Ticker'; +} +class MarketSymbol extends Entity { + symbol = ''; + baseAsset = ''; + quoteAsset = ''; + ticker = undefined; + pk() { + return this.symbol; + } + + static key = 'Symbol'; + static schema = { ticker: Ticker }; + static process(input) { + return { ...input, ticker: input.symbol }; + } +} +class Row extends Entity { + id = 0; + name = ''; + value = 0; + pk() { + return `${this.id}`; + } + + static key = 'Row'; +} + +const SYMBOLS = 3736; + +/** + * Heap held by the memo: used heap with memo and store alive, minus used heap + * with only the store alive. `run(memo)` returns the latest store. + */ +function retained(run) { + fullGC(); + const live = { memo: new MemoCache() }; + live.store = run(live.memo); + fullGC(); + const withMemo = heapUsed(); + delete live.memo; + fullGC(); + const storeOnly = heapUsed(); + delete live.store; + return withMemo - storeOnly; +} + +const symbolRows = Array.from({ length: SYMBOLS }, (_, i) => ({ + symbol: `S${i}USDT`, + baseAsset: `S${i}`, + quoteAsset: 'USDT', +})); +const tickerRows = (tick, count) => + Array.from({ length: count }, (_, i) => ({ + symbol: `S${(tick * 7919 + i * 31) % SYMBOLS}USDT`, + last: tick + i, + })); + +const memoryCases = [ + { + name: `retained: ${SYMBOLS} symbols join tickers, 500 tickers x 60 ticks`, + run(memo) { + const schema = { symbols: [MarketSymbol] }; + let store = normalize(schema, { symbols: symbolRows }); + const input = store.result; + store = normalize([Ticker], tickerRows(0, SYMBOLS), [], store); + for (let tick = 1; tick <= 60; tick++) { + store = normalize([Ticker], tickerRows(tick, 500), [], store); + memo.denormalize(schema, input, store.entities); + } + store.input = input; + return store; + }, + }, + ...[1000, 10000].map(n => ({ + name: `retained: ${n}-row list refetched (every row replaced) x 20`, + run(memo) { + let store; + for (let t = 0; t < 20; t++) { + const data = Array.from({ length: n }, (_, id) => ({ + id, + name: `row ${id}`, + value: t, + })); + store = normalize([Row], data, [], store ?? undefined); + memo.denormalize([Row], store.result, store.entities); + } + return store; + }, + })), +]; +for (const { name, run } of memoryCases) { + if (!want(name)) continue; + const bytes = retained(run); + rows.push({ + scenario: name, + 'memo retains': `${(bytes / 1024 / 1024).toFixed(2)} MB`, + }); +} + +// --- argsKey buckets ------------------------------------------------------------ + +for (const n of [1, 10, 100, 1000]) { + const name = `argsKey buckets: ${ROWS}-row Scalar list read under ${n} portfolios in turn`; + if (!want(name)) continue; + const portfolios = Array.from({ length: n }, (_, i) => [ + { portfolio: `p${i}` }, + ]); + let store = base; + for (const args of portfolios) { + store = normalize(StockSchema, buildStockData(ROWS), args, store, meta()); + } + const memo = new MemoCache(); + const last = new Array(n); + let hits = 0; + let reads = 0; + const read = i => { + const p = i % n; + const { data } = memo.denormalize( + StockSchema, + store.result, + store.entities, + portfolios[p], + ); + if (data === last[p]) hits++; + last[p] = data; + reads++; + }; + for (let i = 0; i < n; i++) read(i); + hits = 0; + reads = 0; + const us = timeit(read, 800); + rows.push({ + scenario: name, + 'µs/read': us.toFixed(1), + 'hit rate': `${((hits / reads) * 100).toFixed(0)}%`, + }); +} + +console.table(rows); diff --git a/examples/benchmark/package.json b/examples/benchmark/package.json index df5897f5eeb8..469ac1859a84 100644 --- a/examples/benchmark/package.json +++ b/examples/benchmark/package.json @@ -15,6 +15,7 @@ "start": "NODE_ENV=production node --allow-natives-syntax ./index.js", "start:memory": "NODE_ENV=production node ./memory.js", "start:ephemerons": "NODE_ENV=production node ./ephemerons.js", + "start:memo-tradeoffs": "NODE_ENV=production node ./memo-tradeoffs.js", "start:trace": "yarn run start --trace_opt --trace_deopt", "start:deopt": "NODE_ENV=production npx dexnode --out v8.log --redirect-code-traces-to=/tmp/codetrace --allow-natives-syntax ./index.js" }, diff --git a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts index ca4d939f822e..f4e446899125 100644 --- a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts +++ b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts @@ -128,6 +128,21 @@ describe('WeakDependencyMap', () => { expect(wem.get(a, withB(b))[0]).toBeUndefined(); }); + it('keeps a re-read value while one-off values come and go', () => { + // a pending transition: React re-reads the committed store between + // retries that each build new entities + const wem = new WeakDependencyMap(); + const withB = (entity: object) => + MemoPolicy.getEntities({ ...state, B: { '2': entity } }); + wem.set([depA, depB], 'committed'); + for (let i = 0; i < 5; i++) { + const retry = [i]; + expect(wem.get(a, withB(retry))[0]).toBeUndefined(); + wem.set([depA, { ...depB, entity: retry }], `retry ${i}`); + expect(wem.get(a, withB(b))[0]).toBe('committed'); + } + }); + it('considers empty key list invalid', () => { const wem = new WeakDependencyMap(); expect(() => wem.set([], 'whatever')).toThrowErrorMatchingInlineSnapshot( diff --git a/packages/normalizr/src/memo/WeakDependencyMap.ts b/packages/normalizr/src/memo/WeakDependencyMap.ts index a0345d5a0277..2b50e447f1cd 100644 --- a/packages/normalizr/src/memo/WeakDependencyMap.ts +++ b/packages/normalizr/src/memo/WeakDependencyMap.ts @@ -4,8 +4,8 @@ import { UNDEF } from '../denormalize/UNDEF.js'; * via `typeof === 'function'`. */ export type KeyFn = (args: readonly any[]) => string | undefined; -/** Sentinel string used in place of `undefined` for string-keyed deps so - * equal `argsKey` results compare equal. */ +/** Sentinel string used in place of `undefined` for string-keyed deps so we + * can perform Map lookups consistently. */ const UNDEF_KEY = '\0'; /** Maps a (ordered) list of dependencies to a value. @@ -19,15 +19,16 @@ const UNDEF_KEY = '\0'; * Hermes rescans every `WeakMap` entry in the heap on each pass, so a chain * as long as the dependency list stalls every old-generation collection. * - * String-keyed dependencies (used by `argsKey`) split entries into buckets - * by the value `path(args)` returns, so each bucket keeps its own value. + * String-keyed dependencies (used by `argsKey`) branch on a `Map` keyed by + * the value `path(args)` returns, so each argument set keeps its own entries + * and a lookup only checks its own. */ export default class WeakDependencyMap< Path, K extends object = object, V = any, > { - private readonly next = new WeakMap[]>(); + private readonly next = new WeakMap>(); /** Sticky: true once any function-typed (`argsKey`) dep has been stored. */ private hasStr = false; @@ -36,12 +37,22 @@ export default class WeakDependencyMap< getDependency: GetDependency, args: readonly any[] = [], ) { - const entries = this.next.get(entity); - if (!entries) return EMPTY; - for (let i = 0; i < entries.length; i++) { - const entry = entries[i]; - if (matches(entry, getDependency, args)) - return [entry.value, entry.journey] as readonly [V, Path[]]; + let node = this.next.get(entity); + while (node) { + const { entries } = node; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (matches(entry, getDependency, args)) { + entry.hit = true; + if (i > 0) { + entries.splice(i, 1); + entries.unshift(entry); + } + return [entry.value, entry.journey] as readonly [V, Path[]]; + } + } + if (!node.fn) break; + node = node.children?.get(node.fn(args) ?? UNDEF_KEY); } return EMPTY; } @@ -59,9 +70,14 @@ export default class WeakDependencyMap< journey?: Path[], ) { if (dependencies.length < 1) throw new KeySize(); + const root = (dependencies[0].entity ?? UNDEF) as K; + let node = this.next.get(root); + if (!node) { + node = newNode(); + this.next.set(root, node); + } const paths = new Array(dependencies.length - 1); const keys = new Array(dependencies.length - 1); - let bucket = ''; for (let i = 1; i < dependencies.length; i++) { const dep = dependencies[i]; paths[i - 1] = dep.path; @@ -69,33 +85,35 @@ export default class WeakDependencyMap< this.hasStr = true; const k = (dep.path as KeyFn)(args) ?? UNDEF_KEY; keys[i - 1] = k; - bucket += `${k}\u0001`; + node.fn = dep.path as KeyFn; + if (!node.children) node.children = new Map(); + let child = node.children.get(k); + if (!child) { + child = newNode(); + node.children.set(k, child); + } + node = child; } else { keys[i - 1] = dep.entity ?? UNDEF; } } - const root = (dependencies[0].entity ?? UNDEF) as K; - const entry: Entry = { + const { entries } = node; + entries.unshift({ paths, keys, - bucket, value, // we could recompute this on get, but it would have a cost and we optimize for `get` journey: journey ?? (dependencies.map(d => d.path) as Path[]), - }; - const entries = this.next.get(root); - if (!entries) { - this.next.set(root, [entry]); - return; - } - entries.unshift(entry); - // Keep the newest few per bucket, so a store that flips back (an - // optimistic update rolled back) still hits. Older ones only pin stale - // entities. - let kept = 0; - for (let i = 0; i < entries.length; i++) { - if (entries[i].bucket !== bucket) continue; - if (++kept > VARIANTS) entries.splice(i--, 1); + hit: false, + }); + // Two entries per argument set, so a store that flips back still hits. + // A pending transition does that: React retries it with freshly built + // entities between urgent renders of the committed store. So when full, + // drop the previous newest if nothing read it again (a one-off), else the + // least recently used. + if (entries.length > VARIANTS) { + if (!entries[1].hit) entries.splice(1, 1); + else entries.length = VARIANTS; } } @@ -134,18 +152,30 @@ export interface Dep { const EMPTY = [undefined, undefined] as const; -/** Entries kept per (first dependency, `argsKey` bucket). */ +/** Entries kept per (first dependency, `argsKey` results). */ const VARIANTS = 2; +/** Entries for one set of `argsKey` results, and the branch on the next one. */ +interface Node { + entries: Entry[]; + /** The next `argsKey` dependency; its result picks the child. */ + fn: KeyFn | undefined; + children: Map> | undefined; +} + +function newNode(): Node { + return { entries: [], fn: undefined, children: undefined }; +} + /** One cached value: the dependencies after the first, and what they resolved to. */ interface Entry { readonly paths: (Path | KeyFn)[]; /** Entity reference (or `UNDEF`) for entity paths; `argsKey` result for function paths. */ readonly keys: unknown[]; - /** `argsKey` results joined, so entries for other args stay cached. */ - readonly bucket: string; readonly value: V; readonly journey: Path[]; + /** Read again since it was set. */ + hit: boolean; } class KeySize extends Error { diff --git a/packages/react/src/__tests__/concurrent-memo.web.tsx b/packages/react/src/__tests__/concurrent-memo.web.tsx new file mode 100644 index 000000000000..71d27a4bde2d --- /dev/null +++ b/packages/react/src/__tests__/concurrent-memo.web.tsx @@ -0,0 +1,236 @@ +import { Endpoint, Entity } from '@data-client/endpoint'; +import { + DataProvider, + getDefaultManagers, + useController, + useSuspense, +} from '@data-client/react'; +import { mockInitialState } from '@data-client/test'; +import { act, fireEvent, render } from '@testing-library/react'; +import { Todo, type TodoItem } from '__tests__/concurrentFixtures'; +import React, { Suspense, useState, useTransition, version } from 'react'; + +import useCacheState from '../hooks/useCacheState'; + +const LegacyReact = version.startsWith('16') || version.startsWith('17'); +const describeConcurrent = LegacyReact ? describe.skip : describe; + +class Note extends Entity { + id = ''; + text = ''; +} + +const ROWS = 300; +const rows = (): TodoItem[] => + Array.from({ length: ROWS }, (_, i) => ({ id: `${i}`, title: `${i}` })); + +const getTodoList = new Endpoint(() => Promise.resolve(rows()), { + schema: [Todo], + name: 'getTodoList', +}); +const getTodo = new Endpoint( + ({ id }: { id: string }) => Promise.resolve({ id, title: id }), + { schema: Todo, name: 'getTodo' }, +); +const getNote = new Endpoint( + ({ id }: { id: string }) => Promise.resolve({ id, text: id }), + { schema: Note, name: 'getNote' }, +); +const resolvers: Record void> = {}; +const getDetail = new Endpoint( + ({ id }: { id: string }) => + new Promise(resolve => { + resolvers[id] = resolve; + }), + { schema: Todo, name: 'getDetail' }, +); +const noDevManagers = getDefaultManagers({ devToolsManager: null }); + +/** Stable small ids for objects, so a render can name the entity versions it read. */ +const ids = new WeakMap(); +let nextId = 0; +const idOf = (o: object | undefined) => { + if (!o) return -1; + let id = ids.get(o); + if (id === undefined) { + id = nextId++; + ids.set(o, id); + } + return id; +}; + +/** + * Every render of the list: which entity versions it read, and the result it + * got back. A render that reads versions an earlier render already read, but + * gets a different result, is a miss that caching more would have avoided. + */ +type ListRender = { versions: string; data: unknown }; +let renders: ListRender[] = []; + +function List() { + const todos = useSuspense(getTodoList); + const state = useCacheState(); + const table = (state.entities[Todo.key] ?? {}) as Record; + renders.push({ + versions: todos.map(todo => idOf(table[todo.id])).join(','), + data: todos, + }); + return
{todos.length}
; +} + +function Detail({ id }: { id: string }) { + const todo = useSuspense(getDetail, { id }); + return
{todo.title}
; +} + +let controller: ReturnType; +function App() { + controller = useController(); + const [detail, setDetail] = useState(null); + const [text, setText] = useState(''); + const [isPending, start] = useTransition(); + return ( +
+ {String(isPending)} + setText(e.target.value)} + /> + + loading list

}> + +
+ {/* already revealed, so a transition that suspends here stays pending */} + loading detail

}> + {detail ? : null} +
+
+ ); +} + +function avoidableMisses(list: ListRender[]) { + const seen = new Map(); + let misses = 0; + for (const { versions, data } of list) { + if (seen.has(versions) && seen.get(versions) !== data) misses++; + seen.set(versions, data); + } + return misses; +} + +/** How often a render read versions it had read before, after reading others. */ +function flipBacks(list: ListRender[]) { + const seen = new Set(); + let last: string | undefined; + let flips = 0; + for (const { versions } of list) { + if (versions !== last && seen.has(versions)) flips++; + seen.add(versions); + last = versions; + } + return flips; +} + +function trace(name: string) { + if (!process.env.MEMO_TRACE) return; + const label = new Map(); + const seq = renders.map(r => { + if (!label.has(r.versions)) label.set(r.versions, label.size); + return `v${label.get(r.versions)}:d${idOf(r.data as object)}`; + }); + console.log( + `${name}: ${renders.length} renders, ${label.size} versions, ${flipBacks(renders)} flip-backs, ${avoidableMisses(renders)} avoidable misses\n${seq.join(' ')}`, + ); +} + +/** Mount, start a transition that stays pending, run `urgent` five times, then let it commit. */ +async function runPendingTransition(urgent: (i: number) => void) { + const { getByTestId } = render( + + + , + ); + expect(getByTestId('count').textContent).toBe(`${ROWS}`); + act(() => { + fireEvent.click(getByTestId('open')); + }); + expect(getByTestId('pending').textContent).toBe('true'); + for (let i = 0; i < 5; i++) { + act(() => urgent(i)); + expect(getByTestId('pending').textContent).toBe('true'); + } + await act(async () => { + resolvers.X({ id: 'X', title: 'detail' }); + }); + expect(getByTestId('detail').textContent).toBe('detail'); + expect(getByTestId('pending').textContent).toBe('false'); +} + +describeConcurrent('memoization under concurrent rendering', () => { + beforeEach(() => { + renders = []; + for (const key of Object.keys(resolvers)) delete resolvers[key]; + }); + + // While a transition is pending, React renders the urgent lane without its + // update and retries the transition with it, so the list alternates between + // the committed rows and the transition's rows. + + it('urgent re-renders during a pending transition reuse the committed result', async () => { + await runPendingTransition(i => { + fireEvent.change(document.querySelector('input')!, { + target: { value: `typed ${i}` }, + }); + }); + trace('typing'); + expect(flipBacks(renders)).toBeGreaterThan(0); + expect(avoidableMisses(renders)).toBe(0); + }); + + it('urgent writes to other entities during a pending transition reuse the committed result', async () => { + await runPendingTransition(i => { + controller.setResponse(getNote, { id: `${i}` }, { + id: `${i}`, + text: `note ${i}`, + }); + }); + trace('other entity writes'); + expect(flipBacks(renders)).toBeGreaterThan(0); + expect(avoidableMisses(renders)).toBe(0); + }); + + it('urgent writes to a listed row during a pending transition', async () => { + // React reapplies every update queued after the transition's on each + // render, so each render reads new rows and none can hit. + await runPendingTransition(i => { + controller.setResponse(getTodo, { id: '7' }, { + id: '7', + title: `urgent ${i}`, + }); + }); + trace('listed row writes'); + expect(avoidableMisses(renders)).toBe(0); + }); +}); From 3c831cd6e330e5ec496af3aa3d81a103ccebe83c Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 24 Oct 2026 15:04:47 -0400 Subject: [PATCH 04/18] bench: Measure memo retention without keeping the memo reachable Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/benchmark/memo-tradeoffs.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/examples/benchmark/memo-tradeoffs.js b/examples/benchmark/memo-tradeoffs.js index 3502479d1bef..f54e2d2c0ee2 100644 --- a/examples/benchmark/memo-tradeoffs.js +++ b/examples/benchmark/memo-tradeoffs.js @@ -158,14 +158,17 @@ const SYMBOLS = 3736; */ function retained(run) { fullGC(); - const live = { memo: new MemoCache() }; - live.store = run(live.memo); - fullGC(); - const withMemo = heapUsed(); - delete live.memo; + let store; + // the memo is only reachable inside this call + const withMemo = (() => { + const memo = new MemoCache(); + store = run(memo); + fullGC(); + return heapUsed(); + })(); fullGC(); const storeOnly = heapUsed(); - delete live.store; + void store; return withMemo - storeOnly; } From 89c682afcbf8b17d04e4e495d9f0f97cece746b1 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 24 Oct 2026 15:47:05 -0400 Subject: [PATCH 05/18] perf(normalizr): Skip memo node work on the cold path Writes for a dependency with no argsKey create the node with its entry in one step, and a full bucket replaces entries in place instead of unshift and splice. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../normalizr/src/memo/WeakDependencyMap.ts | 42 +++++++++++-------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/packages/normalizr/src/memo/WeakDependencyMap.ts b/packages/normalizr/src/memo/WeakDependencyMap.ts index 2b50e447f1cd..110c9a37961b 100644 --- a/packages/normalizr/src/memo/WeakDependencyMap.ts +++ b/packages/normalizr/src/memo/WeakDependencyMap.ts @@ -72,10 +72,6 @@ export default class WeakDependencyMap< if (dependencies.length < 1) throw new KeySize(); const root = (dependencies[0].entity ?? UNDEF) as K; let node = this.next.get(root); - if (!node) { - node = newNode(); - this.next.set(root, node); - } const paths = new Array(dependencies.length - 1); const keys = new Array(dependencies.length - 1); for (let i = 1; i < dependencies.length; i++) { @@ -85,11 +81,15 @@ export default class WeakDependencyMap< this.hasStr = true; const k = (dep.path as KeyFn)(args) ?? UNDEF_KEY; keys[i - 1] = k; + if (!node) { + node = newNode(undefined); + this.next.set(root, node); + } node.fn = dep.path as KeyFn; if (!node.children) node.children = new Map(); let child = node.children.get(k); if (!child) { - child = newNode(); + child = newNode(undefined); node.children.set(k, child); } node = child; @@ -97,24 +97,30 @@ export default class WeakDependencyMap< keys[i - 1] = dep.entity ?? UNDEF; } } - const { entries } = node; - entries.unshift({ + const entry: Entry = { paths, keys, value, // we could recompute this on get, but it would have a cost and we optimize for `get` journey: journey ?? (dependencies.map(d => d.path) as Path[]), hit: false, - }); + }; + if (!node) { + this.next.set(root, newNode(entry)); + return; + } // Two entries per argument set, so a store that flips back still hits. // A pending transition does that: React retries it with freshly built // entities between urgent renders of the committed store. So when full, // drop the previous newest if nothing read it again (a one-off), else the // least recently used. - if (entries.length > VARIANTS) { - if (!entries[1].hit) entries.splice(1, 1); - else entries.length = VARIANTS; + const { entries } = node; + if (entries.length === 0) { + entries.push(entry); + return; } + if (entries.length === 1 || entries[0].hit) entries[1] = entries[0]; + entries[0] = entry; } /** True once any `argsKey`-style dep has been written. Consumers can use @@ -152,10 +158,8 @@ export interface Dep { const EMPTY = [undefined, undefined] as const; -/** Entries kept per (first dependency, `argsKey` results). */ -const VARIANTS = 2; - -/** Entries for one set of `argsKey` results, and the branch on the next one. */ +/** Entries for one set of `argsKey` results (at most two, most recent first), + * and the branch on the next one. */ interface Node { entries: Entry[]; /** The next `argsKey` dependency; its result picks the child. */ @@ -163,8 +167,12 @@ interface Node { children: Map> | undefined; } -function newNode(): Node { - return { entries: [], fn: undefined, children: undefined }; +function newNode(entry: Entry | undefined): Node { + return { + entries: entry ? [entry] : [], + fn: undefined, + children: undefined, + }; } /** One cached value: the dependencies after the first, and what they resolved to. */ From 2a101501e75e8db7718333cf9941b2132a9744c2 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 24 Oct 2026 16:05:52 -0400 Subject: [PATCH 06/18] docs(bench): Describe the memo worst-case script Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/benchmark/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/examples/benchmark/README.md b/examples/benchmark/README.md index e4d67acc73d1..999f95b80bc9 100644 --- a/examples/benchmark/README.md +++ b/examples/benchmark/README.md @@ -109,6 +109,17 @@ So a structure that V8 collects in milliseconds can stall Hermes for seconds: one pass per level of nesting, and every pass rescans every `WeakMap` entry in the app, not only the memo's own. Keep `WeakMap` nesting shallow. +### Memo cache worst cases + +```bash +yarn start:memo-tradeoffs [filter] +``` + +Measures where caching fewer versions could cost reads or memory: one list +read against several store snapshots in turn (hit rate and time per read), +heap the memo keeps beyond the store after a run of updates, and one list read +under many `argsKey` values. Local-only — not tracked in CI. + ### Profiling For opt/deopt investigation: From 17a35d5651df014ebb55f5331762a8ad5a90be7e Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 24 Oct 2026 17:33:54 -0400 Subject: [PATCH 07/18] fix(normalizr): Keep three memo entries and the one the store flips back to The two-entry rules could not replace a stale entry once the store moved on: after the first commit that changed a result, a newly committed version was evicted by the next one-off render. Each node now keeps up to three entries, most recently used first, and never evicts the entry most recently read back after a newer one (the first entry, until that happens). Adds concurrent-memo scenarios across store epochs, reads outside render, and two readers of the same endpoint, and eviction unit tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/__tests__/WeakDependencyMap.test.ts | 127 +++++- .../normalizr/src/memo/WeakDependencyMap.ts | 37 +- .../src/__tests__/concurrent-memo.web.tsx | 396 +++++++++++++----- 3 files changed, 432 insertions(+), 128 deletions(-) diff --git a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts index f4e446899125..8c3dbbc47ff9 100644 --- a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts +++ b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts @@ -110,22 +110,30 @@ describe('WeakDependencyMap', () => { ).toBeUndefined(); }); - it('keeps the previous value when the store flips back', () => { + it('keeps three entries, never evicting the one the store flipped back to', () => { const wem = new WeakDependencyMap(); - const b2 = [4, 5, 6]; - const b3 = [7, 8, 9]; + const [b2, b3, b4, b5] = [[2], [3], [4], [5]]; const withB = (entity: object) => MemoPolicy.getEntities({ ...state, B: { '2': entity } }); - wem.set([depA, depB], 'b'); - wem.set([depA, { ...depB, entity: b2 }], 'b2'); + const setB = (entity: object, value: string) => + wem.set([depA, { ...depB, entity }], value); + setB(b, 'b'); + setB(b2, 'b2'); + setB(b3, 'b3'); + // full: the least recently used goes, except the first entry, kept until a flip + setB(b4, 'b4'); + expect(wem.get(a, withB(b2))[0]).toBeUndefined(); expect(wem.get(a, withB(b))[0]).toBe('b'); - expect(wem.get(a, withB(b2))[0]).toBe('b2'); - // only the newest two per bucket stay - wem.set([depA, { ...depB, entity: b3 }], 'b3'); + // reading b3 back after newer entries makes it the kept one instead; + // most recently used now: b3, b, b4 expect(wem.get(a, withB(b3))[0]).toBe('b3'); - expect(wem.get(a, withB(b2))[0]).toBe('b2'); + setB(b5, 'b5'); + expect(wem.get(a, withB(b4))[0]).toBeUndefined(); + setB(b4, 'b4 again'); + // most recently used: b4, b5, b3 (kept), b; b goes expect(wem.get(a, withB(b))[0]).toBeUndefined(); + expect(wem.get(a, withB(b3))[0]).toBe('b3'); }); it('keeps a re-read value while one-off values come and go', () => { @@ -237,3 +245,104 @@ describe('WeakDependencyMap', () => { }); }); }); + +describe('WeakDependencyMap eviction', () => { + const root = { root: true }; + const rootDep = { path: { key: 'A', pk: '1' }, entity: root }; + const dep = (entity: object) => ({ path: { key: 'B', pk: '1' }, entity }); + const withB = (entity: object) => + MemoPolicy.getEntities({ A: { '1': root }, B: { '1': entity } }); + + it('keeps the committed version after the store moves on to a new one (two epochs)', () => { + const wem = new WeakDependencyMap(); + const S0 = { v: 'S0' }; + wem.set([rootDep, dep(S0)], 'S0'); + // epoch 1: pending transition; retries build fresh entities, urgent + // renders re-read S0. This is the pattern the existing tests cover. + for (let i = 0; i < 2; i++) { + const x = { v: `x${i}` }; + expect(wem.get(root, withB(x))[0]).toBeUndefined(); + wem.set([rootDep, dep(x)], `x${i}`); + expect(wem.get(root, withB(S0))[0]).toBe('S0'); + } + // the transition commits: its final render's entities ARE the new store + const S1 = { v: 'S1' }; + wem.set([rootDep, dep(S1)], 'S1'); + expect(wem.get(root, withB(S1))[0]).toBe('S1'); + // epoch 2: another pending transition builds a fresh entity... + wem.set([rootDep, dep({ v: 'y0' })], 'y0'); + // ...and an urgent render re-reads the committed store. S1 is alive and + // was read moments ago; the old chain would hit. S0 is dead but pinned. + expect(wem.get(root, withB(S1))[0]).toBe('S1'); + }); + + it('a stale entry does not crowd out a version that keeps being read', () => { + const wem = new WeakDependencyMap(); + const dead = { v: 'dead' }; + const S = { v: 'S' }; + wem.set([rootDep, dep(dead)], 'dead'); + wem.set([rootDep, dep(S)], 'S'); + // alternate one-offs with re-reads of S many times; S should earn its keep + let misses = 0; + for (let i = 0; i < 10; i++) { + wem.set([rootDep, dep({ v: `x${i}` })], `x${i}`); + if (wem.get(root, withB(S))[0] === undefined) { + misses++; + wem.set([rootDep, dep(S)], 'S'); + } + } + expect(misses).toBe(0); + }); + + it('a stable version first read after two one-offs is retained', () => { + // e.g. a list whose first render is inside a pending transition that + // suspended once (render, ping, render) before any urgent render + const wem = new WeakDependencyMap(); + const S = { v: 'S' }; + wem.set([rootDep, dep({ v: 'x0' })], 'x0'); + wem.set([rootDep, dep({ v: 'x1' })], 'x1'); + wem.set([rootDep, dep(S)], 'S'); + wem.set([rootDep, dep({ v: 'x2' })], 'x2'); + expect(wem.get(root, withB(S))[0]).toBe('S'); + }); + + it('a different argsKey fn at the same position makes the other entry unreachable (false miss, not wrong data)', () => { + const wem = new WeakDependencyMap(); + const fnA = (args: readonly any[]) => args[0]?.userId; + const fnB = (args: readonly any[]) => args[0]?.team; + wem.set([rootDep, { path: fnA, entity: undefined }], 'A', [ + { userId: 'bob' }, + ]); + wem.set([rootDep, { path: fnB, entity: undefined }], 'B', [{ team: 'x' }]); + const getEntity = MemoPolicy.getEntities({ A: { '1': root } }); + // B is reachable; A only if fnB(args) happens to equal fnA(args) + expect(wem.get(root, getEntity, [{ team: 'x' }])[0]).toBe('B'); + expect(wem.get(root, getEntity, [{ userId: 'bob' }])[0]).toBeUndefined(); + // never the wrong value: an entry is validated against its own fn + expect( + wem.get(root, getEntity, [{ userId: 'bob', team: 'y' }])[0], + ).toBeUndefined(); + }); + + it('a shorter dependency list at the same root is served when the longer one mismatches', () => { + // each entry is checked against its own dependencies, so the older one + // still serves reads that never touch C + const wem = new WeakDependencyMap(); + const b = { b: 1 }; + const c = { c: 1 }; + const depC = (entity: object | undefined) => ({ + path: { key: 'C', pk: '1' }, + entity, + }); + wem.set([rootDep, dep(b)], 'short'); + wem.set([rootDep, dep(b), depC(c)], 'long'); + const both = MemoPolicy.getEntities({ + A: { '1': root }, + B: { '1': b }, + C: { '1': c }, + }); + const noC = MemoPolicy.getEntities({ A: { '1': root }, B: { '1': b } }); + expect(wem.get(root, both)[0]).toBe('long'); + expect(wem.get(root, noC)[0]).toBe('short'); + }); +}); diff --git a/packages/normalizr/src/memo/WeakDependencyMap.ts b/packages/normalizr/src/memo/WeakDependencyMap.ts index 110c9a37961b..cd6e4b87aadd 100644 --- a/packages/normalizr/src/memo/WeakDependencyMap.ts +++ b/packages/normalizr/src/memo/WeakDependencyMap.ts @@ -43,8 +43,10 @@ export default class WeakDependencyMap< for (let i = 0; i < entries.length; i++) { const entry = entries[i]; if (matches(entry, getDependency, args)) { - entry.hit = true; if (i > 0) { + // read back after something newer was cached: the store flipped + // back to this version, so it is the one to keep + node.kept = entry; entries.splice(i, 1); entries.unshift(entry); } @@ -103,24 +105,23 @@ export default class WeakDependencyMap< value, // we could recompute this on get, but it would have a cost and we optimize for `get` journey: journey ?? (dependencies.map(d => d.path) as Path[]), - hit: false, }; if (!node) { this.next.set(root, newNode(entry)); return; } - // Two entries per argument set, so a store that flips back still hits. - // A pending transition does that: React retries it with freshly built - // entities between urgent renders of the committed store. So when full, - // drop the previous newest if nothing read it again (a one-off), else the - // least recently used. + // A store can flip back to a version it already showed: React renders the + // committed store between retries of a pending transition, which build new + // entities each time, and rolling back an optimistic update returns to the + // entities from before it. So beyond the newest entries, keep the one the + // store last flipped back to (or the first one, until a flip happens). const { entries } = node; - if (entries.length === 0) { - entries.push(entry); - return; + if (!node.kept) node.kept = entry; + entries.unshift(entry); + if (entries.length > MAX_ENTRIES) { + const last = entries.length - 1; + entries.splice(entries[last] === node.kept ? last - 1 : last, 1); } - if (entries.length === 1 || entries[0].hit) entries[1] = entries[0]; - entries[0] = entry; } /** True once any `argsKey`-style dep has been written. Consumers can use @@ -158,10 +159,15 @@ export interface Dep { const EMPTY = [undefined, undefined] as const; -/** Entries for one set of `argsKey` results (at most two, most recent first), - * and the branch on the next one. */ +/** Entries kept per (first dependency, `argsKey` results). */ +const MAX_ENTRIES = 3; + +/** Entries for one set of `argsKey` results, most recently used first, and the + * branch on the next one. */ interface Node { entries: Entry[]; + /** Never evicted: the entry most recently read back after a newer one. */ + kept: Entry | undefined; /** The next `argsKey` dependency; its result picks the child. */ fn: KeyFn | undefined; children: Map> | undefined; @@ -170,6 +176,7 @@ interface Node { function newNode(entry: Entry | undefined): Node { return { entries: entry ? [entry] : [], + kept: entry, fn: undefined, children: undefined, }; @@ -182,8 +189,6 @@ interface Entry { readonly keys: unknown[]; readonly value: V; readonly journey: Path[]; - /** Read again since it was set. */ - hit: boolean; } class KeySize extends Error { diff --git a/packages/react/src/__tests__/concurrent-memo.web.tsx b/packages/react/src/__tests__/concurrent-memo.web.tsx index 71d27a4bde2d..0f213791f4ff 100644 --- a/packages/react/src/__tests__/concurrent-memo.web.tsx +++ b/packages/react/src/__tests__/concurrent-memo.web.tsx @@ -6,12 +6,21 @@ import { useSuspense, } from '@data-client/react'; import { mockInitialState } from '@data-client/test'; -import { act, fireEvent, render } from '@testing-library/react'; -import { Todo, type TodoItem } from '__tests__/concurrentFixtures'; +import { act, render } from '@testing-library/react'; +import { Todo } from '__tests__/concurrentFixtures'; +import type { TodoItem } from '__tests__/concurrentFixtures'; import React, { Suspense, useState, useTransition, version } from 'react'; import useCacheState from '../hooks/useCacheState'; +/** + * Memoization under concurrent rendering. Every read of the list records the + * entity versions it read and the result it got. A read of versions an + * earlier read already saw, that gets a different result, is a miss that + * caching more would have avoided (the old dependency chain kept every + * version whose entities were alive). + */ + const LegacyReact = version.startsWith('16') || version.startsWith('17'); const describeConcurrent = LegacyReact ? describe.skip : describe; @@ -20,7 +29,7 @@ class Note extends Entity { text = ''; } -const ROWS = 300; +const ROWS = 50; const rows = (): TodoItem[] => Array.from({ length: ROWS }, (_, i) => ({ id: `${i}`, title: `${i}` })); @@ -36,17 +45,32 @@ const getNote = new Endpoint( ({ id }: { id: string }) => Promise.resolve({ id, text: id }), { schema: Note, name: 'getNote' }, ); -const resolvers: Record void> = {}; -const getDetail = new Endpoint( - ({ id }: { id: string }) => - new Promise(resolve => { - resolvers[id] = resolve; - }), - { schema: Todo, name: 'getDetail' }, +const pending: Record< + string, + { resolve: (value: TodoItem) => void; reject: (error: Error) => void } +> = {}; +const hold = (key: string) => + new Promise((resolve, reject) => { + pending[key] = { resolve, reject }; + }); +const getDetail = new Endpoint(({ id }: { id: string }) => hold(id), { + schema: Todo, + name: 'getDetail', +}); +const updateTodo = new Endpoint( + ({ id, title }: { id: string; title: string }) => + hold(`update ${id} ${title}`), + { + schema: Todo, + sideEffect: true, + name: 'updateTodo', + getOptimisticResponse(_snap, { id, title }) { + return { id, title }; + }, + }, ); const noDevManagers = getDefaultManagers({ devToolsManager: null }); -/** Stable small ids for objects, so a render can name the entity versions it read. */ const ids = new WeakMap(); let nextId = 0; const idOf = (o: object | undefined) => { @@ -59,21 +83,20 @@ const idOf = (o: object | undefined) => { return id; }; -/** - * Every render of the list: which entity versions it read, and the result it - * got back. A render that reads versions an earlier render already read, but - * gets a different result, is a miss that caching more would have avoided. - */ -type ListRender = { versions: string; data: unknown }; -let renders: ListRender[] = []; +/** Every read of the list: entity versions read, result identity, reader. */ +type Read = { versions: string; data: unknown; who: string }; +let reads: Read[] = []; +const versionsOf = (table: Record, todos: { id: string }[]) => + todos.map(todo => idOf(table[todo.id])).join(','); function List() { const todos = useSuspense(getTodoList); const state = useCacheState(); const table = (state.entities[Todo.key] ?? {}) as Record; - renders.push({ - versions: todos.map(todo => idOf(table[todo.id])).join(','), + reads.push({ + versions: versionsOf(table, todos), data: todos, + who: 'render', }); return
{todos.length}
; } @@ -84,47 +107,46 @@ function Detail({ id }: { id: string }) { } let controller: ReturnType; -function App() { +let startTransition: (fn: () => void) => void; +let showDetail: (id: string) => void; +let typeText: (text: string) => void; + +function App({ consumers }: { consumers: number }) { controller = useController(); - const [detail, setDetail] = useState(null); + const [detail, setDetail] = useState([]); const [text, setText] = useState(''); const [isPending, start] = useTransition(); + startTransition = start; + showDetail = id => setDetail(ids => [...ids, id]); + typeText = setText; return (
{String(isPending)} - setText(e.target.value)} - /> - + {text} loading list

}> - + {Array.from({ length: consumers }, (_, i) => ( + + ))}
- {/* already revealed, so a transition that suspends here stays pending */} loading detail

}> - {detail ? : null} + {detail.map(id => ( + + ))}
); } -function avoidableMisses(list: ListRender[]) { +/** A manager or event handler reading the committed store outside render. */ +function readRaw() { + const state = controller.getState(); + const { data } = controller.getResponse(getTodoList, state); + const table = (state.entities[Todo.key] ?? {}) as Record; + reads.push({ versions: versionsOf(table, data as any), data, who: 'raw' }); +} + +/** Reads of versions already read before, that got a different result. */ +function avoidableMisses(list: Read[]) { const seen = new Map(); let misses = 0; for (const { versions, data } of list) { @@ -134,8 +156,7 @@ function avoidableMisses(list: ListRender[]) { return misses; } -/** How often a render read versions it had read before, after reading others. */ -function flipBacks(list: ListRender[]) { +function flipBacks(list: Read[]) { const seen = new Set(); let last: string | undefined; let flips = 0; @@ -150,18 +171,17 @@ function flipBacks(list: ListRender[]) { function trace(name: string) { if (!process.env.MEMO_TRACE) return; const label = new Map(); - const seq = renders.map(r => { + const seq = reads.map(r => { if (!label.has(r.versions)) label.set(r.versions, label.size); - return `v${label.get(r.versions)}:d${idOf(r.data as object)}`; + return `${r.who === 'raw' ? 'R' : ''}v${label.get(r.versions)}:d${idOf(r.data as object)}`; }); console.log( - `${name}: ${renders.length} renders, ${label.size} versions, ${flipBacks(renders)} flip-backs, ${avoidableMisses(renders)} avoidable misses\n${seq.join(' ')}`, + `${name}: ${reads.length} reads, ${label.size} versions, ${flipBacks(reads)} flip-backs, ${avoidableMisses(reads)} avoidable misses\n${seq.join(' ')}`, ); } -/** Mount, start a transition that stays pending, run `urgent` five times, then let it commit. */ -async function runPendingTransition(urgent: (i: number) => void) { - const { getByTestId } = render( +function mount(consumers: number) { + return render( void) { devButton={null} managers={noDevManagers} > - + , ); - expect(getByTestId('count').textContent).toBe(`${ROWS}`); +} + +function beginTransition(row: string, id: string) { act(() => { - fireEvent.click(getByTestId('open')); + startTransition(() => { + controller.setResponse( + getTodo, + { id: row }, + { + id: row, + title: `from transition ${id}`, + }, + ); + showDetail(id); + }); }); - expect(getByTestId('pending').textContent).toBe('true'); - for (let i = 0; i < 5; i++) { - act(() => urgent(i)); - expect(getByTestId('pending').textContent).toBe('true'); - } +} + +async function finishTransition(id: string) { await act(async () => { - resolvers.X({ id: 'X', title: 'detail' }); + pending[id].resolve({ id, title: 'detail' }); }); - expect(getByTestId('detail').textContent).toBe('detail'); - expect(getByTestId('pending').textContent).toBe('false'); } -describeConcurrent('memoization under concurrent rendering', () => { - beforeEach(() => { - renders = []; - for (const key of Object.keys(resolvers)) delete resolvers[key]; +const writeNote = (i: number) => + act(() => { + controller.setResponse( + getNote, + { id: `${i}` }, + { + id: `${i}`, + text: `note ${i}`, + }, + ); }); - // While a transition is pending, React renders the urgent lane without its - // update and retries the transition with it, so the list alternates between - // the committed rows and the transition's rows. +function beginOptimistic(id: string, title: string) { + act(() => { + controller.fetch(updateTodo, { id, title }).catch(() => {}); + }); +} - it('urgent re-renders during a pending transition reuse the committed result', async () => { - await runPendingTransition(i => { - fireEvent.change(document.querySelector('input')!, { - target: { value: `typed ${i}` }, - }); - }); - trace('typing'); - expect(flipBacks(renders)).toBeGreaterThan(0); - expect(avoidableMisses(renders)).toBe(0); +async function rollBack(id: string, title: string) { + await act(async () => { + pending[`update ${id} ${title}`].reject(new Error('rejected')); }); +} - it('urgent writes to other entities during a pending transition reuse the committed result', async () => { - await runPendingTransition(i => { - controller.setResponse(getNote, { id: `${i}` }, { - id: `${i}`, - text: `note ${i}`, - }); - }); - trace('other entity writes'); - expect(flipBacks(renders)).toBeGreaterThan(0); - expect(avoidableMisses(renders)).toBe(0); +async function resolveOptimistic(id: string, title: string) { + await act(async () => { + pending[`update ${id} ${title}`].resolve({ id, title }); }); +} + +describeConcurrent.each([1, 2])( + 'memoization under concurrent rendering, %i consumer(s)', + consumers => { + beforeEach(() => { + reads = []; + for (const key of Object.keys(pending)) delete pending[key]; + }); + + // While a transition is pending, React renders the urgent lane without its + // update and retries the transition with it, so a list alternates between + // the committed rows and the transition's rows. + + it('urgent re-renders during a pending transition', async () => { + const { getByTestId } = mount(consumers); + beginTransition('5', 'X'); + expect(getByTestId('pending').textContent).toBe('true'); + for (let i = 0; i < 5; i++) act(() => typeText(`typed ${i}`)); + await finishTransition('X'); + expect(getByTestId('pending').textContent).toBe('false'); + trace('typing'); + expect(flipBacks(reads)).toBeGreaterThan(0); + expect(avoidableMisses(reads)).toBe(0); + }); - it('urgent writes to a listed row during a pending transition', async () => { - // React reapplies every update queued after the transition's on each - // render, so each render reads new rows and none can hit. - await runPendingTransition(i => { - controller.setResponse(getTodo, { id: '7' }, { - id: '7', - title: `urgent ${i}`, + it('urgent writes to other entities during a pending transition', async () => { + const { getByTestId } = mount(consumers); + beginTransition('5', 'X'); + for (let i = 0; i < 5; i++) writeNote(i); + expect(getByTestId('pending').textContent).toBe('true'); + await finishTransition('X'); + trace('other entity writes'); + expect(flipBacks(reads)).toBeGreaterThan(0); + expect(avoidableMisses(reads)).toBe(0); + }); + + it('urgent writes to a listed row during a pending transition', async () => { + // React reapplies every update queued after the transition's on each + // render, so each render reads new rows and none can hit. + mount(consumers); + beginTransition('5', 'X'); + for (let i = 0; i < 5; i++) { + act(() => { + controller.setResponse( + getTodo, + { id: '7' }, + { + id: '7', + title: `urgent ${i}`, + }, + ); + }); + } + await finishTransition('X'); + trace('listed row writes'); + expect(avoidableMisses(reads)).toBe(0); + }); + + it('two pending transitions with urgent writes between', async () => { + mount(consumers); + beginTransition('5', 'X'); + writeNote(0); + beginTransition('6', 'Y'); + for (let i = 1; i < 5; i++) writeNote(i); + await finishTransition('X'); + writeNote(5); + await finishTransition('Y'); + trace('two transitions'); + expect(avoidableMisses(reads)).toBe(0); + }); + + it('overlapping optimistic updates rolled back out of order', async () => { + mount(consumers); + beginOptimistic('3', 'A'); + writeNote(0); + beginOptimistic('4', 'B'); + writeNote(1); + await rollBack('3', 'A'); + writeNote(2); + await rollBack('4', 'B'); + writeNote(3); + trace('optimistic out of order'); + expect(flipBacks(reads)).toBeGreaterThan(0); + expect(avoidableMisses(reads)).toBe(0); + }); + + it('an optimistic update rolled back during a pending transition', async () => { + mount(consumers); + beginOptimistic('3', 'A'); + writeNote(0); + beginTransition('5', 'X'); + for (let i = 1; i < 4; i++) writeNote(i); + await rollBack('3', 'A'); + for (let i = 4; i < 7; i++) writeNote(i); + await finishTransition('X'); + writeNote(7); + trace('optimistic during transition'); + expect(avoidableMisses(reads)).toBe(0); + }); + + // After a transition commits, the committed rows are a new version. The + // memo only sees it again when a read reaches it: useSuspense serves + // urgent re-renders with an unchanged store from its own useMemo. + + it('urgent re-renders during a second transition, after the first committed', async () => { + const { getByTestId } = mount(consumers); + beginTransition('5', 'X'); + for (let i = 0; i < 3; i++) act(() => typeText(`a${i}`)); + await finishTransition('X'); + expect(getByTestId('pending').textContent).toBe('false'); + // the store moved on: the committed rows now include row 5's new version + beginTransition('6', 'Y'); + for (let i = 0; i < 3; i++) act(() => typeText(`b${i}`)); + await finishTransition('Y'); + trace('second transition'); + expect(flipBacks(reads)).toBeGreaterThan(0); + // The committed rows changed when the first write committed, and typing + // re-renders are served by useSuspense's useMemo, so the memo never + // sees them re-read before the transition's retries push them out. + // That costs one recompute, after which they are kept. + expect(avoidableMisses(reads)).toBeLessThanOrEqual(1); + }); + + it('reads outside render during optimistic updates, across a resolved mutation', async () => { + mount(consumers); + beginOptimistic('3', 'A'); + readRaw(); + writeNote(0); + readRaw(); + // the mutation resolves: row 3 has a new committed version + await resolveOptimistic('3', 'A'); + beginOptimistic('4', 'B'); + readRaw(); + writeNote(1); + readRaw(); + writeNote(2); + readRaw(); + await rollBack('4', 'B'); + trace('raw reads across a resolved mutation'); + expect(flipBacks(reads)).toBeGreaterThan(0); + expect(avoidableMisses(reads)).toBe(0); + }); + + it('urgent writes to other entities during a second transition, after the first committed', async () => { + // every urgent write changes the state object, so useSuspense re-queries + // the memo; the list's rows are unchanged and the old chain would hit + mount(consumers); + beginTransition('5', 'X'); + for (let i = 0; i < 3; i++) writeNote(i); + await finishTransition('X'); + beginTransition('6', 'Y'); + for (let i = 3; i < 8; i++) writeNote(i); + await finishTransition('Y'); + trace('other entity writes during a second transition'); + expect(flipBacks(reads)).toBeGreaterThan(0); + expect(avoidableMisses(reads)).toBe(0); + }); + + it('the same pattern as the existing typing test, after two committed writes to a row', async () => { + mount(consumers); + act(() => { + controller.setResponse(getTodo, { id: '9' }, { id: '9', title: 'v2' }); + }); + act(() => { + controller.setResponse(getTodo, { id: '9' }, { id: '9', title: 'v3' }); }); + beginTransition('5', 'X'); + for (let i = 0; i < 3; i++) act(() => typeText(`a${i}`)); + await finishTransition('X'); + trace('typing after two committed writes'); + expect(flipBacks(reads)).toBeGreaterThan(0); + // The committed rows changed when the first write committed, and typing + // re-renders are served by useSuspense's useMemo, so the memo never + // sees them re-read before the transition's retries push them out. + // That costs one recompute, after which they are kept. + expect(avoidableMisses(reads)).toBeLessThanOrEqual(1); }); - trace('listed row writes'); - expect(avoidableMisses(renders)).toBe(0); - }); -}); + }, +); From 0abba14b80a90e6a6a6623c7ace16c9f448fbcc3 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 24 Oct 2026 17:46:53 -0400 Subject: [PATCH 08/18] docs(normalizr): Document the memo invariants for agents and humans Adds a Memo invariants section to packages/normalizr/AGENTS.md: the seven rules every memo change must keep, the known pre-existing gaps, and why WeakDependencyMap keeps the versions it does under React concurrency. A scoped rule points agents editing memo, denormalize or schema code at it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .claude/rules/memo-invariants.md | 12 +++++++++ .cursor/rules/memo-invariants.mdc | 9 +++++++ packages/normalizr/AGENTS.md | 25 +++++++++++++++++-- .../normalizr/src/memo/WeakDependencyMap.ts | 3 +++ 4 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 .claude/rules/memo-invariants.md create mode 100644 .cursor/rules/memo-invariants.mdc diff --git a/.claude/rules/memo-invariants.md b/.claude/rules/memo-invariants.md new file mode 100644 index 000000000000..d973aa904374 --- /dev/null +++ b/.claude/rules/memo-invariants.md @@ -0,0 +1,12 @@ +--- +paths: + - "packages/normalizr/src/memo/**" + - "packages/normalizr/src/denormalize/**" + - "packages/endpoint/src/schemas/**" +--- + + + +# Memo invariants + +Before changing memoization, or a schema's `denormalize`, read "Memo invariants" in `packages/normalizr/AGENTS.md`. Every change must keep all seven. Cover any new edge case with a test in one of the files listed there. diff --git a/.cursor/rules/memo-invariants.mdc b/.cursor/rules/memo-invariants.mdc new file mode 100644 index 000000000000..4aae2107da97 --- /dev/null +++ b/.cursor/rules/memo-invariants.mdc @@ -0,0 +1,9 @@ +--- +description: Memoization invariants for normalizr's MemoCache and WeakDependencyMap +globs: packages/normalizr/src/memo/**, packages/normalizr/src/denormalize/**, packages/endpoint/src/schemas/** +alwaysApply: false +--- + +# Memo invariants + +Before changing memoization, or a schema's `denormalize`, read "Memo invariants" in `packages/normalizr/AGENTS.md`. Every change must keep all seven. Cover any new edge case with a test in one of the files listed there. diff --git a/packages/normalizr/AGENTS.md b/packages/normalizr/AGENTS.md index 2c12ea2f8fcf..c04bafb2e929 100644 --- a/packages/normalizr/AGENTS.md +++ b/packages/normalizr/AGENTS.md @@ -5,12 +5,33 @@ Non-obvious constraints when changing `src/memo/`, `src/denormalize/`, or `src/i ## Correctness constraints - **Referential equality is a contract.** Successive `MemoCache.denormalize` calls with identical inputs must return the same object reference. `useSuspense`/`useCache`/`useQuery` skip re-renders on `===`, and many tests assert `toBe`. Probe-and-discard caching schemes (write to a throwaway cache, "real" cache repopulates next call) silently break this. -- **`WeakDependencyMap` entity links are identity-keyed.** Entity deps live on a `WeakMap` keyed by the entity object; `argsKey` deps live on a parallel `Map` keyed by `fn(args)` strings branching off a stable function ref (see `WeakDependencyMap.ts` `next` / `nextStr`). Cache hits on the entity segment depend on every ref in the recorded chain being `===` to the live walk's refs — anything that wraps/proxies refs on read defeats memoization. Variants that introduce new caches (buckets, scopes) must share the same `getEntity` (the `DenormGetEntity` from `IMemoPolicy.getEntities(entities)`) so chain refs stay coherent. +- **`WeakDependencyMap` entries are identity-keyed.** The first dependency is a `WeakMap` key; the rest are stored on the entry and compared with `===` on `get`; `argsKey` results branch on a `Map` keyed by `fn(args)`. Anything that wraps/proxies refs on read defeats memoization. Variants that introduce new caches (buckets, scopes) must share the same `getEntity` (the `DenormGetEntity` from `IMemoPolicy.getEntities(entities)`) so recorded refs stay coherent. - **Both MemoCache tiers must stay coherent** — `endpoints` (top-level) and `_getCache` (per-entity) walk against the same store. Any new caching dimension applies to both, or top-level hits return stale per-entity values. - **Cache lookup precedes traversal.** Pick `WeakDependencyMap` → look up → on miss, traverse and write. You can't decide which map to use *based on* what the traversal observes. Discover-then-lookup designs need either a separate pre-scan or a `setRaw`-style promotion primitive (doesn't exist yet). - **Storage shape is API.** `state.entities[key][pk]` is observed by snapshot tests, SSR payloads, and devtools. Shape changes are breaking even when normalize/denormalize round-trip correctly. - **Cache invalidation is reference-driven.** A new object at `entities[key][pk]` busts every chain through that step. Storing multiple logical cells under one `pk` invalidates them all on any single-cell write. +## Memo invariants + +Every change to `src/memo/`, or to a schema's `denormalize`, must keep these. Tests: `src/__tests__/WeakDependencyMap.test.ts`, `src/__tests__/MemoCache.ts`, `packages/react/src/__tests__/concurrent-memo.web.tsx`. + +1. **Sound hits.** A hit returns only a value computed from exactly these entity objects (`===`; `UNDEF` for missing) and these `argsKey` results. +2. **Complete dependencies.** Every entity and `argsKey` a computation reads is recorded, including deps replayed from a nested hit. Known gaps (pre-existing): `INVALID` or `null` nested entities, and entities at the max entity depth, are read without being recorded. +3. **Deterministic.** The same reads lead to the same further reads and an equal result. All args-dependence goes through a pure, referentially stable `argsKey` fn. +4. **Complete journey.** The paths returned with a hit cover every entity the value depends on; subscriptions and GC count refs from them. Callers never mutate it. +5. **Weak, bounded retention.** A value lives only while its first dependency is reachable, with at most `MAX_ENTRIES` values per first dependency and `argsKey` results. +6. **Stable identity.** The same reads return the same object while it is cached. +7. **One `WeakMap` level.** Never nest a `WeakMap` per dependency. Hermes resolves `WeakMap` entries one nesting level per stop-the-world marking pass, and each pass rescans every entry in the app, so N nested levels cost N passes (multi-second freezes). `yarn workspace example-benchmark start:ephemerons` measures it. + +Known gap: the results cache is keyed by the input object, not the schema, so two schemas denormalizing the same result object share one value (pre-existing). + +### Which versions to keep (React concurrency) + +- A render that applies a pending store update rebuilds every entity that update touches (`useReducer` has no eager state). Those reads are one-offs. +- Within one committed store, a reader re-reads at most one version unchanged: renders read the committed `optimisticState`, managers and callbacks read `controller.getState()`. Every commit starts a new version. Rolling back an optimistic update returns to the older real entities. +- So `WeakDependencyMap` keeps the most recently used entries plus the one the store last flipped back to. Bound: `useSuspense` serves urgent re-renders of an unchanged store from its own `useMemo`, so during a retrying transition a newly committed version can be recomputed once. +- `yarn workspace example-benchmark start:memo-tradeoffs` measures hit rates and retained memory for these choices. + ## Schema-tree traversal pattern When walking the schema *definition* (not data), the canonical recursion is used in many places. Keep it consistent: @@ -56,7 +77,7 @@ Contract: - Reading `delegate.args` directly does **not** contribute to memoization — only `argsKey` adds a dep. - Storage shape must let `denormalize` recover the right cell from the bucket key (typically by encoding it into `pk` at normalize time). `Scalar` is the reference implementation. -Under the hood: `argsKey` pushes `{path: fn, entity: undefined}` onto the current `GlobalCache` frame; `WeakDependencyMap` branches function-typed paths via a lazy `Map` alongside the usual `WeakMap`. See `src/memo/WeakDependencyMap.ts` (`KeyFn`, `hasStringDeps`) and `src/memo/globalCache.ts` (`argsKey`, `_hasArgsKey`) for the fast-path gating. +Under the hood: `argsKey` pushes `{path: fn, entity: undefined}` onto the current `GlobalCache` frame; `WeakDependencyMap` branches function-typed paths via a lazy `Map` under the root's `WeakMap` entry. See `src/memo/WeakDependencyMap.ts` (`KeyFn`, `hasStringDeps`) and `src/memo/globalCache.ts` (`argsKey`, `_hasArgsKey`) for the fast-path gating. Schemas without args-dependence need no opt-in. When no `argsKey` fires in a frame, `GlobalCache.paths`/`getResults` take the entity-only fast path (gated on `_hasArgsKey` and `WeakDependencyMap.hasStringDeps`). diff --git a/packages/normalizr/src/memo/WeakDependencyMap.ts b/packages/normalizr/src/memo/WeakDependencyMap.ts index cd6e4b87aadd..01fa00f0b305 100644 --- a/packages/normalizr/src/memo/WeakDependencyMap.ts +++ b/packages/normalizr/src/memo/WeakDependencyMap.ts @@ -22,6 +22,9 @@ const UNDEF_KEY = '\0'; * String-keyed dependencies (used by `argsKey`) branch on a `Map` keyed by * the value `path(args)` returns, so each argument set keeps its own entries * and a lookup only checks its own. + * + * Invariants every change must keep: "Memo invariants" in + * `packages/normalizr/AGENTS.md`. */ export default class WeakDependencyMap< Path, From ecea0af6d616d01a69c3aefaed5635dc8bfdca0d Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 24 Oct 2026 17:47:34 -0400 Subject: [PATCH 09/18] docs(normalizr): Mark schema-agnostic result keys and the depth limit as accepted Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/normalizr/AGENTS.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/normalizr/AGENTS.md b/packages/normalizr/AGENTS.md index c04bafb2e929..51d7fd7ff109 100644 --- a/packages/normalizr/AGENTS.md +++ b/packages/normalizr/AGENTS.md @@ -16,14 +16,17 @@ Non-obvious constraints when changing `src/memo/`, `src/denormalize/`, or `src/i Every change to `src/memo/`, or to a schema's `denormalize`, must keep these. Tests: `src/__tests__/WeakDependencyMap.test.ts`, `src/__tests__/MemoCache.ts`, `packages/react/src/__tests__/concurrent-memo.web.tsx`. 1. **Sound hits.** A hit returns only a value computed from exactly these entity objects (`===`; `UNDEF` for missing) and these `argsKey` results. -2. **Complete dependencies.** Every entity and `argsKey` a computation reads is recorded, including deps replayed from a nested hit. Known gaps (pre-existing): `INVALID` or `null` nested entities, and entities at the max entity depth, are read without being recorded. +2. **Complete dependencies.** Every entity and `argsKey` a computation reads is recorded, including deps replayed from a nested hit. Known gap (pre-existing): `INVALID` or `null` nested entities are read without being recorded, so the parent stays stale after the child becomes valid again. 3. **Deterministic.** The same reads lead to the same further reads and an equal result. All args-dependence goes through a pure, referentially stable `argsKey` fn. 4. **Complete journey.** The paths returned with a hit cover every entity the value depends on; subscriptions and GC count refs from them. Callers never mutate it. 5. **Weak, bounded retention.** A value lives only while its first dependency is reachable, with at most `MAX_ENTRIES` values per first dependency and `argsKey` results. 6. **Stable identity.** The same reads return the same object while it is cached. 7. **One `WeakMap` level.** Never nest a `WeakMap` per dependency. Hermes resolves `WeakMap` entries one nesting level per stop-the-world marking pass, and each pass rescans every entry in the app, so N nested levels cost N passes (multi-second freezes). `yarn workspace example-benchmark start:ephemerons` measures it. -Known gap: the results cache is keyed by the input object, not the schema, so two schemas denormalizing the same result object share one value (pre-existing). +Accepted limitations (intentional; don't "fix"): + +- The results cache is keyed by the input object, not the schema. Two schemas only see the same result object if two endpoints share a cache key, which is already a bug in the endpoints. +- Entities past the max entity depth (64) are read but not tracked. The depth limit exists to bound recursion; changes that deep don't invalidate ancestors. ### Which versions to keep (React concurrency) From 4732cc1c9235a2a9d8acfed6d6eab3bd6ca70c8a Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 24 Oct 2026 20:54:10 -0400 Subject: [PATCH 10/18] test(normalizr): Cover the argsKey check in WeakDependencyMap matches() Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/normalizr/src/__tests__/WeakDependencyMap.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts index b8f265f13c0b..3a2a1b226a27 100644 --- a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts +++ b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts @@ -339,6 +339,13 @@ describe('WeakDependencyMap eviction', () => { expect( wem.get(root, getEntity, [{ userId: 'bob', team: 'y' }])[0], ).toBeUndefined(); + // fnB routes to A's bucket, but A's own fnA rejects these args + expect( + wem.get(root, getEntity, [{ userId: 'alice', team: 'bob' }])[0], + ).toBeUndefined(); + expect( + wem.get(root, getEntity, [{ userId: 'bob', team: 'bob' }])[0], + ).toBe('A'); }); it('a shorter dependency list at the same root is served when the longer one mismatches', () => { From 24525cf63f82d1d7b7bd982e7a41495585d6b840 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 24 Oct 2026 22:13:53 -0400 Subject: [PATCH 11/18] fix(normalizr): Keep one argsKey branch per fn in WeakDependencyMap Which argsKey a computation reaches first can depend on the entities before it: a missing entity skips its nested argsKey. A node kept a single fn, so the last set() overwrote the routing for entries stored under another fn, and snapshots that alternated missed each time. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/normalizr/AGENTS.md | 2 +- .../src/__tests__/WeakDependencyMap.test.ts | 46 ++++++---- .../normalizr/src/memo/WeakDependencyMap.ts | 85 ++++++++++++------- 3 files changed, 83 insertions(+), 50 deletions(-) diff --git a/packages/normalizr/AGENTS.md b/packages/normalizr/AGENTS.md index b6ad64937b4b..ab7fef03ed76 100644 --- a/packages/normalizr/AGENTS.md +++ b/packages/normalizr/AGENTS.md @@ -5,7 +5,7 @@ Non-obvious constraints when changing `src/memo/`, `src/denormalize/`, or `src/i ## Correctness constraints - **Referential equality is a contract.** Successive `MemoCache.denormalize` calls with identical inputs must return the same object reference. `useSuspense`/`useCache`/`useQuery` skip re-renders on `===`, and many tests assert `toBe`. Probe-and-discard caching schemes (write to a throwaway cache, "real" cache repopulates next call) silently break this. -- **`WeakDependencyMap` entries are identity-keyed.** The first dependency is a `WeakMap` key; the rest are stored on the entry and compared with `===` on `get`; `argsKey` results branch on a `Map` keyed by `fn(args)`. Anything that wraps/proxies refs on read defeats memoization. Variants that introduce new caches (buckets, scopes) must share the same `getEntity` (the `DenormGetEntity` from `IMemoPolicy.getEntities(entities)`) so recorded refs stay coherent. +- **`WeakDependencyMap` entries are identity-keyed.** The first dependency is a `WeakMap` key; the rest are stored on the entry and compared with `===` on `get`; `argsKey` results branch on a `Map` keyed by `fn(args)`, one branch per `argsKey` fn (a missing entity can change which fn comes first). Anything that wraps/proxies refs on read defeats memoization. Variants that introduce new caches (buckets, scopes) must share the same `getEntity` (the `DenormGetEntity` from `IMemoPolicy.getEntities(entities)`) so recorded refs stay coherent. - **Both MemoCache tiers must stay coherent** — `endpoints` (top-level) and `_getCache` (per-entity) walk against the same store. Any new caching dimension applies to both, or top-level hits return stale per-entity values. - **Cache lookup precedes traversal.** Pick `WeakDependencyMap` → look up → on miss, traverse and write. You can't decide which map to use *based on* what the traversal observes. Discover-then-lookup designs need either a separate pre-scan or a `setRaw`-style promotion primitive (doesn't exist yet). - **Storage shape is API.** `state.entities[key][pk]` is observed by snapshot tests, SSR payloads, and devtools. Shape changes are breaking even when normalize/denormalize round-trip correctly. diff --git a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts index 3a2a1b226a27..f910e1371504 100644 --- a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts +++ b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts @@ -323,29 +323,39 @@ describe('WeakDependencyMap eviction', () => { expect(wem.get(root, withB(S))[0]).toBe('S'); }); - it('a different argsKey fn at the same position makes the other entry unreachable (false miss, not wrong data)', () => { + it('keeps entries that reach different argsKey fns first reachable', () => { + // a missing entity skips its nested argsKey, so which fn comes first can + // change between store snapshots of the same schema and args const wem = new WeakDependencyMap(); - const fnA = (args: readonly any[]) => args[0]?.userId; - const fnB = (args: readonly any[]) => args[0]?.team; - wem.set([rootDep, { path: fnA, entity: undefined }], 'A', [ - { userId: 'bob' }, - ]); - wem.set([rootDep, { path: fnB, entity: undefined }], 'B', [{ team: 'x' }]); + const portfolio = (args: readonly any[]) => args[0]?.portfolio; + const currency = (args: readonly any[]) => args[0]?.currency; + const args = [{ portfolio: 'P', currency: 'USD' }]; const getEntity = MemoPolicy.getEntities({ A: { '1': root } }); - // B is reachable; A only if fnB(args) happens to equal fnA(args) - expect(wem.get(root, getEntity, [{ team: 'x' }])[0]).toBe('B'); - expect(wem.get(root, getEntity, [{ userId: 'bob' }])[0]).toBeUndefined(); - // never the wrong value: an entry is validated against its own fn + wem.set( + [rootDep, { path: currency, entity: undefined }], + 'A missing', + args, + ); + wem.set( + [ + rootDep, + { path: portfolio, entity: undefined }, + { path: currency, entity: undefined }, + ], + 'A loaded', + args, + ); + // both snapshots keep hitting as they alternate + for (let i = 0; i < 2; i++) { + expect(wem.get(root, getEntity, args)[0]).toBeDefined(); + } expect( - wem.get(root, getEntity, [{ userId: 'bob', team: 'y' }])[0], - ).toBeUndefined(); - // fnB routes to A's bucket, but A's own fnA rejects these args + wem.get(root, getEntity, [{ portfolio: 'Q', currency: 'USD' }])[0], + ).toBe('A missing'); + // never the wrong value: an entry is validated against its own fns expect( - wem.get(root, getEntity, [{ userId: 'alice', team: 'bob' }])[0], + wem.get(root, getEntity, [{ portfolio: 'P', currency: 'EUR' }])[0], ).toBeUndefined(); - expect( - wem.get(root, getEntity, [{ userId: 'bob', team: 'bob' }])[0], - ).toBe('A'); }); it('a shorter dependency list at the same root is served when the longer one mismatches', () => { diff --git a/packages/normalizr/src/memo/WeakDependencyMap.ts b/packages/normalizr/src/memo/WeakDependencyMap.ts index 832f90b7a9f5..6a6a14f004e2 100644 --- a/packages/normalizr/src/memo/WeakDependencyMap.ts +++ b/packages/normalizr/src/memo/WeakDependencyMap.ts @@ -36,7 +36,9 @@ function depKey(entity: unknown): object { * * String-keyed dependencies (used by `argsKey`) branch on a `Map` keyed by * the value `path(args)` returns, so each argument set keeps its own entries - * and a lookup only checks its own. + * and a lookup only checks its own. A node keeps one branch per `argsKey` + * function: which one a computation reaches first can depend on the + * entities before it (a missing entity skips its nested `argsKey`). * * Invariants every change must keep: "Memo invariants" in * `packages/normalizr/AGENTS.md`. @@ -55,26 +57,11 @@ export default class WeakDependencyMap< getDependency: GetDependency, args: readonly any[] = [], ) { - let node = this.next.get(depKey(entity) as K); - while (node) { - const { entries } = node; - for (let i = 0; i < entries.length; i++) { - const entry = entries[i]; - if (matches(entry, getDependency, args)) { - if (i > 0) { - // read back after something newer was cached: the store flipped - // back to this version, so it is the one to keep - node.kept = entry; - entries.splice(i, 1); - entries.unshift(entry); - } - return [entry.value, entry.journey] as readonly [V, Path[]]; - } - } - if (!node.fn) break; - node = node.children?.get(node.fn(args) ?? UNDEF_KEY); - } - return EMPTY; + const root = this.next.get(depKey(entity) as K); + const node = root && find(root, getDependency, args); + if (!node) return EMPTY; + const entry = node.entries[0]; + return [entry.value, entry.journey] as readonly [V, Path[]]; } set( @@ -105,12 +92,16 @@ export default class WeakDependencyMap< node = newNode(undefined); this.next.set(root, node); } - node.fn = dep.path as KeyFn; - if (!node.children) node.children = new Map(); - let child = node.children.get(k); + if (!node.branches) node.branches = []; + let branch = node.branches.find(b => b.fn === dep.path); + if (!branch) { + branch = { fn: dep.path as KeyFn, children: new Map() }; + node.branches.push(branch); + } + let child = branch.children.get(k); if (!child) { child = newNode(undefined); - node.children.set(k, child); + branch.children.set(k, child); } node = child; } else { @@ -149,6 +140,35 @@ export default class WeakDependencyMap< } } +/** The node at or below `node` with an entry that matches the store, if any; + * that entry is moved to the front of its entries. */ +function find( + node: Node, + getDependency: GetDependency, + args: readonly any[], +): Node | undefined { + const { entries } = node; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (matches(entry, getDependency, args)) { + if (i > 0) { + // read back after something newer was cached: the store flipped + // back to this version, so it is the one to keep + node.kept = entry; + entries.splice(i, 1); + entries.unshift(entry); + } + return node; + } + } + if (!node.branches) return; + for (const { fn, children } of node.branches) { + const child = children.get(fn(args) ?? UNDEF_KEY); + const hit = child && find(child, getDependency, args); + if (hit) return hit; + } +} + function matches( entry: Entry, getDependency: GetDependency, @@ -181,22 +201,25 @@ const EMPTY = [undefined, undefined] as const; const MAX_ENTRIES = 3; /** Entries for one set of `argsKey` results, most recently used first, and the - * branch on the next one. */ + * branches on the next one. */ interface Node { entries: Entry[]; /** Never evicted: the entry most recently read back after a newer one. */ kept: Entry | undefined; - /** The next `argsKey` dependency; its result picks the child. */ - fn: KeyFn | undefined; - children: Map> | undefined; + /** One per next `argsKey` dependency; its result picks the child. */ + branches: Branch[] | undefined; +} + +interface Branch { + readonly fn: KeyFn; + readonly children: Map>; } function newNode(entry: Entry | undefined): Node { return { entries: entry ? [entry] : [], kept: entry, - fn: undefined, - children: undefined, + branches: undefined, }; } From 88c6a18a07ef66c836e9048e539c7ca2dbcd453e Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 24 Oct 2026 22:39:45 -0400 Subject: [PATCH 12/18] fix(normalizr): Look up argsKey branches with a loop, not recursion A list can record one argsKey per row, so nodes nest that deep, and the recursive find overflowed the stack on a hit. Other branches go on a stack that is only allocated when a node has more than one. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/__tests__/WeakDependencyMap.test.ts | 12 +++++ .../normalizr/src/memo/WeakDependencyMap.ts | 48 +++++++++++-------- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts index 100ee778e8d7..8e428b9b3fb6 100644 --- a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts +++ b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts @@ -368,6 +368,18 @@ describe('WeakDependencyMap eviction', () => { ).toBeUndefined(); }); + it('looks up values behind many nested argsKey deps', () => { + // a list whose rows each record an argsKey nests one node per row + const wem = new WeakDependencyMap(); + const fns = Array.from({ length: 20000 }, (_, i) => () => `${i}`); + const getEntity = MemoPolicy.getEntities({ A: { '1': root } }); + wem.set( + [rootDep, ...fns.map(path => ({ path, entity: undefined }))], + 'deep', + ); + expect(wem.get(root, getEntity)[0]).toBe('deep'); + }); + it('a shorter dependency list at the same root is served when the longer one mismatches', () => { // each entry is checked against its own dependencies, so the older one // still serves reads that never touch C diff --git a/packages/normalizr/src/memo/WeakDependencyMap.ts b/packages/normalizr/src/memo/WeakDependencyMap.ts index c5cbd445da98..0798fa5bcaa9 100644 --- a/packages/normalizr/src/memo/WeakDependencyMap.ts +++ b/packages/normalizr/src/memo/WeakDependencyMap.ts @@ -146,31 +146,41 @@ export default class WeakDependencyMap< } /** The node at or below `node` with an entry that matches the store, if any; - * that entry is moved to the front of its entries. */ + * that entry is moved to the front of its entries. A loop, not recursion: + * a list can record one `argsKey` per row, so the nodes nest that deep. */ function find( - node: Node, + node: Node | undefined, getDependency: GetDependency, args: readonly any[], ): Node | undefined { - const { entries } = node; - for (let i = 0; i < entries.length; i++) { - const entry = entries[i]; - if (matches(entry, getDependency, args)) { - if (i > 0) { - // read back after something newer was cached: the store flipped - // back to this version, so it is the one to keep - node.kept = entry; - entries.splice(i, 1); - entries.unshift(entry); + // other branches to try, only allocated when a node has more than one + let pending: Node[] | undefined; + while (node) { + const { entries, branches } = node; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (matches(entry, getDependency, args)) { + if (i > 0) { + // read back after something newer was cached: the store flipped + // back to this version, so it is the one to keep + node.kept = entry; + entries.splice(i, 1); + entries.unshift(entry); + } + return node; } - return node; } - } - if (!node.branches) return; - for (const { fn, children } of node.branches) { - const child = children.get(fn(args) ?? UNDEF_KEY); - const hit = child && find(child, getDependency, args); - if (hit) return hit; + let next: Node | undefined; + if (branches) { + for (let b = 0; b < branches.length; b++) { + const { fn, children } = branches[b]; + const child = children.get(fn(args) ?? UNDEF_KEY); + if (!child) continue; + if (!next) next = child; + else (pending || (pending = [])).push(child); + } + } + node = next ?? pending?.pop(); } } From cb478575728813ad9ee46f980f87003143d979a1 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 24 Oct 2026 23:50:22 -0400 Subject: [PATCH 13/18] test(normalizr): Cover argsKey branch fallbacks and child-node eviction Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/__tests__/WeakDependencyMap.test.ts | 132 ++++++++++++++---- 1 file changed, 104 insertions(+), 28 deletions(-) diff --git a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts index bcc062bbad0e..199f27751fb0 100644 --- a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts +++ b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts @@ -333,39 +333,115 @@ describe('WeakDependencyMap eviction', () => { expect(wem.get(root, withB(S))[0]).toBe('S'); }); - it('keeps entries that reach different argsKey fns first reachable', () => { + describe('argsKey branches', () => { // a missing entity skips its nested argsKey, so which fn comes first can // change between store snapshots of the same schema and args - const wem = new WeakDependencyMap(); const portfolio = (args: readonly any[]) => args[0]?.portfolio; const currency = (args: readonly any[]) => args[0]?.currency; - const args = [{ portfolio: 'P', currency: 'USD' }]; - const getEntity = MemoPolicy.getEntities({ A: { '1': root } }); - wem.set( - [rootDep, { path: currency, entity: undefined }], - 'A missing', - args, - ); - wem.set( - [ - rootDep, - { path: portfolio, entity: undefined }, - { path: currency, entity: undefined }, - ], - 'A loaded', - args, - ); - // both snapshots keep hitting as they alternate - for (let i = 0; i < 2; i++) { - expect(wem.get(root, getEntity, args)[0]).toBeDefined(); + const region = (args: readonly any[]) => args[0]?.region; + const args = [{ portfolio: 'P', currency: 'USD', region: 'EU' }]; + const c = (entity: object | undefined) => ({ + path: { key: 'C', pk: '2' }, + entity, + }); + const fn = (path: (args: readonly any[]) => string | undefined) => ({ + path, + entity: undefined, + }); + const withC = (entity: object | undefined) => + MemoPolicy.getEntities({ A: { '1': root }, C: { '2': entity as any } }); + const loaded = { c: 1 }; + const changed = { c: 2 }; + function setup() { + const wem = new WeakDependencyMap(); + wem.set([rootDep, c(undefined)], 'plain', args); + wem.set([rootDep, c(undefined), fn(currency)], 'C missing', args); + wem.set( + [rootDep, c(loaded), fn(portfolio), fn(currency)], + 'C loaded', + args, + ); + // a second branch below the root: the lookup has to come back to it + wem.set([rootDep, c(changed), fn(portfolio), fn(region)], 'region', args); + return wem; } - expect( - wem.get(root, getEntity, [{ portfolio: 'Q', currency: 'USD' }])[0], - ).toBe('A missing'); - // never the wrong value: an entry is validated against its own fns - expect( - wem.get(root, getEntity, [{ portfolio: 'P', currency: 'EUR' }])[0], - ).toBeUndefined(); + + it('finds the entry for each snapshot as they alternate', () => { + const wem = setup(); + for (let i = 0; i < 2; i++) { + // the first branch's child doesn't match, so the other branch is tried + expect(wem.get(root, withC(loaded), args)[0]).toBe('C loaded'); + // root entries are checked before any branch + expect(wem.get(root, withC(undefined), args)[0]).toBe('plain'); + // two branches deep: currency's child misses, then region's is tried + expect(wem.get(root, withC(changed), args)[0]).toBe('region'); + } + }); + + it('never returns an entry whose own argsKey results differ', () => { + const wem = setup(); + expect( + wem.get(root, withC(loaded), [{ ...args[0], currency: 'EUR' }])[0], + ).toBeUndefined(); + expect( + wem.get(root, withC(changed), [{ ...args[0], region: 'US' }])[0], + ).toBeUndefined(); + expect(wem.get(root, withC({ c: 3 }), args)[0]).toBeUndefined(); + }); + + it('keeps rows under the same fn apart by args', () => { + // a Scalar lens: every row records the same argsKey fn + const wem = new WeakDependencyMap(); + const row2 = (entity: object) => ({ + path: { key: 'D', pk: '2' }, + entity, + }); + const withRows = (c2: object, d2: object) => + MemoPolicy.getEntities({ + A: { '1': root }, + C: { '2': c2 }, + D: { '2': d2 }, + }); + const r1 = { r: 1 }; + const r2 = { r: 2 }; + for (const p of ['A', 'B']) { + wem.set([rootDep, fn(portfolio), c(r1), fn(portfolio), row2(r2)], p, [ + { portfolio: p }, + ]); + } + for (let i = 0; i < 2; i++) { + for (const p of ['A', 'B']) { + expect(wem.get(root, withRows(r1, r2), [{ portfolio: p }])[0]).toBe( + p, + ); + } + } + expect( + wem.get(root, withRows(r1, { r: 3 }), [{ portfolio: 'A' }])[0], + ).toBeUndefined(); + }); + + it('evicts inside an argsKey child as at the root', () => { + const wem = new WeakDependencyMap(); + const [b2, b3, b4, b5] = [[2], [3], [4], [5]]; + const b = [1]; + const one = [{ portfolio: 'P' }]; + const setB = (entity: object, value: string) => + wem.set([rootDep, fn(portfolio), dep(entity)], value, one); + const get = (entity: object) => wem.get(root, withB(entity), one)[0]; + setB(b, 'b'); + setB(b2, 'b2'); + setB(b3, 'b3'); + setB(b4, 'b4'); + expect(get(b2)).toBeUndefined(); + expect(get(b)).toBe('b'); + expect(get(b3)).toBe('b3'); + setB(b5, 'b5'); + expect(get(b4)).toBeUndefined(); + setB(b4, 'b4 again'); + expect(get(b)).toBeUndefined(); + expect(get(b3)).toBe('b3'); + }); }); it('looks up values behind many nested argsKey deps', () => { From a8ab4530d51374ba825dd5f2c0c9fd11b3045b98 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 24 Oct 2026 23:50:22 -0400 Subject: [PATCH 14/18] perf(normalizr): Skip argsKey paths when matching a memo entry The route to an entry already picked every argsKey result, so calling each fn again in matches() could never fail. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/normalizr/src/memo/WeakDependencyMap.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/normalizr/src/memo/WeakDependencyMap.ts b/packages/normalizr/src/memo/WeakDependencyMap.ts index d01daeedae81..20879aca5b22 100644 --- a/packages/normalizr/src/memo/WeakDependencyMap.ts +++ b/packages/normalizr/src/memo/WeakDependencyMap.ts @@ -156,7 +156,7 @@ function find( const { entries, branches } = node; for (let i = 0; i < entries.length; i++) { const entry = entries[i]; - if (matches(entry, getDependency, args)) { + if (matches(entry, getDependency)) { if (i > 0) { // read back after something newer was cached: the store flipped // back to this version, so it is the one to keep @@ -184,16 +184,13 @@ function find( function matches( entry: Entry, getDependency: GetDependency, - args: readonly any[], ): boolean { const { paths, keys } = entry; for (let i = 0; i < paths.length; i++) { const path = paths[i]; - if (typeof path === 'function') { - if (((path as KeyFn)(args) ?? UNDEF_KEY) !== keys[i]) return false; - } else if (depKey(getDependency(path as Path)) !== keys[i]) { - return false; - } + // the route to this entry already picked every argsKey result + if (typeof path === 'function') continue; + if (depKey(getDependency(path as Path)) !== keys[i]) return false; } return true; } From 2b7340b508787f58c32330bb560b72dbe2c64cc4 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sun, 25 Oct 2026 00:34:54 -0400 Subject: [PATCH 15/18] bench: Measure memo updates, Scalar reads and deep-chain ephemerons normalizr: a steady-state store update (one project replaced, changed row first or last) read through the cache; a cold Scalar list and one with a Scalar cell replaced per op; a Holding list with two Scalar lenses whose stores miss different Stocks, so list nodes keep more than one argsKey branch. SHOW_OPTIMIZATION also prints WeakDependencyMap get and set. memo-tradeoffs: heap the memo holds for each argsKey buckets row. ephemerons: a 50-level Department/Building chain whose deepest Building changes every tick, for marking passes against depth. Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/benchmark/README.md | 10 ++- examples/benchmark/ephemerons.js | 28 ++++++++ examples/benchmark/memo-tradeoffs.js | 11 ++- examples/benchmark/normalizr.js | 101 +++++++++++++++++++++++++++ examples/benchmark/schemas.js | 37 ++++++++++ 5 files changed, 184 insertions(+), 3 deletions(-) diff --git a/examples/benchmark/README.md b/examples/benchmark/README.md index 1b4309b107a1..d10b1067183d 100644 --- a/examples/benchmark/README.md +++ b/examples/benchmark/README.md @@ -37,6 +37,9 @@ Both arguments are optional: - `core` - benchmarks entire operations using [Controller](https://dataclient.io/docs/api/Controller) - `normalizr` - benchmarks just normalize/denormalize - `denormalizeLong nested 25% missing|null|INVALID` (and `withCache`) - the project list with every 4th nested `BuildTypeDescription` missing from the store, `null`, or `INVALID`; those are still tracked as memo dependencies + - `denormalizeLong withCache update first|last row` - steady state: each op builds a new `entities` with one project replaced, then reads the cached list; the changed row is first or last in the list + - `denormalizeLong Scalar` (cold) and `denormalizeLong Scalar withCache update one cell` - the 700-row `Scalar` list in a new `MemoCache`, and read again after each op replaces one `Scalar` cell + - `denormalizeLong two Scalars, 25% Stocks missing` (and `withCache`) - a `Holding` list with two `Scalar` lenses (one through its `Stock`); two stores missing different Stocks are read in turn, so list nodes keep more than one `argsKey` branch - `spread` - degenerate-case writes where spread cost scales with store size (single-entity `setResponse` into 1k/10k/100k entity stores, 10k cached endpoint keys, collection push onto 10k items, invalidateAll/expireAll). One case (`setOneEntity in 10k entity store`) is tracked over time in CI via `.github/workflows/benchmark-spread.yml`; the rest are manual-only. - `micro` - isolated microbenchmarks for testing specific operations - `old-normalizr` - runs equivalent benchmarks using the normalizr package @@ -97,7 +100,9 @@ it. It reports the live `WeakMap` entries, how many marking passes Hermes would need, and their product, which is the number of entry visits in one Hermes old-generation pause. It also reports V8's own full-GC time. The scenarios mirror a live market screen: a 3,736-symbol endpoint whose symbols -join streaming tickers, and a 100-row trade tape that prepends prints. +join streaming tickers, and a 100-row trade tape that prepends prints. A +50-level Department/Building chain whose deepest Building changes every tick +measures passes against nesting depth. Local-only — not tracked in CI. How engines resolve `WeakMap` entries during a major collection: @@ -120,7 +125,8 @@ yarn start:memo-tradeoffs [filter] Measures where caching fewer versions could cost reads or memory: one list read against several store snapshots in turn (hit rate and time per read), heap the memo keeps beyond the store after a run of updates, and one list read -under many `argsKey` values. Local-only — not tracked in CI. +under many `argsKey` values (hit rate and heap held). Local-only — not tracked +in CI. ### Profiling diff --git a/examples/benchmark/ephemerons.js b/examples/benchmark/ephemerons.js index 0095532d0d7d..e82b0fae978a 100644 --- a/examples/benchmark/ephemerons.js +++ b/examples/benchmark/ephemerons.js @@ -48,6 +48,8 @@ class CountingWeakMap extends NativeWeakMap { globalThis.WeakMap = CountingWeakMap; const { Entity, normalize, MemoCache } = await import('./dist/index.js'); +// after the bundle, so a static import doesn't load it before CountingWeakMap +const { Department, buildBidirectionalChain } = await import('./schemas.js'); const EPHEMERON = /part of key \(.*? @(\d+)\) -> value \(.*? @(\d+)\) pair in WeakMap \(table @(\d+)\)/; @@ -259,6 +261,28 @@ function tradeTape() { return { memo, store }; } +const CHAIN = 50; + +/** A Department -> Building -> Department chain `CHAIN` Departments deep whose + * deepest Building is replaced on every tick, so every level recomputes. */ +function deepChain() { + const memo = new MemoCache(); + const { entities: first, result } = buildBidirectionalChain(CHAIN); + let entities = first; + const leaf = `bldg-${CHAIN - 1}`; + for (let tick = 1; tick <= TICKS; tick++) { + entities = { + ...entities, + Building: { + ...entities.Building, + [leaf]: { ...entities.Building[leaf], name: `Building ${tick}` }, + }, + }; + memo.denormalize(Department, result, entities); + } + return { memo, entities, result }; +} + const scenarios = [ { name: `exchangeInfo: ${SYMBOLS} symbols join tickers, ${TICKERS_PER_TICK} tickers x ${TICKS} ticks`, @@ -272,6 +296,10 @@ const scenarios = [ name: `trade tape: ${TAPE} rows, ${PRINTS_PER_FLUSH} prints x ${FLUSHES} flushes`, fn: tradeTape, }, + { + name: `deep chain: ${CHAIN} Departments deep, deepest Building replaced x ${TICKS} ticks`, + fn: deepChain, + }, ].filter(s => !process.argv[2] || s.name.includes(process.argv[2])); const rows = []; diff --git a/examples/benchmark/memo-tradeoffs.js b/examples/benchmark/memo-tradeoffs.js index f54e2d2c0ee2..75f47193bb5a 100644 --- a/examples/benchmark/memo-tradeoffs.js +++ b/examples/benchmark/memo-tradeoffs.js @@ -7,7 +7,8 @@ * - retained memory: heap the memo holds beyond the store itself, after a run * of updates. Entity churn (every row replaced) is the worst case. * - argsKey buckets: one list read under N different `argsKey` values (a - * portfolio app switching between N portfolios), all cached. + * portfolio app switching between N portfolios), all cached, and the heap + * the memo holds for them. * * Local-only; not part of CI benchmark tracking. Run with: * yarn workspace example-benchmark start:memo-tradeoffs [filter] @@ -237,6 +238,13 @@ for (const n of [1, 10, 100, 1000]) { for (const args of portfolios) { store = normalize(StockSchema, buildStockData(ROWS), args, store, meta()); } + // heap held once every portfolio is cached + const bytes = retained(memo => { + for (const args of portfolios) { + memo.denormalize(StockSchema, store.result, store.entities, args); + } + return store; + }); const memo = new MemoCache(); const last = new Array(n); let hits = 0; @@ -261,6 +269,7 @@ for (const n of [1, 10, 100, 1000]) { scenario: name, 'µs/read': us.toFixed(1), 'hit rate': `${((hits / reads) * 100).toFixed(0)}%`, + 'memo retains': `${(bytes / 1024 / 1024).toFixed(2)} MB`, }); } diff --git a/examples/benchmark/normalizr.js b/examples/benchmark/normalizr.js index e283969a27c4..40c638456933 100644 --- a/examples/benchmark/normalizr.js +++ b/examples/benchmark/normalizr.js @@ -25,6 +25,9 @@ import { StockScalarValuesSchema, buildStockData, buildStockScalarUpdate, + PortfolioScalar, + HoldingSchema, + buildHoldingData, } from './schemas.js'; import userData from './user.json' with { type: 'json' }; @@ -91,6 +94,34 @@ const stockUpdatedState = normalize( globalThis.__scalarChurn = () => denormalize(Stock, 's-0', stockState.entities, stockArgs); +/** A new `entities` with one row of `key` replaced by a new object. */ +function replaceRow(entities, key, pk) { + const table = entities[key]; + return { ...entities, [key]: { ...table, [pk]: { ...table[pk] } } }; +} +const projectKey = ProjectWithBuildTypesDescription.key; +const firstProject = result.project[0]; +const lastProject = result.project[result.project.length - 1]; +const stockCell = `${Stock.key}|s-350|${stockArgs[0].portfolio}`; + +// Holdings join Stocks (portfolio lens) and have their own currency lens. Each +// store is missing a different quarter of the Stocks, so the list reaches the +// two lenses in a different order in each. +const holdingArgs = [{ portfolio: 'portfolioA', currency: 'EUR' }]; +const holdingState = normalize( + HoldingSchema, + buildHoldingData(700), + holdingArgs, + initialState, + actionMeta, +); +const withMissingStocks = offset => { + const stocks = { ...holdingState.entities[Stock.key] }; + for (let i = offset; i < 700; i += 4) delete stocks[`s-${i}`]; + return { ...holdingState.entities, [Stock.key]: stocks }; +}; +const holdingStores = [withMissingStocks(0), withMissingStocks(2)]; + export default function addNormlizrSuite(suite, filter) { const memo = new MemoCache(); // prime the cache @@ -159,6 +190,22 @@ export default function addNormlizrSuite(suite, filter) { valuesState.entities, ); }); + add('denormalizeLong Scalar', () => { + return new MemoCache().denormalize( + StockSchema, + stockState.result, + stockState.entities, + stockArgs, + ); + }); + add('denormalizeLong two Scalars, 25% Stocks missing', () => { + return new MemoCache().denormalize( + HoldingSchema, + holdingState.result, + holdingStores[0], + holdingArgs, + ); + }); add('denormalizeLong donotcache', () => { return denormalize(ProjectSchema, result, entities); }); @@ -225,6 +272,23 @@ export default function addNormlizrSuite(suite, filter) { }, }, ); + // steady state: each op is a store update that replaces one project, then + // a read of the cached list + for (const [row, pk] of [ + ['first', firstProject], + ['last', lastProject], + ]) { + const memoUpdate = new MemoCache(); + memoUpdate.denormalize(ProjectSchema, result, entities, []); + add(`denormalizeLong withCache update ${row} row`, () => { + return memoUpdate.denormalize( + ProjectSchema, + result, + replaceRow(entities, projectKey, pk), + [], + ); + }); + } for (const [hole, holeEntities] of nestedHoles) { // one MemoCache each, so the variants don't evict each other's result const memoHoles = new MemoCache(); @@ -257,6 +321,41 @@ export default function addNormlizrSuite(suite, filter) { stockArgs, ); }); + const memoScalarCell = new MemoCache(); + memoScalarCell.denormalize( + StockSchema, + stockState.result, + stockState.entities, + stockArgs, + ); + add('denormalizeLong Scalar withCache update one cell', () => { + return memoScalarCell.denormalize( + StockSchema, + stockState.result, + replaceRow(stockState.entities, PortfolioScalar.key, stockCell), + stockArgs, + ); + }); + // two stores whose lists reach the two lenses in different orders, read in + // turn, so list nodes keep more than one argsKey branch + const memoHolding = new MemoCache(); + let holdingRead = 0; + for (const store of holdingStores) { + memoHolding.denormalize( + HoldingSchema, + holdingState.result, + store, + holdingArgs, + ); + } + add('denormalizeLong two Scalars, 25% Stocks missing withCache', () => { + return memoHolding.denormalize( + HoldingSchema, + holdingState.result, + holdingStores[holdingRead++ & 1], + holdingArgs, + ); + }); add('denormalizeLong All withCache', () => { return memo.denormalize( AllProjects, @@ -299,6 +398,8 @@ export default function addNormlizrSuite(suite, filter) { printStatus(ProjectWithBuildTypesDescription.prototype.pk); printStatus(Entity.merge); printStatus(Entity.validate); + printStatus(WeakDependencyMap.prototype.get); + printStatus(WeakDependencyMap.prototype.set); } }); } diff --git a/examples/benchmark/schemas.js b/examples/benchmark/schemas.js index 2ae156083d81..2a19b54a4fc0 100644 --- a/examples/benchmark/schemas.js +++ b/examples/benchmark/schemas.js @@ -179,6 +179,43 @@ export function buildStockScalarUpdate(count = 700) { return { stock }; } +/** A position in a Stock, with its own Scalar on a different lens. A row whose + * Stock is missing skips the portfolio `argsKey`, so rows reach the two lenses + * in different orders. */ +export class Holding extends Entity { + id = ''; + stock = undefined; + fx = 0; + + static key = 'Holding'; + pk() { + return this.id; + } +} +export const CurrencyScalar = new Scalar({ + lens: args => args[0]?.currency, + key: 'currency', + entity: Holding, +}); +Holding.schema = { + stock: Stock, + fx: CurrencyScalar, +}; +export const HoldingSchema = { + holding: [Holding], +}; + +export function buildHoldingData(count = 700) { + const { stock } = buildStockData(count); + return { + holding: stock.map((s, i) => ({ + id: `h-${i}`, + stock: s, + fx: 1 + (i % 50) / 100, + })), + }; +} + // Degenerate bidirectional chain for #3822 stack overflow testing export class Department extends Entity { id = ''; From 9d057e39279d54e02272e52b8b550d7e90631dea Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sun, 25 Oct 2026 00:55:33 -0400 Subject: [PATCH 16/18] bench: Give memo-tradeoffs an 8 GB heap The 1000-portfolio argsKey row retains about 3 GB, past Node default heap. Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/benchmark/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/benchmark/package.json b/examples/benchmark/package.json index 469ac1859a84..6f8a7f11e4c8 100644 --- a/examples/benchmark/package.json +++ b/examples/benchmark/package.json @@ -15,7 +15,7 @@ "start": "NODE_ENV=production node --allow-natives-syntax ./index.js", "start:memory": "NODE_ENV=production node ./memory.js", "start:ephemerons": "NODE_ENV=production node ./ephemerons.js", - "start:memo-tradeoffs": "NODE_ENV=production node ./memo-tradeoffs.js", + "start:memo-tradeoffs": "NODE_ENV=production node --max-old-space-size=8192 ./memo-tradeoffs.js", "start:trace": "yarn run start --trace_opt --trace_deopt", "start:deopt": "NODE_ENV=production npx dexnode --out v8.log --redirect-code-traces-to=/tmp/codetrace --allow-natives-syntax ./index.js" }, From 58494d1b4e7345b9cbc1744e7547c458c44bf29d Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sun, 25 Oct 2026 01:24:46 -0400 Subject: [PATCH 17/18] fix(normalizr): Bound argsKey forks under one memo root Missing entities change which argsKey a computation reaches next, so each distinct sequence added a route that was never freed, and every lookup searched all of them. Past 8 forks under one first dependency, the root starts over. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/__tests__/WeakDependencyMap.test.ts | 22 +++++++++++++++++++ .../normalizr/src/memo/WeakDependencyMap.ts | 16 +++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts index 199f27751fb0..f635fc7afd4c 100644 --- a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts +++ b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts @@ -421,6 +421,28 @@ describe('WeakDependencyMap eviction', () => { ).toBeUndefined(); }); + it('bounds the routes a lookup searches', () => { + // each entry reaches a different argsKey first, as missing entities + // can make happen; lookups must not slow down as routes pile up + const wem = new WeakDependencyMap(); + let calls = 0; + const fns = Array.from({ length: 40 }, (_, i) => (a: readonly any[]) => { + calls++; + return `${i}${a[0]?.x}`; + }); + const getEntity = MemoPolicy.getEntities({ A: { '1': root } }); + fns.forEach((path, i) => + wem.set([rootDep, fn(path)], `v${i}`, [{ x: 1 }]), + ); + calls = 0; + // every route is valid for these args, so any of the kept ones may hit + expect(wem.get(root, getEntity, [{ x: 1 }])[0]).toBeDefined(); + expect(calls).toBeLessThanOrEqual(10); + // a long chain of one argsKey after another is not a fork + wem.set([rootDep, ...fns.map(fn)], 'chain', [{ x: 1 }]); + expect(wem.get(root, getEntity, [{ x: 2 }])[0]).toBeUndefined(); + }); + it('evicts inside an argsKey child as at the root', () => { const wem = new WeakDependencyMap(); const [b2, b3, b4, b5] = [[2], [3], [4], [5]]; diff --git a/packages/normalizr/src/memo/WeakDependencyMap.ts b/packages/normalizr/src/memo/WeakDependencyMap.ts index 7a8bf489e387..5819d617ab2a 100644 --- a/packages/normalizr/src/memo/WeakDependencyMap.ts +++ b/packages/normalizr/src/memo/WeakDependencyMap.ts @@ -86,6 +86,7 @@ export default class WeakDependencyMap< if (dependencies.length < 1) throw new KeySize(); const root = depKey(dependencies[0].entity) as K; let node = this.next.get(root); + let top = node; const paths = new Array(dependencies.length - 1); const keys = new Array(dependencies.length - 1); for (let i = 1; i < dependencies.length; i++) { @@ -96,12 +97,19 @@ export default class WeakDependencyMap< const k = (dep.path as KeyFn)(args) ?? UNDEF_KEY; keys[i - 1] = k; if (!node) { - node = newNode(undefined); + node = top = newNode(undefined); this.next.set(root, node); } if (!node.branches) node.branches = []; let branch = node.branches.find(b => b.fn === dep.path); if (!branch) { + // Missing entities change which argsKey comes next, so the routes + // under one root can multiply, and a lookup searches all of them. + // Past MAX_FORKS, start this root over instead. + if (node.branches.length && ++top!.forks > MAX_FORKS) { + this.next.delete(root); + return this.set(dependencies, value, args, journey); + } branch = { fn: dep.path as KeyFn, children: new Map() }; node.branches.push(branch); } @@ -214,6 +222,9 @@ const EMPTY = [undefined, undefined] as const; /** Entries kept per (first dependency, `argsKey` results). */ const MAX_ENTRIES = 3; +/** Nodes under one first dependency that may gain a second `argsKey` branch. */ +const MAX_FORKS = 8; + /** Entries for one set of `argsKey` results, most recently used first, and the * branches on the next one. */ interface Node { @@ -222,6 +233,8 @@ interface Node { kept: Entry | undefined; /** One per next `argsKey` dependency; its result picks the child. */ branches: Branch[] | undefined; + /** On a first dependency's node: extra branches made anywhere below it. */ + forks: number; } interface Branch { @@ -234,6 +247,7 @@ function newNode(entry: Entry | undefined): Node { entries: entry ? [entry] : [], kept: entry, branches: undefined, + forks: 0, }; } From 0d7efa3639ec4b4f06623b0f507624ac858696ef Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sun, 25 Oct 2026 01:39:38 -0400 Subject: [PATCH 18/18] fix(normalizr): Treat a throwing argsKey selector as a memo miss A branch is tried before the entities that led to it are checked, so a selector can run with args its schema path would never reach with, such as when the nested entity holding a Scalar is now missing. Traversal would not have called it, so a throw is no match rather than an error. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/__tests__/WeakDependencyMap.test.ts | 13 +++++++++++++ packages/normalizr/src/memo/WeakDependencyMap.ts | 15 ++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts index f635fc7afd4c..3d11eb35359e 100644 --- a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts +++ b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts @@ -421,6 +421,19 @@ describe('WeakDependencyMap eviction', () => { ).toBeUndefined(); }); + it('treats a selector that throws on unreached args as a miss', () => { + // the Scalar inside C reads args[0].portfolio; with C missing, traversal + // never calls it, so a lookup must not fail on it either + const wem = new WeakDependencyMap(); + const strict = (a: readonly any[]) => a[0].portfolio; + wem.set([rootDep, c(loaded), fn(strict)], 'with C', [{ portfolio: 'P' }]); + expect(() => wem.get(root, withC(undefined), [])).not.toThrow(); + expect(wem.get(root, withC(undefined), [])[0]).toBeUndefined(); + expect(wem.get(root, withC(loaded), [{ portfolio: 'P' }])[0]).toBe( + 'with C', + ); + }); + it('bounds the routes a lookup searches', () => { // each entry reaches a different argsKey first, as missing entities // can make happen; lookups must not slow down as routes pile up diff --git a/packages/normalizr/src/memo/WeakDependencyMap.ts b/packages/normalizr/src/memo/WeakDependencyMap.ts index 5819d617ab2a..7ed2d32d1034 100644 --- a/packages/normalizr/src/memo/WeakDependencyMap.ts +++ b/packages/normalizr/src/memo/WeakDependencyMap.ts @@ -184,7 +184,8 @@ function find( if (branches) { for (let b = 0; b < branches.length; b++) { const { fn, children } = branches[b]; - const child = children.get(fn(args) ?? UNDEF_KEY); + const key = select(fn, args); + const child = key === undefined ? undefined : children.get(key); if (!child) continue; if (!next) next = child; else (pending || (pending = [])).push(child); @@ -194,6 +195,18 @@ function find( } } +/** A branch's `argsKey` result. A branch is tried before the entities that + * led to it are checked, so its selector can see args its own schema path + * would never reach with (say, a nested entity that is now missing): treat a + * throw as no match, since traversal wouldn't have called it. */ +function select(fn: KeyFn, args: readonly any[]): string | undefined { + try { + return fn(args) ?? UNDEF_KEY; + } catch { + return undefined; + } +} + function matches( entry: Entry, getDependency: GetDependency,