Skip to content

fix(normalizr): do not mutate cached journey on result-cache hit#3924

Open
ntucker wants to merge 1 commit intomasterfrom
fix/journey-mutation-on-result-cache-hit
Open

fix(normalizr): do not mutate cached journey on result-cache hit#3924
ntucker wants to merge 1 commit intomasterfrom
fix/journey-mutation-on-result-cache-hit

Conversation

@ntucker
Copy link
Copy Markdown
Collaborator

@ntucker ntucker commented Apr 27, 2026

Summary

GlobalCache.getResults called paths.shift() on a result-cache hit, mutating the journey array stored by reference on the WeakDependencyMap Link node. After the first hit stripped the placeholder input slot, every subsequent hit on the same cached entry would shift off a real EntityPath, progressively losing subscription entries.

The hit path now returns a non-mutating copy (paths.slice(1)).

   } else {
-    paths.shift();
+    paths = paths.slice(1);
   }

Why it matters

Downstream consumers capture paths by reference:

  • Controller.getResponseMeta passes paths into GCPolicy.createCountRef, whose increment/decrement closures iterate the captured array at effect time. After three React subscribers to the same endpoint + args with entities unchanged (1 miss + 2 hits), the journey has been shifted twice — subscribers 2 and 3 silently skip the first entity. entityCount is under-counted and GC can reap entities still referenced by mounted components.
  • entityExpiresAt(paths, ...) returns an incorrect (too-late) expiry when paths drop entries, suppressing auto-refetch.
  • React hooks (useSuspense, useCache, useLive, useDLE, useQuery) all funnel through getResponseMeta, so any app rendering the same endpoint from multiple places can hit this.

Empirical confirmation

On master, a tight loop of result-cache hits against the same primed MemoCache drains the journey to zero:

iterations paths.length (master) paths.length (fix)
1 2826 2826
100 2727 2826
1,000 1827 2826
10,000 0 2826

Test plan

  • New integration test packages/core/src/controller/__tests__/getResponseMeta-countRef.ts simulates three React subscribers to the same endpoint + args, asserts all three countRef closures correctly increment GCPolicy.entityCount for every entity, and that decrement restores counts to zero. Fails on master (under-counts first entity); passes with fix.
  • yarn jest --selectProjects ReactDOM --testPathPatterns "packages/(core|normalizr)" — 259 tests pass.

Performance analysis

Run on Linux 6.6.87.2 (WSL2), AMD Ryzen 9 7950X, Node v20.18.2, example-benchmark with NODE_ENV=production and --expose_gc.

Methodological caveat

Master's withCache benchmark numbers are not directly comparable to the fix's numbers. The bug makes each paths.shift() mutate the shared journey on every hit, so after benchmark.js's ~100k-iteration warmup the journey is fully drained and master enters a steady state where each hit returns an empty paths array (doing nearly no work). The fix does honest work on every hit (slice of a full-length journey).

The numbers below are reported as-measured. Interpret withCache rows with that bias in mind — a small slowdown vs. a journey-corrupting baseline is expected.

normalizr suite

Benchmark Master (buggy) Fix Δ
normalizeLong 756 785 +3.8%
normalizeLong Values 681 701 +2.9%
denormalizeLong 666 465 -30.2% ‡
denormalizeLong Values 438 412 -5.9%
denormalizeLong donotcache 1,804 1,789 -0.8%
denormalizeLong Values donotcache 1,280 1,292 +0.9%
denormalizeShort donotcache 500x 2,603 2,658 +2.1%
denormalizeShort 500x 1,617 1,275 -21.2% ‡
denormalizeShort 500x withCache † 12,956 13,373 +3.2%
queryShort 500x withCache † 4,648 4,848 +4.3%
buildQueryKey All 80,880 80,950 +0.1%
query All withCache † 11,540 10,683 -7.4%
denormalizeLong with mixin Entity 589 419 -28.9% ‡
denormalizeLong withCache † 12,671 12,373 -2.4%
denormalizeLong Values withCache † 9,442 8,648 -8.4%
denormalizeLong All withCache † 11,999 10,200 -15.0%
denormalizeLong Query-sorted withCache † 11,665 10,772 -7.7%
denormalizeLongAndShort withEntityCacheOnly 2,965 3,071 +3.6%
denormalize bidirectional 50 10,292 8,858 -13.9% ‡
denormalize bidirectional 50 donotcache 77,675 76,023 -2.1%

