@data-client/core 0.16.7 → 0.18.1

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