Skip to content

Commit 6e8e499

Browse files
authored
fix(normalizr): do not mutate cached journey on result-cache hit (#3925)
* chore: rebase PR #3925 onto latest master * pkg: Bump peerdeps of @data-client/react to support 0.17 (#3927) * pkg: Bump peerdeps of @data-client/react to support 0.17 * pkg: Update yarn.lock for peerdep bump * perf(normalizr): store consumer-facing journey at write time (#3928) 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. * fix: Scalar reversion * test(core): add getResponseMeta-paths integration test (non-GC angle) (#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().
1 parent 84078d7 commit 6e8e499

10 files changed

Lines changed: 275 additions & 21 deletions

File tree

.changeset/fix-journey-mutation.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@data-client/normalizr': patch
3+
'@data-client/core': patch
4+
'@data-client/endpoint': patch
5+
'@data-client/rest': patch
6+
'@data-client/graphql': patch
7+
'@data-client/react': patch
8+
---
9+
10+
Fix cached journey being mutated on repeated result-cache hits.
11+
12+
`GlobalCache.getResults` called `paths.shift()` on a cache hit, mutating
13+
the `journey` array stored by reference on the `WeakDependencyMap` `Link`
14+
node. After the first hit stripped the placeholder input slot, every
15+
subsequent hit on the same cached entry would shift off a real
16+
`EntityPath`, progressively losing subscription entries. This could cause
17+
missed `countRef` tracking (premature GC of still-referenced entities)
18+
and incorrect `entityExpiresAt` calculations. The hit path now returns a
19+
non-mutating copy.

.changeset/peerdeps-017.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@data-client/img': patch
3+
'@data-client/test': patch
4+
---
5+
6+
Bump `@data-client/react` peer dependency range to include `^0.17.0`.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { Endpoint, Entity } from '@data-client/endpoint';
2+
3+
import { GCPolicy } from '../../state/GCPolicy';
4+
import { initialState } from '../../state/reducer/createReducer';
5+
import Controller from '../Controller';
6+
7+
/** Integration test for the `paths` → `countRef` pipeline.
8+
*
9+
* React hooks (`useSuspense`, `useCache`, `useLive`, `useDLE`, `useQuery`)
10+
* all call `controller.getResponseMeta(...)` during `useMemo` and then
11+
* pass the returned `countRef` to `useEffect`. `useEffect` executes
12+
* `countRef` once per hook instance — iterating the **captured** `paths`
13+
* array to increment `GCPolicy.entityCount` per referenced entity. When
14+
* the component unmounts (or `data` changes) the returned `decrement`
15+
* closure iterates that same captured `paths` array to decrement.
16+
*
17+
* Before the fix, `globalCache.getResults` called `paths.shift()` on a
18+
* result-cache hit, mutating the journey array stored by reference on
19+
* `WeakDependencyMap.Link.journey`. Because `paths` aliases that stored
20+
* journey, every successive hit dropped one more real `EntityPath` from
21+
* the subscription list. The Nth component mount (for N ≥ 3, same
22+
* endpoint + args, entities unchanged) would have its `countRef`
23+
* iterate a progressively shorter list — skipping the first real
24+
* entity, then the second, etc. — leaving `entityCount` under-counted
25+
* for those entities and allowing GC to reap entities that mounted
26+
* components still subscribe to.
27+
*/
28+
describe('Controller.getResponseMeta + GCPolicy.countRef integration', () => {
29+
it('entityCount tracks all entities for each subscriber across repeated result-cache hits', () => {
30+
class Foo extends Entity {
31+
id = '';
32+
pk() {
33+
return this.id;
34+
}
35+
}
36+
const ep = new Endpoint(() => Promise.resolve(), {
37+
key: () => 'listFoo',
38+
schema: [Foo],
39+
});
40+
41+
const gcPolicy = new GCPolicy();
42+
const controller = new Controller({ gcPolicy });
43+
gcPolicy.init(controller);
44+
45+
const state = {
46+
...initialState,
47+
entities: {
48+
Foo: {
49+
'1': { id: '1' },
50+
'2': { id: '2' },
51+
},
52+
},
53+
endpoints: {
54+
[ep.key()]: ['1', '2'],
55+
},
56+
};
57+
58+
// Three successive `getResponseMeta` calls simulate three React
59+
// components mounting with the same endpoint + args, where the store
60+
// has not changed between renders. Call 1 is a result-cache miss;
61+
// calls 2 and 3 are hits — each hit mutates the shared journey.
62+
const m1 = controller.getResponseMeta(ep, state);
63+
const m2 = controller.getResponseMeta(ep, state);
64+
const m3 = controller.getResponseMeta(ep, state);
65+
66+
// Simulate `useEffect(countRef, [data])` firing for each mount.
67+
// Pre-fix: m2.paths and m3.paths both aliased the shared journey,
68+
// and by the time the countRefs run the journey has been shifted
69+
// twice — so both iterate `[Foo|'2']` only, silently skipping
70+
// Foo|'1'. Only m1 (miss path, fresh `paths`) counted Foo|'1'.
71+
const dec1 = m1.countRef();
72+
const dec2 = m2.countRef();
73+
const dec3 = m3.countRef();
74+
75+
// Each mount must have incremented *both* entities.
76+
// Pre-fix: Foo|'1' = 1 (only m1 counted it); Foo|'2' = 3.
77+
expect(gcPolicy['entityCount'].get('Foo')?.get('1')).toBe(3);
78+
expect(gcPolicy['entityCount'].get('Foo')?.get('2')).toBe(3);
79+
80+
// Simulate all three components unmounting (or `data` changing).
81+
// Pre-fix: only dec1 would decrement Foo|'1' (taking it from 1 to
82+
// 0) — observable as premature GC-eligibility of Foo|'1' while
83+
// the other two subscribers still depended on it.
84+
dec1();
85+
dec2();
86+
dec3();
87+
88+
expect(gcPolicy['entityCount'].get('Foo')?.get('1')).toBeUndefined();
89+
expect(gcPolicy['entityCount'].get('Foo')?.get('2')).toBeUndefined();
90+
91+
gcPolicy.cleanup();
92+
});
93+
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { Endpoint, Entity } from '@data-client/endpoint';
2+
3+
import { initialState } from '../../state/reducer/createReducer';
4+
import Controller from '../Controller';
5+
6+
/** Integration test: `Controller.getResponseMeta()` is the seam every read
7+
* hook (`useSuspense`, `useCache`, `useFetch`, `useDLE`, `useLive`) calls.
8+
* Multiple subscribers to the same endpoint + args + entities must observe
9+
* the same `expiresAt`, otherwise late subscribers silently skip the
10+
* entity-expiry refetch in `useSuspense`'s gate
11+
* `if (Date.now() <= expiresAt && !forceFetch) return;`.
12+
*
13+
* The bug fixed by this PR was a `paths.shift()` mutation on the journey
14+
* stored by reference on `WeakDependencyMap.Link.journey`. Each successive
15+
* cache hit returned a journey one entry shorter than the last. The first
16+
* entity dropped is the one with the *earliest* expiry, so subscriber 2's
17+
* `entityExpiresAt(paths, …)` skipped it and computed a too-late
18+
* `expiresAt`; subscriber 3 saw an empty array and got `Infinity`.
19+
*
20+
* The path is GC-independent: it fires under `ImmortalGCPolicy` too, since
21+
* `entityExpiresAt` is unconditional whenever the endpoint has no
22+
* top-level `meta.expiresAt` (the common case for state populated via
23+
* `controller.set(Entity, …)`, SSR hydration, or `useQuery`).
24+
*/
25+
describe('Controller.getResponseMeta repeated calls', () => {
26+
it('returns identical expiresAt across subscribers (journey is not consumed by reads)', () => {
27+
class Foo extends Entity {
28+
id = '';
29+
pk() {
30+
return this.id;
31+
}
32+
}
33+
const ep = new Endpoint(() => Promise.resolve(), {
34+
key: () => 'listFoo',
35+
schema: [Foo],
36+
});
37+
38+
const controller = new Controller({});
39+
40+
// Endpoint slot exists (so we hit the `denormalize` path) but the
41+
// endpoint itself has no top-level `meta.expiresAt` yet — mirrors
42+
// apps that hydrate from SSR or use `controller.set(Entity, …)`
43+
// without going through an endpoint fetch. In that shape,
44+
// `Controller.getResponseMeta()` falls back to walking per-entity
45+
// meta via `entityExpiresAt(paths, …)`. Foo|'1' has the earliest
46+
// expiry — the first journey entry the buggy `paths.shift()` would
47+
// drop.
48+
const FOO_1_EXPIRY = 100;
49+
const FOO_2_EXPIRY = 1_000_000;
50+
const state = {
51+
...initialState,
52+
entities: {
53+
Foo: {
54+
'1': { id: '1' },
55+
'2': { id: '2' },
56+
},
57+
},
58+
entitiesMeta: {
59+
Foo: {
60+
'1': { date: 0, fetchedAt: 0, expiresAt: FOO_1_EXPIRY },
61+
'2': { date: 0, fetchedAt: 0, expiresAt: FOO_2_EXPIRY },
62+
},
63+
},
64+
endpoints: {
65+
[ep.key()]: ['1', '2'],
66+
},
67+
};
68+
69+
// Three successive calls simulate three React components mounting
70+
// with the same endpoint + args. Call 1 is a result-cache miss;
71+
// calls 2 and 3 are hits.
72+
const m1 = controller.getResponseMeta(ep, state);
73+
const m2 = controller.getResponseMeta(ep, state);
74+
const m3 = controller.getResponseMeta(ep, state);
75+
76+
// Subscriber 1 picks the earliest entity expiry. Subscribers 2+ must
77+
// pick the same one — pre-fix they observed FOO_2_EXPIRY (m2) and
78+
// Infinity (m3), silently suppressing entity-expiry refetch.
79+
expect(m1.expiresAt).toBe(FOO_1_EXPIRY);
80+
expect(m2.expiresAt).toBe(m1.expiresAt);
81+
expect(m3.expiresAt).toBe(m1.expiresAt);
82+
83+
// Cached data must remain referentially equal across hits — the
84+
// memo contract `useSuspense` etc. rely on for skip-rerender.
85+
expect(m2.data).toBe(m1.data);
86+
expect(m3.data).toBe(m1.data);
87+
});
88+
});

packages/img/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
"@data-client/endpoint": "workspace:^"
7979
},
8080
"peerDependencies": {
81-
"@data-client/react": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0",
81+
"@data-client/react": "^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0 || ^0.17.0",
8282
"@types/react": "^16.14.0 || ^17.0.0 || ^18.0.0-0 || ^19.0.0",
8383
"react": "^16.14.0 || ^17.0.0 || ^18.0.0-0 || ^19.0.0"
8484
},

packages/normalizr/src/__tests__/globalCache.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,47 @@ describe('GlobalCache', () => {
121121
expect(r2.paths).toEqual(r1.paths);
122122
});
123123

124+
it('does not mutate cached journey across repeated result-cache hits', () => {
125+
// Regression: on a resultCache hit, `getResults` must strip the
126+
// placeholder input slot from the returned paths WITHOUT mutating the
127+
// journey array stored on the WeakDependencyMap `Link` node. A mutating
128+
// `paths.shift()` progressively removes real entity paths from the
129+
// stored journey on each successive hit, corrupting the subscription
130+
// list (missed countRef, incorrect entityExpiresAt).
131+
const entities = {
132+
Foo: { '1': { id: '1' }, '2': { id: '2' } },
133+
};
134+
const { getEntity, getCache, resultCache } = makeDeps(entities);
135+
const input = [{ id: '1' }, { id: '2' }];
136+
137+
const frame1 = new GlobalCache(getEntity, getCache, resultCache, []);
138+
const r1 = frame1.getResults(input, true, () => {
139+
frame1.getEntity('1', Foo, entities.Foo['1'], m =>
140+
m.set('1', { ...entities.Foo['1'] }),
141+
);
142+
frame1.getEntity('2', Foo, entities.Foo['2'], m =>
143+
m.set('2', { ...entities.Foo['2'] }),
144+
);
145+
return [{ id: '1' }, { id: '2' }];
146+
});
147+
expect(r1.paths).toEqual([
148+
{ key: 'Foo', pk: '1' },
149+
{ key: 'Foo', pk: '2' },
150+
]);
151+
152+
// Repeated hits must each return the full set of entity paths.
153+
for (let n = 0; n < 3; n++) {
154+
const frame = new GlobalCache(getEntity, getCache, resultCache, []);
155+
const { paths } = frame.getResults(input, true, () => {
156+
throw new Error('resultCache must hit');
157+
});
158+
expect(paths).toEqual([
159+
{ key: 'Foo', pk: '1' },
160+
{ key: 'Foo', pk: '2' },
161+
]);
162+
}
163+
});
164+
124165
it('cache hit across frames also works when argsKey was used (paths filtered)', () => {
125166
// Once the resultCache has stored any function-typed dep, future hits
126167
// must strip them when returning paths. Ensures the on-hit filter at

packages/normalizr/src/memo/WeakDependencyMap.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,18 @@ export default class WeakDependencyMap<
6969
return [curLink.value, curLink.journey] as readonly [V, Path[]];
7070
}
7171

72-
set(dependencies: Dep<Path, K>[], value: V, args: readonly any[] = []) {
72+
set(
73+
dependencies: Dep<Path, K>[],
74+
value: V,
75+
args: readonly any[] = [],
76+
/** Optional consumer-facing journey returned to `get()` callers verbatim.
77+
* Defaults to `dependencies.map(d => d.path)`. Pass an explicit array to
78+
* skip the per-write `.map(...)` and (more importantly) to skip per-hit
79+
* post-processing — see `GlobalCache.getResults` for the read-side
80+
* payoff. The array becomes a shared reference held by every subsequent
81+
* cache hit; callers MUST NOT mutate it. */
82+
journey?: Path[],
83+
) {
7384
if (dependencies.length < 1) throw new KeySize();
7485
let curLink: Link<Path, K, V> = this as any;
7586
for (const dep of dependencies) {
@@ -98,7 +109,7 @@ export default class WeakDependencyMap<
98109
curLink.nextPath = undefined;
99110
curLink.value = value;
100111
// we could recompute this on get, but it would have a cost and we optimize for `get`
101-
curLink.journey = dependencies.map(d => d.path) as Path[];
112+
curLink.journey = journey ?? (dependencies.map(d => d.path) as Path[]);
102113
}
103114

104115
/** True once any `argsKey`-style dep has been written. Consumers can use

packages/normalizr/src/memo/globalCache.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -173,24 +173,20 @@ export default class GlobalCache implements Cache {
173173

174174
if (paths === undefined) {
175175
data = computeValue();
176-
// we want to do this before we fill our 'input' entry
176+
// build the consumer-facing subscription list once, here.
177+
// `paths()` already excludes the input placeholder slot and (when
178+
// `_hasArgsKey`) function-typed `argsKey` deps, so it's exactly the
179+
// shape every hit-branch consumer needs. We hand it to the cache as
180+
// the journey so subsequent hits return it by reference — no per-hit
181+
// `slice(1)` or filter pass required.
177182
paths = this.paths();
178-
// fill pre-allocated slot 0 with the input reference
183+
// fill pre-allocated slot 0 with the input reference (chain key only)
179184
this.dependencies[0] = { path: { key: '', pk: '' }, entity: input };
180-
this._resultCache.set(this.dependencies, data, this._args);
181-
} else {
182-
paths.shift();
183-
// strip any function-typed (`argsKey`) paths — not subscribable entities.
184-
// Only possible when the result cache has ever stored such a dep.
185-
if (this._resultCache.hasStringDeps) {
186-
for (let i = 0; i < paths.length; i++) {
187-
if (typeof paths[i] === 'function') {
188-
paths = paths.filter(p => typeof p !== 'function') as EntityPath[];
189-
break;
190-
}
191-
}
192-
}
185+
this._resultCache.set(this.dependencies, data, this._args, paths);
193186
}
187+
// hit branch: `paths` aliases the stored Link.journey — return as-is.
188+
// Callers must not mutate it (entityExpiresAt and createCountRef both
189+
// read-only). The journey-mutation regression test guards this contract.
194190
return { data, paths };
195191
}
196192

packages/test/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@
120120
"@testing-library/react": "^16.0.0"
121121
},
122122
"peerDependencies": {
123-
"@data-client/react": "^0.12.15 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0",
123+
"@data-client/react": "^0.12.15 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0 || ^0.17.0",
124124
"@testing-library/react-hooks": "^8.0.0",
125125
"@testing-library/react-native": "^13.0.0",
126126
"@types/react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-0 || ^19.0.0",

yarn.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3262,7 +3262,7 @@ __metadata:
32623262
react-dom: "npm:^19.2.1"
32633263
rollup-plugins: "workspace:*"
32643264
peerDependencies:
3265-
"@data-client/react": ^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0
3265+
"@data-client/react": ^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0 || ^0.17.0
32663266
"@types/react": ^16.14.0 || ^17.0.0 || ^18.0.0-0 || ^19.0.0
32673267
react: ^16.14.0 || ^17.0.0 || ^18.0.0-0 || ^19.0.0
32683268
peerDependenciesMeta:
@@ -3369,7 +3369,7 @@ __metadata:
33693369
react-test-renderer: "npm:*"
33703370
rollup-plugins: "workspace:*"
33713371
peerDependencies:
3372-
"@data-client/react": ^0.12.15 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0
3372+
"@data-client/react": ^0.12.15 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0 || ^0.17.0
33733373
"@testing-library/react-hooks": ^8.0.0
33743374
"@testing-library/react-native": ^13.0.0
33753375
"@types/react": ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-0 || ^19.0.0

0 commit comments

Comments
 (0)