† withCache scenarios — master is biased low-work by journey drain. See caveat.
‡ Scenarios not touching the hit path. Deltas here are run-to-run noise (these rows do not call getResults on a hit, so the fix cannot affect them). Re-running multiple times produced swings of similar magnitude, including >10% swings on the Values withCache row (9,190 → 11,078 on repeated master runs).

core suite (does not exercise the hit path in the changed branch; all rows are noise-level)

Benchmark Master Fix Δ
getResponse 9,275 9,290 +0.2%
getResponse (null) 19.9M 20.7M +3.8%
getResponse (clear cache) 644 458 -28.9% ‡
getSmallResponse 7,274 7,392 +1.6%
getSmallInferredResponse 5,148 4,965 -3.6%
getResponse Collection 8,122 7,954 -2.1%
get Collection 6,457 6,960 +7.8%
get Query-sorted 7,215 8,128 +12.7%
setLong 777 778 +0.1%
setLongWithMerge 434 440 +1.4%
setLongWithSimpleMerge 477 476 -0.2%
setSmallResponse 500x 1,446 1,438 -0.6%

Core suite is mostly within run-to-run noise (±3–5%). getResponse (clear cache) has known wide variance; the -29% is noise, not a fix-related regression.

Controlled microbenchmark (drain-free)

