Repository navigation
Conversation
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) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: e6ef582 The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## fix/memo-dependency-gaps #4459 +/- ##
============================================================
+ Coverage 98.25% 98.27% +0.01%
============================================================
Files 160 160
Lines 3094 3127 +33
Branches 607 621 +14
============================================================
+ Hits 3040 3073 +33
Misses 14 14
Partials 40 40 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Docs preview: https://docs-site-pr-4459-data-client.vercel.app Built from abe418b merged into |
|
On-device results: native-binance release build on a Samsung S948U1 (Android 17, Hermes). The run cycles Book ↔ Trades for about 2 minutes on the BTCUSDT screen while tickers, depth and trades stream. Counts come from
Note for RN testing: Metro resolves |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Size Change: +558 B (+0.5%) Total Size: 112 kB 📦 View Changed
ℹ️ View Unchanged
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3c43ac120b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // 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; |
There was a problem hiding this comment.
Preserve weak ownership of query-cache dependencies
When entities are read through MemoCache.query() or buildQueryKey(), storing every dependency strongly here prevents those entities from being garbage-collected after removal from the store. MemoCache.queryKeys retains a separate cache per serialized argument list, and BaseDelegate.tracked() uses the schema as its first dependency; that schema normally lives for the application's lifetime. Consequently, each queried ID permanently retains its last entity, and queries that inspect entity tables can retain entire historical tables. The two-variant limit does not bound this across argument lists. An isolated GC check against the parent and this commit confirms that a released dependency is collected before this change but retained afterward. Preserve non-owning dependencies for these schema-rooted caches or add corresponding cache eviction.
AGENTS.md reference: packages/normalizr/AGENTS.md:L8-L8
Useful? React with 👍 / 👎.
| for (let i = 0; i < entries.length; i++) { | ||
| const entry = entries[i]; | ||
| if (matches(entry, getDependency, args)) |
There was a problem hiding this comment.
Index argument buckets before matching entity dependencies
For args-dependent schemas that reuse an entity across many argsKey values, every cached read now scans entries belonging to unrelated argument buckets. The two-entry limit applies separately to each bucket, so lookup work grows with all previously visited buckets instead of using the previous string-keyed Map lookup. Running the same dependency sequence against the parent and this commit, with 1,000 argument buckets, increases a hit on the first bucket from one selector call and one entity lookup to 1,000 of each; longer shared dependency prefixes amplify that cost further. This affects both endpoint and per-entity caches, so reading large lists with Scalar lenses can progressively slow down despite unchanged data. Keep a shallow bucket index so matching only walks variants for the requested arguments.
AGENTS.md reference: packages/normalizr/AGENTS.md:L34-L34
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Benchmark React
Details
| Benchmark suite | Current: abe418b | Previous: 8817f47 | Ratio |
|---|---|---|---|
data-client: getlist-100 |
165.3 ops/s (± 9.2%) |
222.22 ops/s (± 8.5%) |
1.34 |
data-client: getlist-500 |
45.69 ops/s (± 7.8%) |
73.27 ops/s (± 6.4%) |
1.60 |
data-client: update-entity |
476.19 ops/s (± 6.4%) |
666.67 ops/s (± 6.1%) |
1.40 |
data-client: update-user |
526.32 ops/s (± 3.2%) |
625 ops/s (± 6.8%) |
1.19 |
data-client: getlist-500-sorted |
46.19 ops/s (± 7.6%) |
64.78 ops/s (± 9.3%) |
1.40 |
data-client: update-entity-sorted |
425.72 ops/s (± 5.3%) |
444.66 ops/s (± 10.7%) |
1.04 |
data-client: update-entity-multi-view |
425.72 ops/s (± 9.7%) |
555.56 ops/s (± 7.0%) |
1.30 |
data-client: list-detail-switch-10 |
10.32 ops/s (± 10.8%) |
20.64 ops/s (± 8.0%) |
2 |
data-client: update-user-10000 |
125 ops/s (± 8.3%) |
125.79 ops/s (± 11.2%) |
1.01 |
data-client: invalidate-and-resolve |
47.97 ops/s (± 6.2%) |
60.61 ops/s (± 7.1%) |
1.26 |
data-client: unshift-item |
250 ops/s (± 11.2%) |
384.62 ops/s (± 0.0%) |
1.54 |
data-client: delete-item |
434.78 ops/s (± 7.0%) |
555.56 ops/s (± 9.1%) |
1.28 |
data-client: move-item |
232.56 ops/s (± 8.3%) |
307.77 ops/s (± 6.0%) |
1.32 |
This comment was automatically generated by workflow using github-action-benchmark.
There was a problem hiding this comment.
Benchmark
Details
| Benchmark suite | Current: abe418b | Previous: 8817f47 | Ratio |
|---|---|---|---|
normalizeLong |
392 ops/sec (±2.39%) |
430 ops/sec (±3.98%) |
1.10 |
normalizeLong Values |
345 ops/sec (±1.46%) |
385 ops/sec (±1.23%) |
1.12 |
normalizeLong Scalar |
334 ops/sec (±3.72%) |
347 ops/sec (±4.24%) |
1.04 |
normalizeLong Scalar update |
888 ops/sec (±0.27%) |
908 ops/sec (±0.60%) |
1.02 |
denormalizeLong |
211 ops/sec (±5.96%) |
246 ops/sec (±6.06%) |
1.17 |
denormalizeLong Values |
204 ops/sec (±4.12%) |
230 ops/sec (±5.69%) |
1.13 |
denormalizeLong donotcache |
964 ops/sec (±0.65%) |
1008 ops/sec (±0.14%) |
1.05 |
denormalizeLong Values donotcache |
713 ops/sec (±0.38%) |
745 ops/sec (±0.96%) |
1.04 |
denormalizeLong Scalar donotcache |
1002 ops/sec (±0.40%) |
1047 ops/sec (±0.26%) |
1.04 |
denormalizeShort donotcache 500x |
1390 ops/sec (±0.89%) |
1410 ops/sec (±0.24%) |
1.01 |
denormalizeShort 500x |
565 ops/sec (±6.32%) |
643 ops/sec (±7.21%) |
1.14 |
denormalizeShort 500x withCache |
6648 ops/sec (±0.21%) |
6658 ops/sec (±0.07%) |
1.00 |
queryShort 500x withCache |
1283 ops/sec (±5.41%) |
3173 ops/sec (±1.30%) |
2.47 |
buildQueryKey All |
44419 ops/sec (±3.90%) |
59577 ops/sec (±0.48%) |
1.34 |
query All withCache |
7397 ops/sec (±0.36%) |
6010 ops/sec (±2.24%) |
0.81 |
denormalizeLong with mixin Entity |
203 ops/sec (±5.59%) |
214 ops/sec (±7.43%) |
1.05 |
denormalizeLong withCache |
8084 ops/sec (±0.27%) |
6866 ops/sec (±0.32%) |
0.85 |
denormalizeLong withCache (Scalar churn) |
7959 ops/sec (±0.90%) |
6883 ops/sec (±0.28%) |
0.86 |
denormalizeLong Values withCache |
5646 ops/sec (±1.76%) |
5204 ops/sec (±1.30%) |
0.92 |
denormalizeLong Scalar withCache |
6810 ops/sec (±0.28%) |
7633 ops/sec (±0.40%) |
1.12 |
denormalizeLong Scalar update withCache |
3712 ops/sec (±0.28%) |
4107 ops/sec (±0.26%) |
1.11 |
denormalizeLong All withCache |
7538 ops/sec (±0.54%) |
6448 ops/sec (±0.28%) |
0.86 |
denormalizeLong Query-sorted withCache |
7553 ops/sec (±0.32%) |
6194 ops/sec (±1.31%) |
0.82 |
denormalizeLongAndShort withEntityCacheOnly |
2225 ops/sec (±5.53%) |
1715 ops/sec (±0.22%) |
0.77 |
denormalize bidirectional 50 |
6660 ops/sec (±6.41%) |
4626 ops/sec (±10.90%) |
0.69 |
denormalize bidirectional 50 donotcache |
42764 ops/sec (±0.19%) |
43771 ops/sec (±0.20%) |
1.02 |
getResponse |
6975 ops/sec (±1.12%) |
4487 ops/sec (±3.52%) |
0.64 |
getResponse (null) |
9466760 ops/sec (±0.66%) |
10332786 ops/sec (±0.47%) |
1.09 |
getResponse (clear cache) |
186 ops/sec (±6.51%) |
209 ops/sec (±7.61%) |
1.12 |
getSmallResponse |
3378 ops/sec (±1.28%) |
3467 ops/sec (±1.29%) |
1.03 |
getSmallInferredResponse |
1227 ops/sec (±5.00%) |
2811 ops/sec (±0.10%) |
2.29 |
getResponse Collection |
6847 ops/sec (±1.20%) |
4695 ops/sec (±1.81%) |
0.69 |
get Collection |
5097 ops/sec (±0.20%) |
3514 ops/sec (±0.26%) |
0.69 |
get Query-sorted |
5653 ops/sec (±0.32%) |
5093 ops/sec (±1.15%) |
0.90 |
setLong |
387 ops/sec (±1.36%) |
433 ops/sec (±0.20%) |
1.12 |
setLongWithMerge |
232 ops/sec (±1.36%) |
246 ops/sec (±0.98%) |
1.06 |
setLongWithSimpleMerge |
253 ops/sec (±0.48%) |
261 ops/sec (±0.62%) |
1.03 |
setSmallResponse 500x |
905 ops/sec (±0.88%) |
911 ops/sec (±1.30%) |
1.01 |
setMany 50x one-per-row |
143 ops/sec (±0.44%) |
152 ops/sec (±0.45%) |
1.06 |
setMany 50 batch |
3481 ops/sec (±0.54%) |
3558 ops/sec (±0.57%) |
1.02 |
setMany 500x one-per-row |
14.77 ops/sec (±1.16%) |
15.5 ops/sec (±0.67%) |
1.05 |
setMany 500 batch |
1389 ops/sec (±0.24%) |
1398 ops/sec (±0.19%) |
1.01 |
This comment was automatically generated by workflow using github-action-benchmark.
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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ack 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) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 17a35d5651
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| for (let i = 0; i < entries.length; i++) { | ||
| const entry = entries[i]; | ||
| if (matches(entry, getDependency, args)) { |
There was a problem hiding this comment.
Avoid rechecking full dependency lists at every args-tree prefix
When a fixed list of IDs is populated incrementally and its entities contain Scalar fields, each newly available entity adds another argsKey dependency, leaving an intermediate entry at each depth of the argument tree. Cached reads then run matches() on every historical prefix before reaching the current entry, making lookup quadratic even with just one argument bucket. Replaying this through GlobalCache and the Scalar schema with 300 incrementally loaded rows increased entity lookups on an unchanged cached read from 600 in the parent to 90,599 here. Avoid validating complete historical dependency lists at every prefix, or remove superseded prefix entries, so subsequent renders retain linear cached-read cost.
AGENTS.md reference: packages/normalizr/AGENTS.md:L34-L34
Useful? React with 👍 / 👎.
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) <noreply@anthropic.com>
… as accepted Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
WeakDependencyMap keys dependencies through depKey(), so INVALID and null nested entities are tracked here too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Staff engineer (Cursor agent): LGTM at abe418b. Folding #4460 in settles the landing-order question I raised there: one memo-invariants section, invariant 2 no longer lists the INVALID/null gap, and the rules say "all of them" instead of "seven". Validated: read the ecea0af...abe418b compare. The docs-only fence and managers.md changes come from master, not this branch (master...abe418b does not touch them). The rest is the #4460 change I already reviewed at 6d2dbfb, now applied through FOLLOW_UP (small, before release): the v0.19 blog line cites #4460. If #4460 gets closed as folded into this PR, point that line at #4459 (or merge it into the existing #4459 bullet) so the release notes do not link an unmerged PR. |
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts: # packages/normalizr/src/__tests__/WeakDependencyMap.test.ts
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4bbe87a65e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| node.fn = dep.path as KeyFn; | ||
| if (!node.children) node.children = new Map(); | ||
| let child = node.children.get(k); |
There was a problem hiding this comment.
Preserve selector branches when entity dependencies diverge
When an earlier entity is missing in one snapshot but present in another, the first argsKey encountered can change even with an unchanged schema and arguments. For example, a result containing portfolio-dependent A followed by currency-dependent B records B's selector first while A is missing, then A's selector first once A loads. Assigning node.fn here replaces the routing function for both snapshots: with portfolio P and currency USD, the older entry remains stored under USD but lookup now searches P. Reproducing this dependency sequence against the parent returns both cached values; this commit misses in both directions as the snapshots alternate. This defeats rollback/concurrent-render identity preservation even with only two versions, below the three-entry limit. Preserve distinct selector branches when preceding entity dependencies differ instead of overwriting the existing routing function.
AGENTS.md reference: packages/normalizr/AGENTS.md:L7-L7
Useful? React with 👍 / 👎.
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) <noreply@anthropic.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 24525cf. Configure here.
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) <noreply@anthropic.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
# Conflicts: # packages/normalizr/src/__tests__/WeakDependencyMap.test.ts # packages/normalizr/src/memo/WeakDependencyMap.ts
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Commit pins only reached the endpoints tier, so single-entity endpoints, entity queries and Collection queries, whose input is a pk, kept #4459's bound: a retrying transition could rebuild them once. Now the top-level getEntity of a pk input records its entry, and MemoCache pins it the same way. Tracking is per call (`track`), so nested entity maps still never hold a recorded entry. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
The 1000-portfolio argsKey row retains about 3 GB, past Node default heap. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4daa7b2250
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const child = children.get(fn(args) ?? UNDEF_KEY); | ||
| if (!child) continue; | ||
| if (!next) next = child; | ||
| else (pending || (pending = [])).push(child); |
There was a problem hiding this comment.
Bound historical selector routes before searching every branch
When missing entities change which selectors a list reaches, each distinct selector sequence creates another permanently retained route. Lookup searches those routes without checking the entity dependencies that distinguish them until reaching their entries. This is separate from revalidating historical prefixes: even equal-depth routes accumulate beyond the three-entry limit. Replaying 512 missing-entity combinations for 12 rows, each optionally reading a portfolio selector and always reading a currency selector, makes an unchanged cached read execute 3,069 selectors and 1,025 entity lookups; the parent needs only 21 and 12 respectively. Lists with changing missing subsets therefore become progressively slower despite unchanged arguments. Bound historical routes or use entity dependencies to prune them before descending.
AGENTS.md reference: packages/normalizr/AGENTS.md:L58-L58
Useful? React with 👍 / 👎.
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) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 58494d1b4e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (branches) { | ||
| for (let b = 0; b < branches.length; b++) { | ||
| const { fn, children } = branches[b]; | ||
| const child = children.get(fn(args) ?? UNDEF_KEY); |
There was a problem hiding this comment.
Check entity guards before evaluating historical selectors
When an entity containing an args-dependent field disappears, lookup still evaluates its previously recorded selector before checking whether that entity exists in the current store. For example, cache a result with a nested Scalar using args => args[0].portfolio, then read the same input with that nested entity missing and no arguments: the parent returns a cache miss and traversal never reaches the Scalar, whereas this version throws a TypeError from fn(args). This makes otherwise valid missing-entity reads depend on which snapshots were previously cached. Preserve the preceding entity-dependency guards when descending selector branches so selectors from unreachable schema paths are not invoked.
Useful? React with 👍 / 👎.
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) <noreply@anthropic.com>

Requested by Nathaniel
This PR is stacked on #4460. Review #4460 first.
Motivation
Bug fix. On React Native (Hermes), an app reads a large result again and again while its entities stream. The app freezes for 0.5–3s at a time, and the freezes become longer the longer it runs.
Example: native-binance's market screens read a 3,736-symbol
getExchangeInfowithuseSuspense(). The symbols joinTickers, and the tickers stream each second. Taps did nothing for up to 1.5s. On the device, each freeze was one old-generation garbage collection, and it grew from 0.6s to 3.3s in two minutes.Solution
WeakDependencyMapkeys each cached value on its first dependency only, in oneWeakMap. The entry keeps the other dependencies in an array, andgetcompares them by identity.argsKeyresults branch on aMap, so a read checks only the entries of its argument set.argsKeybranch per function, because a missing entity can change whichargsKeya computation reaches first.find()walks the branches with a loop, since a list can record oneargsKeyper row.matches()checks only entity dependencies: the route to an entry already picked everyargsKeyresult.Architecture change
The memo cache no longer nests one
WeakMapper dependency.dataclient-memo-weakmap-depth.mp4
Take one result with N entity dependencies. On master,
setbuilds a chain: aWeakMapkeyed by entity 1 holds aLink, whoseWeakMapis keyed by entity 2, and so on to entity N. Hermes ends each old-gen collection with a stop-the-world loop,markWeakMapEntrySlots. Each pass scans everyWeakMapentry slot in the heap. A value is marked only when its key is marked, and the loop repeats until a pass marks nothing new. Each pass can only reach one level deeper in the chain. So the chain costs N passes over everyWeakMapslot in the app. With thousands of results and thousands of dependencies, this is quadratic. V8 also starts with these passes, but after--ephemeron-fixpoint-iterations(10) it changes to a linear algorithm. This is why the web never showed the problem.Now
setmakes oneWeakMapentry, keyed by entity 1. The value is a node with a short list of entries. Each entry holds plain arrays of the other dependencies. Hermes marks the whole node in one pass. The loop ends after 3 or 4 passes, for any N.Assumptions and validation
High: the Hermes fix.
start:ephemeronsreplays Hermes's marking loop on a V8 heap snapshot:High: on a Samsung S948U1, native-binance ran for 2 minutes with the patched build. Total Hermes GC time went from 27.9s to 0.49s, with no GC stalls.
High: React only shows the memo stable versions, each between one-off reads. React 19.3
useReducerupdates never carry eager state (updateReducerImpl,dispatchReducerAction). Thus a render that applies a pending update builds new entities, and those reads are one-offs. In one committed store, renders readoptimisticStateand managers read the raw state.High:
concurrent-memo.web.tsxruns 10 scenarios with 1 and 2 readers: transitions, optimistic rollbacks, and reads outside render. All 20 runs have 0 avoidable misses, except for the bound below.Medium: the bound.
useSuspenseserves urgent re-renders of an unchanged store from its ownuseMemo, so the memo does not see them. If a commit changes a result while a transition retries and nothing else reads it, the memo can evict the new result once and compute it again once. Two scenarios assert a maximum of 1 such miss. feat(normalizr): Keep results a committed render read #4461 removes it.Medium: speed. benchmark-react (Ryzen 9 7950X, WSL2, Chromium 156, medians of 3 alternating rounds, about ±5% spread) shows updates 18–45% faster. Examples:
update-entity+45%,update-user-10000+38%,invalidate-and-resolve+18%.getlist-500is −4% andlist-detail-switch-10−3%. Nodeexamples/benchmarkshows cached reads 35–96% faster, cold paths within ±5%, andbuildQueryKey All−8%. I measured these on an earlier two-entry version and will run them again on this version.Medium: worst cases (
start:memo-tradeoffs). Reads of one 700-row list against 2 or 3 store snapshots in turn hit 100% and are faster than master. Against 4 or 8 snapshots in turn, they hit only 25% or 13% (master: 100%). React does not make that pattern.Medium: coverage. I ran coverage for the memo and denormalize files only; every branch in
WeakDependencyMapis covered, including the extra-branch stack infind(), eviction inside anargsKeychild node, and the sameargsKeyfn repeated per row. Codecov checks the full run.Not tested: Hermes on iOS, and React Native apps other than native-binance.
Methodology
HermesInternal.getInstrumentedStats()on the device during the freezes, plus allocation sampling. About 10,000WeakMaps per second were created, 85% fromWeakDependencyMap.setthroughGlobalCache.getResults.examples/benchmark/ephemerons.jscounts the marking passes and entry visits that Hermes would do, so it works on any engine.GOALS impact
@data-client/normalizrover fix(normalizr): Track INVALID and null nested entities as memo dependencies #4460, +295 B over master (+8.3%). GOALS ranks bundle size below 60 FPS. This includes the review fix that keeps oneargsKeybranch per function and walks them with a loop.getdeopts 5 times (before the review fixes: 7; master: 3), and the newfind3 times during warm-up.getreaches TurboFan where it stopped at Maglev before. No megamorphic inline caches; onelengthsite in the lookup loop is polymorphic with 2 maps, as before.Open questions
🤖 Generated with Claude Code
Note
Medium Risk
Changes core denormalization memo semantics (bounded eviction, new lookup paths); correctness is heavily tested but referential stability can differ slightly under concurrent React edge cases.
Overview
Fixes multi-second Hermes GC freezes on React Native when large memoized results are re-read while entities stream (e.g. thousands of symbols joining live tickers). The root cause was nested
WeakMaps per dependency, which made Hermes rescan every WeakMap slot once per nesting level on each old-gen collection.WeakDependencyMapis restructured: only the first dependency is aWeakMapkey; remaining deps live on flat entries checked by identity onget.argsKeypaths branch viaMaps (one branch per function), with iterativefind()instead of per-level WeakMaps. Each node keeps at most three cached versions (MRU), plus a pinned “flip-back” entry when the store re-reads a prior version (transitions / optimistic rollback).MAX_FORKScaps alternate argsKey routes under one root.Adds benchmark tooling (
start:ephemerons,start:memo-tradeoffs, extra normalizr cases), AGENTS.md memo invariants, and broad tests for eviction and argsKey behavior.concurrent-memotolerates ≤1 avoidable miss when urgent typing re-renders bypass memo visibility. Patch bumps normalizr, core, react, vue; release notes mention RN freeze fix and slightly higher memo retention.Reviewed by Cursor Bugbot for commit 0d7efa3. Bugbot is set up for automated code reviews on this repo. Configure here.