Skip to content

fix(normalizr): Keep memo WeakMaps one level deep - #4459

Open
ntucker wants to merge 29 commits into
fix/memo-dependency-gapsfrom
fix/memo-ephemeron-depth
Open

ntucker wants to merge 29 commits into
fix/memo-dependency-gapsfrom
fix/memo-ephemeron-depth

Conversation

@ntucker

@ntucker ntucker commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

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 getExchangeInfo with useSuspense(). The symbols join Tickers, 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

  • Behavior: memoized reads keep the same results and identities, but they no longer slow down Hermes's garbage collector. Cached reads are also faster on every platform.
  • Internal: WeakDependencyMap keys each cached value on its first dependency only, in one WeakMap. The entry keeps the other dependencies in an array, and get compares them by identity. argsKey results branch on a Map, so a read checks only the entries of its argument set.
  • Internal: each node keeps one argsKey branch per function, because a missing entity can change which argsKey a computation reaches first. find() walks the branches with a loop, since a list can record one argsKey per row. matches() checks only entity dependencies: the route to an entry already picked every argsKey result.
  • Trade-off: master kept every version while its entities were alive. Now each (first dependency, argument set) keeps a maximum of three entries, most recently used first. The entry that the store last flipped back to is never evicted. A store flips back when React renders the committed store between retries of a pending transition, or when an optimistic update rolls back.

Architecture change

The memo cache no longer nests one WeakMap per dependency.

dataclient-memo-weakmap-depth.mp4

Take one result with N entity dependencies. On master, set builds a chain: a WeakMap keyed by entity 1 holds a Link, whose WeakMap is 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 every WeakMap entry 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 every WeakMap slot 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 set makes one WeakMap entry, 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:ephemerons replays Hermes's marking loop on a V8 heap snapshot:

    Scenario master passes master entry visits/GC this PR passes this PR visits/GC
    3,736 symbols join tickers, 500 tickers × 60 ticks 7,474 251,544,944 4 104,732
    3,736 symbols, no join 3,738 42,011,382 3 22,509
    100-row trade tape, 3 prints × 600 flushes 102 563,958 3 16,287
  • 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 useReducer updates 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 read optimisticState and managers read the raw state.

  • High: concurrent-memo.web.tsx runs 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. useSuspense serves urgent re-renders of an unchanged store from its own useMemo, 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-500 is −4% and list-detail-switch-10 −3%. Node examples/benchmark shows cached reads 35–96% faster, cold paths within ±5%, and buildQueryKey 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 WeakDependencyMap is covered, including the extra-branch stack in find(), eviction inside an argsKey child node, and the same argsKey fn repeated per row. Codecov checks the full run.

  • Not tested: Hermes on iOS, and React Native apps other than native-binance.

