|
| 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 commit comments