Skip to content

Commit 05e1298

Browse files
authored
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 059b0e3 commit 05e1298

1 file changed

Lines changed: 88 additions & 0 deletions

File tree

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+
});

0 commit comments

Comments
 (0)