To isolate the per-hit cost without the benchmark.js journey-drain artifact, I ran three scenarios with fresh MemoCache per outer iteration (so the bug can't bias toward "shift on empty array"):

Scenario Master (buggy) Fix Δ
single-hit (fresh memo, primed once, 1 hit measured) 632 573 -9.3%
1000 hits back-to-back (shared memo, biased by drain) 16.60 15.17 -8.6%
1000 hits with fresh memo per outer iteration 12.51 14.04 +12.2%

The last row — the only scenario where both branches do equal work — shows the fix is actually faster. Array.prototype.shift on a 2800-element array is O(n) reindex-in-place, which V8 routinely hits slow paths for; Array.prototype.slice(1) is O(n) allocate + memcpy which V8 optimizes well. Once we stop the bug from letting master do less work, the fix wins.

Real-world impact

A React app does not iterate one endpoint 10,000 times in a tight loop — it does O(hook instances × re-renders) hits per second. At typical hook counts (tens to low hundreds), one extra slice(1) per hit on a ~50-element paths array is sub-microsecond per render. The correctness benefits (no silent under-subscription, no premature GC of live entities, correct entityExpiresAt) are unambiguously worth it.

Changeset

Included as .changeset/fix-journey-mutation.md. Version-linked packages: normalizr, core, endpoint, rest, graphql, react.


Note

Medium Risk
Touches core caching/subscription metadata used by GC ref-counting; behavior change is small but impacts correctness across repeated cache hits and could affect dependency tracking if wrong.

Overview
Fixes a bug where GlobalCache.getResults mutated cached dependency "journey" data on result-cache hits by replacing paths.shift() with a non-mutating paths.slice(1) copy, preventing progressive loss of entity subscription paths.

Adds an integration test covering repeated Controller.getResponseMeta() calls to ensure countRef increments/decrements entity ref-counts correctly across multiple subscribers, and includes a changeset bumping several @data-client/* packages.

Reviewed by Cursor Bugbot for commit 95e758f. Bugbot is set up for automated code reviews on this repo. Configure here.

`GlobalCache.getResults` called `paths.shift()` on a cache hit, mutating
the `journey` array stored by reference on the `WeakDependencyMap` `Link`
node. After the first hit stripped the placeholder input slot, every
subsequent hit on the same cached entry would shift off a real
`EntityPath`, progressively losing subscription entries.

This corrupts downstream consumers that capture `paths` by reference:

- `Controller.getResponseMeta` passes `paths` into
  `GCPolicy.createCountRef`, whose increment/decrement closures iterate
  the captured array at effect time. After three React subscribers to
  the same endpoint+args (miss + 2 hits), the journey has been shifted
  twice — so subscribers 2 and 3 silently skip the first entity,
  leaving `entityCount` under-counted and making GC reap entities still
  in use.
- `entityExpiresAt(paths, ...)` returns an incorrect (too-late) expiry
  when paths drop entries, suppressing auto-refetch.

The hit path now returns a non-mutating copy (`paths.slice(1)`).
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs-site Ignored Ignored Apr 27, 2026 5:05pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 27, 2026

🦋 Changeset detected

Latest commit: 95e758f

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

This PR includes changesets to release 16 packages
Name Type
@data-client/normalizr Patch
@data-client/core Patch
@data-client/endpoint Patch
@data-client/rest Patch
@data-client/graphql Patch
@data-client/react Patch
example-benchmark Patch
normalizr-github-example Patch
normalizr-redux-example Patch
normalizr-relationships Patch
@data-client/vue Patch
example-benchmark-react Patch
@data-client/img Patch
test-bundlesize Patch
coinbase-lite Patch
@data-client/test 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

@github-actions
Copy link
Copy Markdown
Contributor

Size Change: 0 B

Total Size: 80.7 kB

ℹ️ View Unchanged
Filename Size
examples/test-bundlesize/dist/App.js 1.46 kB
examples/test-bundlesize/dist/polyfill.js 307 B
examples/test-bundlesize/dist/rdcClient.js 10.5 kB
examples/test-bundlesize/dist/rdcEndpoint.js 8.01 kB
examples/test-bundlesize/dist/react.js 59.7 kB
examples/test-bundlesize/dist/webpack-runtime.js 726 B

compressed-size-action

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

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: 95e758f Previous: ce99359 Ratio
data-client: getlist-100 133.33 ops/s (± 5.3%) 136.99 ops/s (± 5.1%) 1.03
data-client: getlist-500 39.92 ops/s (± 5.4%) 41.85 ops/s (± 4.7%) 1.05
data-client: update-entity 357.14 ops/s (± 10.1%) 333.33 ops/s (± 9.8%) 0.93
data-client: update-user 317.54 ops/s (± 7.2%) 344.83 ops/s (± 7.8%) 1.09
data-client: getlist-500-sorted 36.83 ops/s (± 7.5%) 46.08 ops/s (± 6.6%) 1.25
data-client: update-entity-sorted 277.78 ops/s (± 6.9%) 294.12 ops/s (± 7.4%) 1.06
data-client: update-entity-multi-view 322.58 ops/s (± 6.0%) 317.54 ops/s (± 8.1%) 0.98
data-client: list-detail-switch-10 6.98 ops/s (± 5.0%) 7.48 ops/s (± 6.1%) 1.07
data-client: update-user-10000 66.45 ops/s (± 15.0%) 74.91 ops/s (± 14.1%) 1.13
data-client: invalidate-and-resolve 34.97 ops/s (± 5.1%) 36.1 ops/s (± 5.2%) 1.03
data-client: unshift-item 212.77 ops/s (± 2.8%) 238.1 ops/s (± 2.1%) 1.12
data-client: delete-item 285.71 ops/s (± 4.1%) 270.27 ops/s (± 7.0%) 0.95
data-client: move-item 175.44 ops/s (± 5.3%) 181.82 ops/s (± 5.0%) 1.04

This comment was automatically generated by workflow using github-action-benchmark.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

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: 95e758f Previous: 4f5b6b6 Ratio
normalizeLong 466 ops/sec (±1.87%) 458 ops/sec (±1.29%) 0.98
normalizeLong Values 423 ops/sec (±0.22%) 417 ops/sec (±0.37%) 0.99
denormalizeLong 243 ops/sec (±4.30%) 394 ops/sec (±1.17%) 1.62
denormalizeLong Values 221 ops/sec (±3.99%) 339 ops/sec (±0.50%) 1.53
denormalizeLong donotcache 1123 ops/sec (±0.28%) 1015 ops/sec (±0.10%) 0.90
denormalizeLong Values donotcache 770 ops/sec (±1.12%) 758 ops/sec (±0.23%) 0.98
denormalizeShort donotcache 500x 1494 ops/sec (±0.13%) 1558 ops/sec (±0.14%) 1.04
denormalizeShort 500x 666 ops/sec (±3.51%) 1060 ops/sec (±2.16%) 1.59
denormalizeShort 500x withCache 7813 ops/sec (±0.19%) 7687 ops/sec (±0.11%) 0.98
queryShort 500x withCache 3292 ops/sec (±0.16%) 2999 ops/sec (±0.11%) 0.91
buildQueryKey All 49080 ops/sec (±0.36%) 54049 ops/sec (±0.42%) 1.10
query All withCache 5538 ops/sec (±0.24%) 6752 ops/sec (±0.32%) 1.22
denormalizeLong with mixin Entity 230 ops/sec (±4.39%) 357 ops/sec (±2.60%) 1.55
denormalizeLong withCache 8783 ops/sec (±0.31%) 7722 ops/sec (±0.45%) 0.88
denormalizeLong Values withCache 5637 ops/sec (±4.53%) 5215 ops/sec (±0.19%) 0.93
denormalizeLong All withCache 6213 ops/sec (±0.72%) 6457 ops/sec (±0.11%) 1.04
denormalizeLong Query-sorted withCache 5116 ops/sec (±0.83%) 6764 ops/sec (±0.21%) 1.32
denormalizeLongAndShort withEntityCacheOnly 1792 ops/sec (±0.22%) 1837 ops/sec (±0.18%) 1.03
denormalize bidirectional 50 4690 ops/sec (±4.65%) 6952 ops/sec (±0.28%) 1.48
denormalize bidirectional 50 donotcache 43222 ops/sec (±1.79%) 40359 ops/sec (±1.32%) 0.93
getResponse 5998 ops/sec (±0.77%) 4714 ops/sec (±0.69%) 0.79
getResponse (null) 10651347 ops/sec (±0.69%) 10852221 ops/sec (±0.75%) 1.02
getResponse (clear cache) 224 ops/sec (±3.78%) 338 ops/sec (±0.33%) 1.51
getSmallResponse 3893 ops/sec (±0.97%) 3511 ops/sec (±0.09%) 0.90
getSmallInferredResponse 2888 ops/sec (±0.96%) 2617 ops/sec (±0.11%) 0.91
getResponse Collection 5391 ops/sec (±0.64%) 4708 ops/sec (±0.82%) 0.87
get Collection 4889 ops/sec (±0.39%) 4732 ops/sec (±0.47%) 0.97
get Query-sorted 6084 ops/sec (±2.92%) 4672 ops/sec (±0.27%) 0.77
setLong 474 ops/sec (±0.32%) 469 ops/sec (±0.13%) 0.99
setLongWithMerge 265 ops/sec (±0.84%) 255 ops/sec (±0.21%) 0.96
setLongWithSimpleMerge 277 ops/sec (±1.28%) 277 ops/sec (±0.12%) 1
setSmallResponse 500x 914 ops/sec (±0.11%) 918 ops/sec (±0.74%) 1.00

This comment was automatically generated by workflow using github-action-benchmark.

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.

1 participant