diff --git a/src/cache/inmemory/__tests__/readFromStore.ts b/src/cache/inmemory/__tests__/readFromStore.ts index d46c79b89ca..978a6c9062a 100644 --- a/src/cache/inmemory/__tests__/readFromStore.ts +++ b/src/cache/inmemory/__tests__/readFromStore.ts @@ -4,7 +4,7 @@ import gql from 'graphql-tag'; import { stripSymbols } from '../../../utilities/testing/stripSymbols'; import { StoreObject } from '../types'; import { StoreReader } from '../readFromStore'; -import { makeReference, InMemoryCache, Reference, isReference } from '../../../core'; +import { makeReference, InMemoryCache, Reference, isReference, InMemoryCacheConfig } from '../../../core'; import { Cache } from '../../core/types/Cache'; import { MissingFieldError } from '../../core/types/common'; import { defaultNormalizedCacheFactory, readQueryFromStore } from './helpers'; @@ -1337,7 +1337,7 @@ describe('reading from the store', () => { }); }); - it("propagates eviction signals to parent queries", () => { + function getEvictionTestData(options: InMemoryCacheConfig = {}) { const cache = new InMemoryCache({ typePolicies: { Deity: { @@ -1363,6 +1363,7 @@ describe('reading from the store', () => { }, }, }, + ...options, }); const rulerQuery = gql` @@ -1421,6 +1422,31 @@ describe('reading from the store', () => { }); } + function evictAndGC(name: string) { + /* + * The number of evicted entries is 1 (the evicted entry) + the number of + * gc'ed entries. + */ + let evicted = 1; + cache.evict({ + id: cache.identify({ __typename: "Deity", name }), + }); + evicted += cache.gc().length; + return evicted; + } + + return { + watch, + cache, + rulerQuery, + diffs, + children, + evictAndGC, + }; + } + + it("propagates eviction signals to parent queries", () => { + const { watch, cache, rulerQuery, diffs, children } = getEvictionTestData(); const cancel = watch(); function devour(name: string) { @@ -1827,4 +1853,63 @@ describe('reading from the store', () => { }, }); }); + + it('clears cached entries when garbage collected', () => { + const { cache, rulerQuery, evictAndGC } = getEvictionTestData(); + + /* + * TODO: find a better way to spy on forgetKey. + */ + const reader = (cache as any).storeReader; + const executeSelectionSetSpy = jest.spyOn((reader as any)['executeSelectionSet'], 'forgetKey'); + const executeSubSelectedArraySpy = jest.spyOn((reader as any)['executeSubSelectedArray'], 'forgetKey'); + + cache.watch({ + query: rulerQuery, + immediate: true, + optimistic: true, + callback: () => {}, + }); + expect(executeSelectionSetSpy).not.toBeCalled(); + expect(executeSubSelectedArraySpy).not.toBeCalled(); + + /* + * Since each Deity entry has exactly one cache entry at this point we + * expect the forget count to match. + */ + const evicted = evictAndGC('Cronus'); + expect(executeSelectionSetSpy).toBeCalledTimes(evicted); + expect(executeSubSelectedArraySpy).toBeCalledTimes(evicted); + }); + + it('does not clear caches when resultCaching is disabled', () => { + const { cache, evictAndGC, rulerQuery } = getEvictionTestData({ resultCaching: false }); + /* + * TODO: find a better way to spy. + */ + const reader = (cache as any).storeReader; + const executeSelectionSetEvictionHandler = reader['executeSelectionSetEvictionHandlers']; + const executeSubSelectedEvictionHandler = reader['executeSubSelectedArrayEvictionHandlers']; + const executeSelectionSetSpy = jest.spyOn(reader['executeSelectionSet'], 'forgetKey'); + const executeSubSelectedArraySpy = jest.spyOn(reader['executeSubSelectedArray'], 'forgetKey'); + + cache.watch({ + query: rulerQuery, + immediate: true, + optimistic: true, + callback: () => {}, + }); + expect(executeSelectionSetSpy).not.toBeCalled(); + expect(executeSubSelectedArraySpy).not.toBeCalled(); + + evictAndGC('Cronus'); + + /* + * Even after evicition no clearing happens since resultCaching is disabled. + */ + expect(executeSelectionSetSpy).not.toBeCalled(); + expect(executeSubSelectedArraySpy).not.toBeCalled(); + expect(executeSelectionSetEvictionHandler.size).toEqual(0); + expect(executeSubSelectedEvictionHandler.size).toEqual(0); + }); }); diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index 3f40b7d034b..9d3e1ddd554 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -263,7 +263,9 @@ export class InMemoryCache extends ApolloCache { // Request garbage collection of unreachable normalized entities. public gc() { - return this.optimisticData.gc(); + const evicted = this.optimisticData.gc(); + this.storeReader.onEvict(evicted); + return evicted; } // Call this method to ensure the given root ID remains in the cache after @@ -312,7 +314,11 @@ export class InMemoryCache extends ApolloCache { // this.txCount still seems like a good idea, for uniformity with // the other update methods. ++this.txCount; - return this.optimisticData.evict(options); + const evicted = this.optimisticData.evict(options); + if (evicted) { + this.storeReader.onEvict([ options.id! ]); + } + return evicted; } finally { if (!--this.txCount && options.broadcast !== false) { this.broadcastWatches(); diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index a37fcb08da8..12a066e3cf1 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -30,6 +30,7 @@ import { DiffQueryAgainstStoreOptions, NormalizedCache, ReadMergeModifyContext, + StoreValue, } from './types'; import { supportsResultCaching } from './entityStore'; import { getTypenameFromStoreObject } from './helpers'; @@ -75,6 +76,7 @@ type ExecSubSelectedArrayOptions = { field: FieldNode; array: any[]; context: ReadContext; + ref: StoreValue; }; export interface StoreReaderConfig { @@ -98,10 +100,25 @@ export class StoreReader { ExecResult, [ExecSubSelectedArrayOptions]>; + private executeSelectionSetEvictionHandlers: Map = new Map(); + private executeSubSelectedArrayEvictionHandlers: Map = new Map(); + constructor(private config: StoreReaderConfig) { this.config = { addTypename: true, ...config }; - this.executeSelectionSet = wrap(options => this.execSelectionSetImpl(options), { + this.executeSelectionSet = wrap(options => { + const key = this.executeSelectionSet.getKey(options); + if (key !== void 0) { + const ref = options.objectOrReference.__ref; + let handlers = this.executeSelectionSetEvictionHandlers.get(ref); + if (!handlers) { + handlers = []; + this.executeSelectionSetEvictionHandlers.set(ref, handlers); + } + handlers!.push(key); + } + return this.execSelectionSetImpl(options); + }, { keyArgs(options) { return [ options.selectionSet, @@ -122,8 +139,17 @@ export class StoreReader { } } }); - - this.executeSubSelectedArray = wrap((options: ExecSubSelectedArrayOptions) => { + this.executeSubSelectedArray = wrap((options: ExecSubSelectedArrayOptions) => { + const key = this.executeSubSelectedArray.getKey(options); + if (key !== void 0) { + const ref = options.ref; + let handlers = this.executeSubSelectedArrayEvictionHandlers.get(ref); + if (!handlers) { + handlers = []; + this.executeSubSelectedArrayEvictionHandlers.set(ref, handlers); + } + handlers!.push(key); + } return this.execSubSelectedArrayImpl(options); }, { max: this.config.resultCachMaxSize, @@ -139,6 +165,20 @@ export class StoreReader { }); } + public onEvict(evicted: string[]) { + evicted.forEach(dataId => { + this.executeSelectionSetEvictionHandlers.get(dataId)?.forEach((k) => { + this.executeSelectionSet.forgetKey(k); + }); + this.executeSelectionSetEvictionHandlers.delete(dataId); + + this.executeSubSelectedArrayEvictionHandlers.get(dataId)?.forEach((k) => { + this.executeSubSelectedArray.forgetKey(k); + }); + this.executeSubSelectedArrayEvictionHandlers.delete(dataId); + }); + } + /** * Given a store and a query, return as much of the result as possible and * identify if any data was missing from the store. @@ -300,6 +340,7 @@ export class StoreReader { field: selection, array: fieldValue, context, + ref: objectOrReference.__ref, })); } else if (!selection.selectionSet) { @@ -369,6 +410,7 @@ export class StoreReader { field, array, context, + ref, }: ExecSubSelectedArrayOptions): ExecResult { let missing: MissingFieldError[] | undefined; @@ -401,6 +443,7 @@ export class StoreReader { field, array: item, context, + ref, }), i); }