Methodology

  • Root cause: HermesInternal.getInstrumentedStats() on the device during the freezes, plus allocation sampling. About 10,000 WeakMaps per second were created, 85% from WeakDependencyMap.set through GlobalCache.getResults.
  • Repro: examples/benchmark/ephemerons.js counts the marking passes and entry visits that Hermes would do, so it works on any engine.
  • Rejected: two entries per node (a second reader of the same result evicted the first), and a "flipped" rule without the first-entry pin (Fable's review found that it drops to one useful slot).

GOALS impact

  • 60 FPS, no blocking stutters: strongly positive on React Native. The 0.6–3.3s Hermes GC freezes do not occur.
  • First class React Native support: positive. This removes a failure mode specific to Hermes.
  • Speed: positive. See the benchmark numbers above.
  • Memory: negative. The memo holds some stale versions strongly: 15.4 MB vs 9.8 MB in the 3,736-symbol case, and 12.9 MB vs 12.3 MB for a 10,000-row list refetched 20 times.
  • Bundle size: negative, small. +263 B brotli to @data-client/normalizr over 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 one argsKey branch per function and walks them with a loop.
  • Referential stability: slightly negative, bounded. One extra recompute in the case above. feat(normalizr): Keep results a committed render read #4461 closes it.
  • V8 optimization: neutral to positive. No deopt loops. get deopts 5 times (before the review fixes: 7; master: 3), and the new find 3 times during warm-up. get reaches TurboFan where it stopped at Maglev before. No megamorphic inline caches; one length site in the lookup loop is polymorphic with 2 maps, as before.

Open questions

  • Is +5.6 MB of memo memory in the 3,736-symbol case acceptable, or should the keep policy drop stale versions sooner?

🤖 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.

WeakDependencyMap is restructured: only the first dependency is a WeakMap key; remaining deps live on flat entries checked by identity on get. argsKey paths branch via Maps (one branch per function), with iterative find() 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_FORKS caps 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-memo tolerates ≤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.

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-bot

changeset-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e6ef582

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@data-client/normalizr Patch
@data-client/core Patch
@data-client/react Patch
@data-client/vue Patch
example-benchmark Patch
normalizr-github-example Patch
normalizr-redux-example Patch
normalizr-relationships Patch
example-benchmark-react Patch
test-bundlesize Patch
coinbase-lite Patch

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

codecov Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.27%. Comparing base (595ead9) to head (e6ef582).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Docs preview: https://docs-site-pr-4459-data-client.vercel.app

Built from abe418b merged into master (this build). English only: this PR doesn't touch translations.

@ntucker

ntucker commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

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 HermesInternal.getInstrumentedStats().

0.18.2 this PR
WeakMaps created 1,066,584 16,905
total Hermes GC time 27.9 s 0.49 s
JS-thread stalls over 250 ms 12, growing from 0.6 s to 3.3 s, one per old-gen collection none caused by GC
live heap after GC (end of run) 43.9 MB 40.8 MB

Note for RN testing: Metro resolves @data-client/normalizr to dist/normalizr.js. The import condition (node.mjs, which re-exports dist) matches before react-native, so patching lib/ has no effect on a React Native build.

@ntucker
ntucker marked this pull request as ready for review October 24, 2026 18:23
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-26T15:34:28.486564Z e6ef582 New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Size Change: +558 B (+0.5%)

Total Size: 112 kB

📦 View Changed
Filename Size Change
examples/test-bundlesize/dist/rdcClient.js 11 kB +187 B (+1.73%)
examples/test-bundlesize/dist/rdcNextjs.js 12.6 kB +186 B (+1.5%)
examples/test-bundlesize/dist/rdcPipeableStream.js 9.83 kB +185 B (+1.92%)
ℹ️ View Unchanged
Filename Size
examples/test-bundlesize/dist/App.js 1.45 kB
examples/test-bundlesize/dist/polyfill.js 301 B
examples/test-bundlesize/dist/rdcEndpoint.js 8.04 kB
examples/test-bundlesize/dist/react.js 68.6 kB
examples/test-bundlesize/dist/webpack-runtime.js 698 B

compressed-size-action

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +41 to +43
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (matches(entry, getDependency, args))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

ntucker and others added 4 commits October 24, 2026 14:51
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>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/normalizr/src/memo/WeakDependencyMap.ts
Comment thread packages/normalizr/src/memo/WeakDependencyMap.ts Outdated
…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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +43 to +45
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (matches(entry, getDependency, args)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

ntucker and others added 2 commits October 24, 2026 17:46
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>
@ntucker
ntucker changed the base branch from master to fix/memo-dependency-gaps October 24, 2026 21:56
@ntucker

ntucker commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

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 depKey() everywhere WeakDependencyMap keys an entity. valueKeys only ever holds null and INVALID, so the module-level Map stays bounded. Not run locally; I am relying on CI for the new MemoCache tests.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +108 to +110
node.fn = dep.path as KeyFn;
if (!node.children) node.children = new Map();
let child = node.children.get(k);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread packages/normalizr/src/memo/WeakDependencyMap.ts
ntucker and others added 2 commits October 24, 2026 22:38
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>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

ntucker and others added 4 commits October 24, 2026 23:27
# 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>
ntucker added a commit that referenced this pull request Oct 25, 2026
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>
ntucker and others added 3 commits October 25, 2026 00:34
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +179 to +182
const child = children.get(fn(args) ?? UNDEF_KEY);
if (!child) continue;
if (!next) next = child;
else (pending || (pending = [])).push(child);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

ntucker and others added 3 commits October 25, 2026 01:39
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>

This branch was successfully deployed

1 active (outdated) deployment
docs-preview — abe418bc Deployed Oct 24, 2026 by ntucker via docs deploy #3852
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants