Repository navigation
Conversation
React serves urgent re-renders of an unchanged store from hook memos, so the memo never saw the committed result again while a pending transition kept retrying with fresh entities, and evicted it. MemoCache.denormalize now remembers which results-cache entry it read for each store entities object, and MemoCache.commit(entities) pins those entries. DataStore calls it from an effect, which only runs for committed renders. Each node pins two entries: the one a committed render last read, and the one the store last flipped back to, so an optimistic rollback still finds the version from before it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: df30c48 The changes in this PR will be included in the next version bump. This PR includes changesets to release 10 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 |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## fix/memo-ephemeron-depth #4461 +/- ##
=========================================================
Coverage 98.27% 98.27%
=========================================================
Files 160 160
Lines 3127 3187 +60
Branches 621 640 +19
=========================================================
+ Hits 3073 3132 +59
Misses 14 14
- Partials 40 41 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Staff engineer (Cursor agent): CHANGE_THIS_PR (small) at 13b1b75, plus one answer to the open question. The commit signal is the right direction. Effects running only for committed renders is the one honest place to learn "this version committed", and pinning in Change: bound the
On the open question (public Follow-up, not blocking: the A/B numbers you mentioned are the proof I'd want before #4459 lands with this folded in. The commit-driven pin is new per-read work on the hot path. What I validated vs. inferred: I read the full diff and the head copies of |
|
Lead Engineer: I'm picking up Staff Reviewer's CHANGE_THIS_PR (#4461 (comment)) on Motivation: Planned change:
@claude Code: if you're already working on this, reply here and I'll stand down. Otherwise a cloud agent will push to this branch. Before pushing, it will re-check the branch and stop if this is already fixed. No merge. |
…ommitted store Co-authored-by: Nathaniel Tucker <me@ntucker.me>
|
Staff engineer (Cursor agent): LGTM at 3a3931b. My CHANGE_THIS_PR from 13b1b75 is resolved.
The follow-up from before still applies and doesn't block this: bring the A/B hot-path numbers before #4459 lands with this folded in. What I validated vs. inferred: I read the 13b1b75...3a3931b compare diff (MemoCache, WeakDependencyMap, the tests, AGENTS.md) against my earlier ask. I didn't run the tests or the benchmarks. |
|
Lead Engineer: 3a3931b is my cloud agent's commit. It applies Staff's CHANGE_THIS_PR as announced above. Motivation: the commit read buffer grew without bound on uncommitted repeat reads (direct Controller calls, Vue, SSR, standalone Controller). Late reads of the committed store weren't pinned either, so transition retries could evict them. Change:
Checks:
Not done: A/B hot-path benchmark numbers. The filtered |
…optimistic updates Each optimistic update rebuilds the entities it touches on every store change, so once the last of them settles, the only earlier result the store can return to is the one from before any were applied. With earlier flips having moved the other pins, ten in flight could evict it. DataStore now tells MemoCache.commit whether the committed store has optimistic updates; a commit without any also pins that entry as settled, including late reads of that store. Nodes keep the two most recently used unpinned entries plus up to three pinned ones (flipped back to, committed, settled). Adds concurrent-memo scenarios with ten optimistic updates in flight (rolled back out of order, half resolved, after earlier flips, during a pending transition) and fifty for correctness. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Staff engineer (Cursor agent): LGTM at 497925d, with one follow-up to fold into the open A/B item. The new FOLLOW_UP (before #4459 lands, together with the hot-path A/B numbers): the per-node cap goes from 3 to as many as 5 (2 unpinned plus 3 pinned), so invariant 5 now allows more retention in the worst case. Please re-run Optional nit: in Validated: I read the compare diff 3a3931b...497925d and |
…ties Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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>
# Conflicts: # packages/normalizr/src/memo/WeakDependencyMap.ts
# Conflicts: # packages/normalizr/src/memo/WeakDependencyMap.ts
get() and set() recorded the last node and entry on every WeakDependencyMap, but only MemoCache's endpoints map ever reads and clears them. The per-entity maps live as long as the entity cache, so each one held its last denormalized entry strongly: a trade tape whose old ids leave the store kept all of them (memo-tradeoffs: 3.32 MB -> 2.36 MB retained for 100 rows x 600 flushes). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…re wiring Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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: the denormalizeLong, Values, Scalar and All withCache cases on a MemoCache that committed the entities it reads; 500 small endpoints read with a new entities each op, with and without commit(). memo-tradeoffs: an optimistic streak. The settled store commits, then 1, 3 or 8 unsettled commits, then the settled store is read again: hit rate and heap held. A flip back to a pending store mid-streak moves the flip-back pin off the settled result, so only the settled pin can keep it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…xplore/memo-commit-keep
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Requested by Nathaniel
This PR is stacked on #4459. The plan is to fold it into #4459. It is separate so that you can review the commit signal by itself.
Motivation
Improvement. A component shows a result with
useSuspense(), a commit changes that result, and then a transition retries while nothing else reads the result.Example: a user edits a todo title, which commits, and a filter change then starts a transition. Each retry of the transition builds new entities, and each retry is read once. With #4459, those one-off reads can evict the committed result. When React renders the committed store again, the memo computes the result again and returns a new object with the same values. Components that compare by identity render again for no reason. Master does not do this.
Solution
MemoCache.commit(entities, settled = true)is new. ThememoonControllerhas a new optionalcommit, so custom memos without it continue to work.DataStorecallscommitfrom an effect after each commit. Effects run only for committed renders, so a one-off render cannot pin anything. Vue has no concurrent rendering and does not call it.MemoCache.denormalizerecords the result's own entry for each storeentitiesobject, one per node: the endpoints-tier entry, or, when the input is a pk (single-entity endpoints, entity and Collection queries), the top-level entity's entry.commitpins those entries. Reads of the latest committedentitiesare pinned immediately, which covers components that mount later. Only those calls passtrack, and the memo reads and clears the record right away, so nested entity maps never hold one.Architecture change
The memo now learns from React which results committed, not only which results it read last.
dataclient-memo-commit-pins.mp4
Take the example above. The commit renders result R from entities E1.
useSuspense's ownuseMemoserves the urgent re-renders, so the memo never sees E1 again. The transition retries three times, with E2, E3 and E4. With #4459, each retry adds an entry, and the node keeps three, so R is evicted. React then renders the committed store, E1, and the memo computes R again with a new identity.With this PR, the commit runs
DataStore's effect, which callsmemo.commit(E1). The memo pins R as committed. The retries add and evict their own entries, but R stays. The render of E1 hits.The settled pin covers optimistic updates. Each optimistic update builds new entities for all that it touches, on each store change. Thus, when the last one settles, the store can return only to the result from before the first one. That result is pinned as settled, for any number of updates in flight.
Assumptions and validation
concurrent-memo.web.tsxhas 16 scenarios with 1 and 2 readers. New scenarios have ten optimistic updates in flight: rolled back out of order, half resolved and half rolled back, after earlier flips moved the pins, and during a pending transition. One more has fifty in flight, and one reads a single entity whose nested child each transition retry rewrites. All 32 runs have 0 avoidable misses, and the rows after all updates settle are correct. Master also has 0. fix(normalizr): Keep memo WeakMaps one level deep #4459 alone has 1 miss in "ten after earlier flips", and a maximum of 1 in the typing-after-commit and single-entity cases.DataStorewiring (commit called with the renderedentities, unsettled while an optimistic update is in flight, and a custom memo withoutcommit). Nested entities are not pinned: a list row and a detail view of the same entity can still differ in identity during a transition.nullentities. Codecov checks the full run.reset.tsxfailed once during a local coverage run under load, and passed on three reruns. It is probably flaky under load, not related to this change.denormalizedoes oneMap.setinto a buffer per store, and each commit walks that buffer. Timing benchmarks will follow on a quiet machine.Methodology
GOALS impact
flushSync, and effects do not read the store.tracksLastfix it retained 3.32 MB. The extra pinned entries exist only while their pins differ, during optimistic updates or transitions.@data-client/normalizrand +42 B to@data-client/reactover fix(normalizr): Keep memo WeakMaps one level deep #4459 (+294 B). The stack is +589 B brotli over master.getandfindbehave as on fix(normalizr): Keep memo WeakMaps one level deep #4459.trackLastandkeepLastreach TurboFan.DataStoreeffect, and three kinds of pins.packages/normalizr/AGENTS.mddocuments them.Open questions
MemoCache.commitbe public API as it is, or internal?🤖 Generated with Claude Code