fix(normalizr): do not mutate cached journey on result-cache hit#3925
fix(normalizr): do not mutate cached journey on result-cache hit#3925
Conversation
🦋 Changeset detectedLatest commit: 05e1298 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 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 |
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #3925 +/- ##
==========================================
- Coverage 98.21% 98.21% -0.01%
==========================================
Files 154 154
Lines 3024 3018 -6
Branches 605 604 -1
==========================================
- Hits 2970 2964 -6
Misses 11 11
Partials 43 43 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
ce58191 to
fc6aeba
Compare
There was a problem hiding this comment.
Benchmark React
Details
| Benchmark suite | Current: 05e1298 | Previous: 84078d7 | Ratio |
|---|---|---|---|
data-client: getlist-100 |
132.46 ops/s (± 4.6%) |
142.86 ops/s (± 5.0%) |
1.08 |
data-client: getlist-500 |
37.04 ops/s (± 6.6%) |
42.38 ops/s (± 6.4%) |
1.14 |
data-client: update-entity |
307.77 ops/s (± 9.8%) |
377.49 ops/s (± 10.2%) |
1.23 |
data-client: update-user |
307.77 ops/s (± 7.0%) |
370.37 ops/s (± 9.8%) |
1.20 |
data-client: getlist-500-sorted |
40.49 ops/s (± 7.8%) |
44.95 ops/s (± 6.8%) |
1.11 |
data-client: update-entity-sorted |
256.41 ops/s (± 5.8%) |
312.5 ops/s (± 4.9%) |
1.22 |
data-client: update-entity-multi-view |
274.02 ops/s (± 6.0%) |
333.33 ops/s (± 10.7%) |
1.22 |
data-client: list-detail-switch-10 |
6.45 ops/s (± 6.4%) |
7.18 ops/s (± 5.8%) |
1.11 |
data-client: update-user-10000 |
78.43 ops/s (± 11.6%) |
95.7 ops/s (± 12.4%) |
1.22 |
data-client: invalidate-and-resolve |
33.56 ops/s (± 4.8%) |
39.22 ops/s (± 5.3%) |
1.17 |
data-client: unshift-item |
200 ops/s (± 4.8%) |
243.9 ops/s (± 4.3%) |
1.22 |
data-client: delete-item |
263.16 ops/s (± 5.6%) |
294.12 ops/s (± 6.8%) |
1.12 |
data-client: move-item |
156.25 ops/s (± 9.5%) |
196.08 ops/s (± 10.0%) |
1.25 |
This comment was automatically generated by workflow using github-action-benchmark.
|
Size Change: -37 B (-0.05%) Total Size: 81 kB 📦 View Changed
ℹ️ View Unchanged
|
There was a problem hiding this comment.
Benchmark
Details
| Benchmark suite | Current: 05e1298 | Previous: 84078d7 | Ratio |
|---|---|---|---|
normalizeLong |
429 ops/sec (±4.04%) |
433 ops/sec (±2.62%) |
1.01 |
normalizeLong Values |
400 ops/sec (±0.32%) |
395 ops/sec (±0.27%) |
0.99 |
normalizeLong Scalar |
329 ops/sec (±0.50%) |
331 ops/sec (±0.85%) |
1.01 |
normalizeLong Scalar update |
837 ops/sec (±0.49%) |
853 ops/sec (±0.69%) |
1.02 |
denormalizeLong |
236 ops/sec (±4.70%) |
243 ops/sec (±4.33%) |
1.03 |
denormalizeLong Values |
218 ops/sec (±4.48%) |
231 ops/sec (±4.22%) |
1.06 |
denormalizeLong donotcache |
960 ops/sec (±0.68%) |
942 ops/sec (±0.13%) |
0.98 |
denormalizeLong Values donotcache |
712 ops/sec (±0.41%) |
717 ops/sec (±0.20%) |
1.01 |
denormalizeLong Scalar donotcache |
894 ops/sec (±1.77%) |
902 ops/sec (±0.59%) |
1.01 |
denormalizeShort donotcache 500x |
1533 ops/sec (±0.70%) |
1540 ops/sec (±0.10%) |
1.00 |
denormalizeShort 500x |
697 ops/sec (±4.13%) |
717 ops/sec (±3.96%) |
1.03 |
denormalizeShort 500x withCache |
7055 ops/sec (±0.17%) |
6899 ops/sec (±0.14%) |
0.98 |
queryShort 500x withCache |
2685 ops/sec (±0.61%) |
2819 ops/sec (±0.17%) |
1.05 |
buildQueryKey All |
54261 ops/sec (±0.78%) |
53631 ops/sec (±0.51%) |
0.99 |
query All withCache |
5300 ops/sec (±0.24%) |
6245 ops/sec (±0.31%) |
1.18 |
denormalizeLong with mixin Entity |
227 ops/sec (±4.04%) |
233 ops/sec (±3.82%) |
1.03 |
denormalizeLong withCache |
7019 ops/sec (±0.19%) |
6104 ops/sec (±0.38%) |
0.87 |
denormalizeLong withCache (Scalar churn) |
7007 ops/sec (±0.18%) |
6090 ops/sec (±0.23%) |
0.87 |
denormalizeLong Values withCache |
4929 ops/sec (±0.31%) |
5139 ops/sec (±0.17%) |
1.04 |
denormalizeLong Scalar withCache |
7761 ops/sec (±1.42%) |
7615 ops/sec (±2.80%) |
0.98 |
denormalizeLong Scalar update withCache |
4068 ops/sec (±0.42%) |
4011 ops/sec (±1.12%) |
0.99 |
denormalizeLong All withCache |
6122 ops/sec (±0.19%) |
6420 ops/sec (±0.23%) |
1.05 |
denormalizeLong Query-sorted withCache |
5379 ops/sec (±0.21%) |
6232 ops/sec (±0.21%) |
1.16 |
denormalizeLongAndShort withEntityCacheOnly |
1669 ops/sec (±0.83%) |
1659 ops/sec (±0.21%) |
0.99 |
denormalize bidirectional 50 |
4922 ops/sec (±4.58%) |
5090 ops/sec (±4.89%) |
1.03 |
denormalize bidirectional 50 donotcache |
40019 ops/sec (±0.21%) |
36895 ops/sec (±0.16%) |
0.92 |
getResponse |
4586 ops/sec (±0.87%) |
4636 ops/sec (±0.97%) |
1.01 |
getResponse (null) |
10060925 ops/sec (±0.63%) |
9348305 ops/sec (±1.09%) |
0.93 |
getResponse (clear cache) |
223 ops/sec (±3.99%) |
227 ops/sec (±4.11%) |
1.02 |
getSmallResponse |
3425 ops/sec (±0.25%) |
3556 ops/sec (±0.35%) |
1.04 |
getSmallInferredResponse |
2558 ops/sec (±0.08%) |
2623 ops/sec (±0.25%) |
1.03 |
getResponse Collection |
4559 ops/sec (±0.48%) |
4566 ops/sec (±0.65%) |
1.00 |
get Collection |
4404 ops/sec (±0.30%) |
4607 ops/sec (±0.27%) |
1.05 |
get Query-sorted |
5204 ops/sec (±0.26%) |
5287 ops/sec (±0.40%) |
1.02 |
setLong |
440 ops/sec (±0.38%) |
431 ops/sec (±0.19%) |
0.98 |
setLongWithMerge |
252 ops/sec (±0.52%) |
250 ops/sec (±0.34%) |
0.99 |
setLongWithSimpleMerge |
268 ops/sec (±0.66%) |
266 ops/sec (±0.46%) |
0.99 |
setSmallResponse 500x |
939 ops/sec (±0.07%) |
921 ops/sec (±0.68%) |
0.98 |
This comment was automatically generated by workflow using github-action-benchmark.
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 fc6aeba. Configure here.
* pkg: Bump peerdeps of @data-client/react to support 0.17 * pkg: Update yarn.lock for peerdep bump
Move the per-hit paths.slice(1) (and the hasStringDeps filter loop) out of GlobalCache.getResults and into the cache write. GlobalCache.paths() already produces the placeholder-free, function-free shape every consumer needs. Hand it to WeakDependencyMap.set as the journey, and the cache-hit branch can return that array by reference - no per-hit allocation, no per-hit typeof === 'function' walk. Safety: paths is now a shared reference held by every subsequent hit. The contract that consumers must not mutate it was established by the journey-mutation fix (PR #3925) and is exercised by the existing globalCache.test.ts regression test.
…#3929) * test(core): replace getResponseMeta-countRef with getResponseMeta-paths The previous integration test poked GCPolicy['entityCount'] directly to prove the journey-mutation bug. Replace it with a test that asserts the public-API consequence the bug creates outside of GC: every subscriber to the same endpoint must observe the same expiresAt from Controller.getResponseMeta(). This is the property the bug actually broke for non-GC users: with the buggy paths.shift(), entityExpiresAt(paths, …) iterates a progressively-shorter list, dropping the entity with the earliest expiry first. Subscriber 2 observes a too-late expiresAt; subscriber 3+ observe Infinity and never refetch. Fires under ImmortalGCPolicy too, since entityExpiresAt is unconditional whenever the endpoint has no top-level meta.expiresAt — typical for state populated via controller.set(Entity, …), SSR hydration, or useQuery. Verified the assertion fails on the buggy paths.shift() (m3.expiresAt returns FOO_2_EXPIRY instead of FOO_1_EXPIRY) and passes on the fix. * test(core): keep existing countRef integration test alongside paths test Restore the GC-side getResponseMeta-countRef.ts integration test that the prior commit replaced. The two tests cover the journey-mutation bug from complementary angles: - getResponseMeta-countRef.ts: GC consumer of paths (entityCount under-counting → premature reaping under default GCPolicy). - getResponseMeta-paths.ts: non-GC consumer of paths (entityExpiresAt → suppressed entity-expiry refetch; fires under ImmortalGCPolicy too). Both pass on the fix; both fail on the buggy paths.shift().

Summary
Companion to #3924 (which targets
master), shaped for the argsKey infrastructure oncursor/scalar-schema-design-for-lenses-d6e3(#3887).The base PR (#3887) introduced function-typed (
argsKey) deps and added ahasStringDepsfilter pass toGlobalCache.getResults's hit branch — but kept the pre-existingpaths.shift()mutation. That mutation operates on the journey array stored by reference onWeakDependencyMap.Link.journey, so every successive hit on the same cached entry shifts off one more real entity path, progressively corrupting the subscription list returned to consumers.This PR fixes the bug and (in commit
5a63f74, originally #3928) moves journey materialization to write time so the hit branch returns the stored array by reference with zero post-processing:@@ packages/normalizr/src/memo/WeakDependencyMap.ts set( dependencies: Dep<Path, K>[], value: V, args: readonly any[] = [], + /** Optional consumer-facing journey returned to `get()` callers verbatim. + * Defaults to `dependencies.map(d => d.path)`. The array becomes a shared + * reference held by every subsequent cache hit; callers MUST NOT mutate. */ + journey?: Path[], ) { … - curLink.journey = dependencies.map(d => d.path) as Path[]; + curLink.journey = journey ?? (dependencies.map(d => d.path) as Path[]); }@@ packages/normalizr/src/memo/globalCache.ts (getResults) if (paths === undefined) { data = computeValue(); paths = this.paths(); // already excludes placeholder + argsKey functions this.dependencies[0] = { path: { key: '', pk: '' }, entity: input }; - this._resultCache.set(this.dependencies, data, this._args); - } else { - paths.shift(); - if (this._resultCache.hasStringDeps) { - for (let i = 0; i < paths.length; i++) { - if (typeof paths[i] === 'function') { - paths = paths.filter(p => typeof p !== 'function') as EntityPath[]; - break; - } - } - } + this._resultCache.set(this.dependencies, data, this._args, paths); } + // hit branch: `paths` aliases the stored Link.journey — return as-is. return { data, paths }; }The stored journey is built once at write time, in the consumer-facing shape (placeholder slot + function-typed
argsKeydeps already filtered out bypaths()). No mutation, no per-hit allocation, no per-hittypeof === 'function'walk.Why it matters
Two distinct downstream consumers of the corrupted
paths, only one of which is GC-related:Controller.getResponseMeta→entityExpiresAt(paths, state.entitiesMeta)→expiresAt— picks the earliest entity expiry across the path list. ReturnsInfinityonce the journey is empty. Flows directly intouseSuspense(anduseCache/useFetch/useDLE/useLive)'s refetch gateif (Date.now() <= expiresAt && !forceFetch) return;, so once any entity is stale the hook never refetches. This bug fires underImmortalGCPolicytoo —createCountRefis a no-op there, butentityExpiresAtis unconditional.Controller.getResponseMeta→gcPolicy.createCountRef({key, paths})→GCPolicy.entityCount— under-counted entities get reaped while still referenced. This one is GC-only;ImmortalGCPolicymasks it.Controller.get(used byuseQuery) is the GC-only path — it only passespathstocreateCountRef.Empirical confirmation
A tight loop of result-cache hits against the same primed
MemoCachedrains the journey to zero on the buggy code; with the fix it stays intact:Differences from #3924 (master version)
paths.shift() → paths = paths.slice(1). No argsKey infrastructure to coexist with.hasStringDepsfilter pass with the placeholder-strip, and additionally moves journey materialization to write time so the hit path is allocation-free.Test plan
packages/normalizr/src/__tests__/globalCache.test.ts(does not mutate cached journey across repeated result-cache hits) — fails on the buggy version, passes with fix.packages/core/src/controller/__tests__/getResponseMeta-countRef.tssimulating three React subscribers to the same endpoint + args; asserts all threecountRefclosures correctly incrementGCPolicy.entityCountfor every entity, and that decrement restores counts to zero. Fails on buggy version (under-counts first entity); passes with fix.yarn jest --selectProjects ReactDOM --testPathPatterns "packages/(normalizr|core|endpoint)"— 837 tests pass.yarn jest --selectProjects ReactDOM --testPathPatterns "packages/react/.*useSuspense|useCache|useQuery"— 62 tests pass.Performance
Methodology: Node 24.14.1, full
examples/benchmarknormalizrsuite × 5 runs per branch +coresuite × 3 runs per branch + focused write-only and drain-free fair microbenches. Trimmed mean (drop min/max for n=5; median for n=3). Three branches compared:paths.shift()mutation. Cache-hit "throughput" reflects the bug draining the journey: by the time Benchmark.js samples have stabilized, the stored journey is empty and the hit branch returns no work.paths.slice(1)/ non-mutatinghasStringDepsfilter at hit time.5a63f74). Hit branch is allocation-free.Read path (cache-hit benchmarks, n=5 trimmed mean, ops/sec)
denormalizeLong withCachedenormalizeLong withCache (Scalar churn)denormalizeLong All withCachedenormalizeLong Query-sorted withCachedenormalizeLong Scalar withCache(hasStringDeps)denormalizeLong Scalar update withCache(hasStringDeps)denormalizeLong Values withCachedenormalizeLongAndShort withEntityCacheOnlyquery All withCachequeryShort 500x withCachedenormalizeShort 500x withCacheThe
*withCachebenchmarks against master look modest because master was cheating: itspaths.shift()was mutating the stored journey on each hit, so Benchmark.js samples were measuring the cost of returning empty subscription lists (after the journey drained to zero), not the real per-hit work. The fix-only column shows what doing the correct amount of work consistently costs. Moving journey materialization to write time recovers that and exceeds master's apparent throughput on every hit-path benchmark, by removing the per-hitslice(1)/ filter entirely.Write path (write-then-discard benchmarks)
These benches do
new MemoCache().denormalize(...)per iteration — exactly the "write but no hit" pattern. The optimization moves one allocation rather than adding work, so deltas are within run-to-run noise:denormalizeLong(1 write, N≈2826)denormalizeLong Values(1 write, N≈2826)denormalizeLong with mixin Entitydenormalize bidirectional 50(1 write, cycles)denormalizeShort 500x(500 fresh writes/iter)Dedicated write-only microbench (one fresh
MemoCache, exactly one denormalize per iter, n=5 trimmed mean):WRITE-only: ProjectSchema (1 miss)WRITE-only: AllProjects (1 miss)WRITE-only: StockSchema (1 miss, hasStringDeps)The optimization eliminates one redundant
dependencies.map(d => d.path)allocation per top-level cache miss (the journey for the result cache was being computed twice — once aspaths()for the consumer, once insideWeakDependencyMap.set). On entity-only schemas this is net-zero in the noise; onhasStringDepsit's a small win because the seconddependencies.mapwas building a journey that included function entries which then got skipped at every hit. The inner per-entity cache (_getCache.set) is unchanged.coresuite (n=3, fresh-thermal re-run)All within run-to-run noise (1–3% per
packages/normalizr/AGENTS.md):getResponsegetResponse Collectionget Collectionget Query-sortedgetSmallResponsegetSmallInferredResponsesetLongsetLongWithMergesetLongWithSimpleMergesetSmallResponse 500x^set*benchmarks don't go throughMemoCache.set(it's only invoked from thedenormalizecache-miss branch), so they don't move.Drain-free fair microbench (one fresh
MemoCacheper iter → 1 miss + 1 hit)Apples-to-apples since neither branch's bug can accumulate state across iterations:
ProjectSchema fresh-prime + 1 hitAllProjects fresh-prime + 1 hitStockSchema fresh-prime + 1 hit (hasStringDeps)V8 deopt analysis
node --trace-opt --trace-deopton thedenormalizeLongslice. BothgetResultsandpathsreach stable TURBOFAN_JS optimization on every branch with zero deopts:getResultspathsgetResultsSame set of pre-existing deopting functions across all branches (
unvisit,getEntity,denormalize,visit,mergeEntity,arrayEach, etc.) — none introduced or removed by this PR. The simpler hit-branch shape on the final state letsgetResultsstabilize with fewer recompile cycles than master (22 vs 32 events through MAGLEV/TURBOFAN_JS).Bundlesize
Net source change in
packages/normalizr/src/memo/: the entireif (hasStringDeps) { …filter… } else { paths.slice(1) }block ingetResultsis removed (–14 LOC);WeakDependencyMap.setgains one optional parameter wrapped in JSDoc (+12 LOC). Roughly –80 bytes minified (CI compressed-size workflow on the rebase commit reported-37 B (-0.05%)onrdcClient.jsfor the fix-only state; the journey-at-write commit drops it slightly more).Changeset
Included as
.changeset/fix-journey-mutation.md. Version-linked packages:normalizr,core,endpoint,rest,graphql,react.Note
Medium Risk
Touches core memoization/caching behavior used by all denormalize reads; while the change is small, it affects subscription paths used for GC ref-counting and expiry calculations and could surface subtle regressions in cache semantics.
Overview
Prevents
GlobalCache.getResultsfrom mutating cached dependency "journeys" on result-cache hits by materializing the consumer-facingpathsonce on cache write and storing it as theWeakDependencyMapjourney returned verbatim on subsequent hits.Adds regression coverage in
normalizrandcoreto ensure repeated reads keep stablepaths,expiresAt, andGCPolicycountReftracking across multiple subscribers, and bumps@data-client/reactpeer ranges in@data-client/imgand@data-client/testto include^0.17.0.Reviewed by Cursor Bugbot for commit 05e1298. Bugbot is set up for automated code reviews on this repo. Configure here.