@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.
- package/CHANGELOG.md +149 -0
- package/README.md +1 -1
- package/dist/index.js +18 -15
- package/dist/index.umd.min.js +1 -1
- package/dist/mock.js +18 -15
- package/legacy/controller/Controller.js +20 -17
- package/lib/controller/Controller.d.ts.map +1 -1
- package/lib/controller/Controller.js +20 -17
- package/package.json +4 -4
- package/src/controller/Controller.ts +18 -17
- package/src/controller/__tests__/getResponse.ts +191 -1
- package/src/controller/__tests__/getResponseMeta-countRef.ts +93 -0
- package/src/controller/__tests__/getResponseMeta-paths.ts +88 -0
|
@@ -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
|
+
});
|