@data-client/core 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ });