From 6207e1b7e63e003f7e0c109455cd6751182d5a6a Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 25 Nov 2019 19:19:43 -0500 Subject: [PATCH 1/4] Ensure StoreObject properties always start with field name. When a field has multiple cached values due to being written into the cache at different times with different arguments, it's important that we do not throw away the association between the short field.name.value (an identifier, as defined in the schema) and the longer storeFieldName strings computed from the field and its arguments. This commit ensures that field.name.value is always recoverable from the storeFieldName, by prepending field.name.value + ":" to any storeFieldName that does not already start with field.name.value. --- src/__tests__/__snapshots__/client.ts.snap | 4 +-- src/cache/inmemory/__tests__/readFromStore.ts | 7 ++++-- src/cache/inmemory/__tests__/writeToStore.ts | 6 ++--- src/cache/inmemory/helpers.ts | 6 +++++ src/cache/inmemory/policies.ts | 25 ++++++++++++++++--- src/cache/inmemory/types.ts | 2 +- 6 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/__tests__/__snapshots__/client.ts.snap b/src/__tests__/__snapshots__/client.ts.snap index 92258cfd705..4abceb872b6 100644 --- a/src/__tests__/__snapshots__/client.ts.snap +++ b/src/__tests__/__snapshots__/client.ts.snap @@ -4,7 +4,7 @@ exports[`@connection should run a query with the @connection directive and write Object { "ROOT_QUERY": Object { "__typename": "Query", - "abc": Array [ + "books:abc": Array [ Object { "__typename": "Book", "name": "abcd", @@ -18,7 +18,7 @@ exports[`@connection should run a query with the connection directive and filter Object { "ROOT_QUERY": Object { "__typename": "Query", - "abc({\\"order\\":\\"popularity\\"})": Array [ + "books:abc({\\"order\\":\\"popularity\\"})": Array [ Object { "__typename": "Book", "name": "abcd", diff --git a/src/cache/inmemory/__tests__/readFromStore.ts b/src/cache/inmemory/__tests__/readFromStore.ts index 3fa359b5bca..5f04dc8ded2 100644 --- a/src/cache/inmemory/__tests__/readFromStore.ts +++ b/src/cache/inmemory/__tests__/readFromStore.ts @@ -743,7 +743,7 @@ describe('reading from the store', () => { it('properly handles the @connection directive', () => { const store = defaultNormalizedCacheFactory({ ROOT_QUERY: { - abc: [ + 'books:abc': [ { name: 'efgh', }, @@ -778,6 +778,9 @@ describe('reading from the store', () => { Query: { fields: { books: { + // Even though we're returning an arbitrary string here, + // the InMemoryCache will ensure the actual key begins + // with "books". keyArgs: () => "abc", }, }, @@ -788,7 +791,7 @@ describe('reading from the store', () => { const store = defaultNormalizedCacheFactory({ ROOT_QUERY: { - abc: [ + "books:abc": [ { name: 'efgh', }, diff --git a/src/cache/inmemory/__tests__/writeToStore.ts b/src/cache/inmemory/__tests__/writeToStore.ts index 45beebaa19a..26dc7ddfffe 100644 --- a/src/cache/inmemory/__tests__/writeToStore.ts +++ b/src/cache/inmemory/__tests__/writeToStore.ts @@ -1806,7 +1806,7 @@ describe('writing to the store', () => { expect(store.toObject()).toEqual({ ROOT_QUERY: { __typename: "Query", - abc: [ + 'books:abc': [ { name: 'efgh', }, @@ -1852,7 +1852,7 @@ describe('writing to the store', () => { expect(store.toObject()).toEqual({ ROOT_QUERY: { __typename: "Query", - abc: [ + "books:abc": [ { name: 'abcd', }, @@ -1881,7 +1881,7 @@ describe('writing to the store', () => { expect(store.toObject()).toEqual({ ROOT_QUERY: { __typename: "Query", - abc: [ + "books:abc": [ { name: 'efgh', }, diff --git a/src/cache/inmemory/helpers.ts b/src/cache/inmemory/helpers.ts index 1f2881ad066..0a5123d2100 100644 --- a/src/cache/inmemory/helpers.ts +++ b/src/cache/inmemory/helpers.ts @@ -9,3 +9,9 @@ export function getTypenameFromStoreObject( ? store.getFieldValue(objectOrReference.__ref, "__typename") as string : objectOrReference && objectOrReference.__typename; } + +const FieldNamePattern = /^[_A-Za-z0-9]+/; +export function fieldNameFromStoreName(storeFieldName: string) { + const match = storeFieldName.match(FieldNamePattern); + return match && match[0]; +} diff --git a/src/cache/inmemory/policies.ts b/src/cache/inmemory/policies.ts index d4a89598d6a..f56d0dc2c4b 100644 --- a/src/cache/inmemory/policies.ts +++ b/src/cache/inmemory/policies.ts @@ -30,6 +30,8 @@ import { StoreObject, } from "./types"; +import { fieldNameFromStoreName } from './helpers'; + const hasOwn = Object.prototype.hasOwnProperty; export type TypePolicies = { @@ -383,16 +385,31 @@ export class Policies { field: FieldNode, variables: Record, ): string { + const fieldName = field.name.value; + let storeFieldName: string | undefined; + if (typeof typename === "string") { - const policy = this.getFieldPolicy(typename, field.name.value, false); + const policy = this.getFieldPolicy(typename, fieldName, false); if (policy && policy.keyFn) { - return policy.keyFn.call(this, field, { + // If the custom keyFn returns a falsy value, fall back to + // fieldName instead. + storeFieldName = policy.keyFn.call(this, field, { typename, variables, - }); + }) || fieldName; } } - return storeKeyNameFromField(field, variables); + + if (storeFieldName === void 0) { + storeFieldName = storeKeyNameFromField(field, variables); + } + + // Make sure custom field names start with the actual field.name.value + // of the field, so we can always figure out which properties of a + // StoreObject correspond to which original field names. + return fieldName === fieldNameFromStoreName(storeFieldName) + ? storeFieldName + : fieldName + ":" + storeFieldName; } public readFieldFromStoreObject( diff --git a/src/cache/inmemory/types.ts b/src/cache/inmemory/types.ts index e62c6ee8f46..50486e4bfed 100644 --- a/src/cache/inmemory/types.ts +++ b/src/cache/inmemory/types.ts @@ -57,7 +57,7 @@ export interface NormalizedCacheObject { export interface StoreObject { __typename?: string; - [storeFieldKey: string]: StoreValue; + [storeFieldName: string]: StoreValue; } export type OptimisticStoreItem = { From acd2562c6a256ae7d3234947900842ed32a7a5a4 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Wed, 27 Nov 2019 15:59:09 -0500 Subject: [PATCH 2/4] Use field.name.value for result caching, rather than storeFieldName. In PR #5617, we shifted from using just IDs for caching results read from the EntityStore to using IDs plus field names, which made the result caching system significantly more precise and less likely to trigger unnecessary rerenders. However, the field names we used in that PR were storeFieldName strings, instead of the actual field names you'd find in your GraphQL schema. Within the InMemoryCache implementation, a storeFieldName is the full string produced by Policies#getStoreFieldName, which must begin with the original field.name.value, but may include an additional suffix that captures field identity based on arguments (and also, but less frequently, directives). For the purposes of result caching, it's important to depend on the entity ID plus the actual field name (field.name.value), rather than the ID plus the storeFieldName, because there can be many different storeFieldName strings for a single field.name.value (and we don't want to waste memory on dependency tracking), and because it's safer (more conservative) to dirty all fields with a particular field.name.value when any field with a matching storeFieldName is updated or evicted, since field values with the same field.name.value often overlap in subtle ways (for example, multiple pages of a paginated list), so it seems dangerous to encourage deleting some but not all of them. Perhaps more importantly, application developers should never have to worry about the concept of a storeFieldName, since it is very much an implementation detail of the InMemoryCache, and the precise format of storeFieldName strings is subject to change. --- src/cache/inmemory/entityStore.ts | 56 ++++++++++++++----------------- src/cache/inmemory/types.ts | 2 +- 2 files changed, 27 insertions(+), 31 deletions(-) diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index 1b52ef56cad..e680fdded98 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -7,32 +7,36 @@ import { } from '../../utilities/common/mergeDeep'; import { isEqual } from '../../utilities/common/isEqual'; import { NormalizedCache, NormalizedCacheObject, StoreObject } from './types'; -import { getTypenameFromStoreObject } from './helpers'; +import { + getTypenameFromStoreObject, + fieldNameFromStoreName, +} from './helpers'; const hasOwn = Object.prototype.hasOwnProperty; type DependType = OptimisticDependencyFunction | null; -function makeDepKey(dataId: string, fieldName?: string) { +function makeDepKey(dataId: string, storeFieldName?: string) { const parts = [dataId]; - if (typeof fieldName === "string") { - parts.push(fieldName); + if (typeof storeFieldName === "string") { + parts.push(fieldNameFromStoreName(storeFieldName)); } return JSON.stringify(parts); } -function depend(store: EntityStore, dataId: string, fieldName?: string) { +function depend(store: EntityStore, dataId: string, storeFieldName?: string) { if (store.depend) { - store.depend(makeDepKey(dataId, fieldName)); + store.depend(makeDepKey(dataId, storeFieldName)); } } -function dirty(store: EntityStore, dataId: string, fieldName?: string) { +function dirty(store: EntityStore, dataId: string, storeFieldName?: string) { if (store.depend) { - store.depend.dirty(makeDepKey(dataId)); - if (typeof fieldName === "string") { - store.depend.dirty(makeDepKey(dataId, fieldName)); - } + store.depend.dirty( + typeof storeFieldName === "string" + ? makeDepKey(dataId, storeFieldName) + : makeDepKey(dataId), + ); } } @@ -70,10 +74,10 @@ export abstract class EntityStore implements NormalizedCache { return this.data[dataId]; } - public getFieldValue(dataId: string, fieldName: string): StoreValue { - depend(this, dataId, fieldName); + public getFieldValue(dataId: string, storeFieldName: string): StoreValue { + depend(this, dataId, storeFieldName); const storeObject = this.data[dataId]; - return storeObject && storeObject[fieldName]; + return storeObject && storeObject[storeFieldName]; } public merge(dataId: string, incoming: StoreObject): void { @@ -89,9 +93,9 @@ export abstract class EntityStore implements NormalizedCache { dirty(this, dataId); // Now invalidate dependents who called getFieldValue for any // fields that are changing as a result of this merge. - Object.keys(incoming).forEach(fieldName => { - if (!existing || incoming[fieldName] !== existing[fieldName]) { - dirty(this, dataId, fieldName); + Object.keys(incoming).forEach(storeFieldName => { + if (!existing || incoming[storeFieldName] !== existing[storeFieldName]) { + dirty(this, dataId, storeFieldName); } }); } @@ -322,27 +326,19 @@ class Layer extends EntityStore { return this.parent.get(dataId); } - public getFieldValue(dataId: string, fieldName: string): StoreValue { + public getFieldValue(dataId: string, storeFieldName: string): StoreValue { if (hasOwn.call(this.data, dataId)) { const storeObject = this.data[dataId]; - if (storeObject && hasOwn.call(storeObject, fieldName)) { - return super.getFieldValue(dataId, fieldName); + if (storeObject && hasOwn.call(storeObject, storeFieldName)) { + return super.getFieldValue(dataId, storeFieldName); } } if (this.depend && this.depend !== this.parent.depend) { - depend(this, dataId, fieldName); + depend(this, dataId, storeFieldName); } - return this.parent.getFieldValue(dataId, fieldName); - } - - public delete(dataId: string): void { - super.delete(dataId); - // In case this.parent (or one of its ancestors) has an entry for this ID, - // we need to shadow it with an undefined value, or it might be inherited - // by the Layer#get method. - this.data[dataId] = void 0; + return this.parent.getFieldValue(dataId, storeFieldName); } // Return a Set of all the ID strings that have been retained by this diff --git a/src/cache/inmemory/types.ts b/src/cache/inmemory/types.ts index 50486e4bfed..329ba077658 100644 --- a/src/cache/inmemory/types.ts +++ b/src/cache/inmemory/types.ts @@ -20,7 +20,7 @@ export declare type IdGetter = ( export interface NormalizedCache { has(dataId: string): boolean; get(dataId: string): StoreObject; - getFieldValue(dataId: string, fieldName: string): StoreValue; + getFieldValue(dataId: string, storeFieldName: string): StoreValue; merge(dataId: string, incoming: StoreObject): void; delete(dataId: string): void; clear(): void; From d508de639d41fb295986f9445ae8d5a8cd8a9e1f Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 25 Nov 2019 19:20:22 -0500 Subject: [PATCH 3/4] Support eviction of specific entity fields. Evicting an entire entity object is often overkill, when all you really want is to invalidate and refetch specific fields within the entity. This observation is especially true for the ROOT_QUERY object, which should never be evicted in its entirety, but whose individual fields often become stale or need to be recomputed. A critical nuance here is that fields are evicted according to their field.name.value, rather than a specific storeFieldName, since it doesn't make a whole lot of sense to evict the field value associated with a specific set of arguments. Instead, calling cache.evict(id, fieldName) will evict *all* values for that field, regardless of the arguments. --- src/cache/core/cache.ts | 6 +- src/cache/inmemory/__tests__/entityStore.ts | 299 ++++++++++++++++++++ src/cache/inmemory/entityStore.ts | 94 ++++-- src/cache/inmemory/inMemoryCache.ts | 4 +- src/cache/inmemory/types.ts | 2 +- 5 files changed, 384 insertions(+), 21 deletions(-) diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index bfb6512a442..ecd32687ac1 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -18,9 +18,13 @@ export abstract class ApolloCache implements DataProxy { ): void; public abstract diff(query: Cache.DiffOptions): Cache.DiffResult; public abstract watch(watch: Cache.WatchOptions): () => void; - public abstract evict(dataId: string): boolean; public abstract reset(): Promise; + // If called with only one argument, removes the entire entity + // identified by dataId. If called with a fieldName as well, removes all + // fields of the identified entity whose store names match fieldName. + public abstract evict(dataId: string, fieldName?: string): boolean; + // intializer / offline / ssr API /** * Replaces existing state in the cache (if any) with the values expressed by diff --git a/src/cache/inmemory/__tests__/entityStore.ts b/src/cache/inmemory/__tests__/entityStore.ts index 7821a4178a8..63a943bfc7b 100644 --- a/src/cache/inmemory/__tests__/entityStore.ts +++ b/src/cache/inmemory/__tests__/entityStore.ts @@ -763,6 +763,305 @@ describe('EntityStore', () => { ]); }); + it("allows evicting specific fields", () => { + const query: DocumentNode = gql` + query { + authorOfBook(isbn: $isbn) { + name + hobby + } + publisherOfBook(isbn: $isbn) { + name + yearOfFounding + } + } + `; + + const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + authorOfBook: { + keyArgs: ["isbn"], + }, + }, + }, + Author: { + keyFields: ["name"], + }, + Publisher: { + keyFields: ["name"], + }, + }, + }); + + const TedChiangData = { + __typename: "Author", + name: "Ted Chiang", + hobby: "video games", + }; + + const KnopfData = { + __typename: "Publisher", + name: "Alfred A. Knopf", + yearOfFounding: 1915, + }; + + cache.writeQuery({ + query, + data: { + authorOfBook: TedChiangData, + publisherOfBook: KnopfData, + }, + variables: { + isbn: "1529014514", + }, + }); + + const justTedRootQueryData = { + __typename: "Query", + 'authorOfBook:{"isbn":"1529014514"}': { + __ref: 'Author:{"name":"Ted Chiang"}', + }, + // This storeFieldName format differs slightly from that of + // authorOfBook because we did not define keyArgs for the + // publisherOfBook field, so the legacy storeKeyNameFromField + // function was used instead. + 'publisherOfBook({"isbn":"1529014514"})': { + __ref: 'Publisher:{"name":"Alfred A. Knopf"}', + }, + }; + + expect(cache.extract()).toEqual({ + ROOT_QUERY: justTedRootQueryData, + 'Author:{"name":"Ted Chiang"}': TedChiangData, + 'Publisher:{"name":"Alfred A. Knopf"}': KnopfData, + }); + + const JennyOdellData = { + __typename: "Author", + name: "Jenny Odell", + hobby: "birding", + }; + + const MelvilleData = { + __typename: "Publisher", + name: "Melville House", + yearOfFounding: 2001, + }; + + cache.writeQuery({ + query, + data: { + authorOfBook: JennyOdellData, + publisherOfBook: MelvilleData, + }, + variables: { + isbn: "1760641790", + }, + }); + + const justJennyRootQueryData = { + __typename: "Query", + 'authorOfBook:{"isbn":"1760641790"}': { + __ref: 'Author:{"name":"Jenny Odell"}', + }, + 'publisherOfBook({"isbn":"1760641790"})': { + __ref: 'Publisher:{"name":"Melville House"}', + }, + }; + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + ...justTedRootQueryData, + ...justJennyRootQueryData, + }, + 'Author:{"name":"Ted Chiang"}': TedChiangData, + 'Publisher:{"name":"Alfred A. Knopf"}': KnopfData, + 'Author:{"name":"Jenny Odell"}': JennyOdellData, + 'Publisher:{"name":"Melville House"}': MelvilleData, + }); + + const fullTedResult = cache.readQuery({ + query, + variables: { + isbn: "1529014514", + }, + }); + + expect(fullTedResult).toEqual({ + authorOfBook: TedChiangData, + publisherOfBook: KnopfData, + }); + + const fullJennyResult = cache.readQuery({ + query, + variables: { + isbn: "1760641790", + }, + }); + + expect(fullJennyResult).toEqual({ + authorOfBook: JennyOdellData, + publisherOfBook: MelvilleData, + }); + + cache.evict( + cache.identify({ + __typename: "Publisher", + name: "Alfred A. Knopf", + }), + "yearOfFounding", + ); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + ...justTedRootQueryData, + ...justJennyRootQueryData, + }, + 'Author:{"name":"Ted Chiang"}': TedChiangData, + 'Publisher:{"name":"Alfred A. Knopf"}': { + __typename: "Publisher", + name: "Alfred A. Knopf", + // yearOfFounding has been removed + }, + 'Author:{"name":"Jenny Odell"}': JennyOdellData, + 'Publisher:{"name":"Melville House"}': MelvilleData, + }); + + // Nothing to garbage collect yet. + expect(cache.gc()).toEqual([]); + + cache.evict(cache.identify({ + __typename: "Publisher", + name: "Melville House", + })); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + ...justTedRootQueryData, + ...justJennyRootQueryData, + }, + 'Author:{"name":"Ted Chiang"}': TedChiangData, + 'Publisher:{"name":"Alfred A. Knopf"}': { + __typename: "Publisher", + name: "Alfred A. Knopf", + }, + 'Author:{"name":"Jenny Odell"}': JennyOdellData, + // Melville House has been removed + }); + + cache.evict("ROOT_QUERY", "publisherOfBook"); + + function withoutPublisherOfBook(obj: object) { + const clean = { ...obj }; + Object.keys(obj).forEach(key => { + if (key.startsWith("publisherOfBook")) { + delete clean[key]; + } + }); + return clean; + } + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + ...withoutPublisherOfBook(justTedRootQueryData), + ...withoutPublisherOfBook(justJennyRootQueryData), + }, + 'Author:{"name":"Ted Chiang"}': TedChiangData, + 'Publisher:{"name":"Alfred A. Knopf"}': { + __typename: "Publisher", + name: "Alfred A. Knopf", + }, + 'Author:{"name":"Jenny Odell"}': JennyOdellData, + }); + + expect(cache.gc()).toEqual([ + 'Publisher:{"name":"Alfred A. Knopf"}', + ]); + + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + ...withoutPublisherOfBook(justTedRootQueryData), + ...withoutPublisherOfBook(justJennyRootQueryData), + }, + 'Author:{"name":"Ted Chiang"}': TedChiangData, + 'Author:{"name":"Jenny Odell"}': JennyOdellData, + }); + + const partialTedResult = cache.diff({ + query, + returnPartialData: true, + optimistic: false, // required but not important + variables: { + isbn: "1529014514", + }, + }); + expect(partialTedResult.complete).toBe(false); + expect(partialTedResult.result).toEqual({ + authorOfBook: TedChiangData, + }); + // The result caching system preserves the referential identity of + // unchanged nested result objects. + expect( + partialTedResult.result.authorOfBook, + ).toBe(fullTedResult.authorOfBook); + + const partialJennyResult = cache.diff({ + query, + returnPartialData: true, + optimistic: true, // required but not important + variables: { + isbn: "1760641790", + }, + }); + expect(partialJennyResult.complete).toBe(false); + expect(partialJennyResult.result).toEqual({ + authorOfBook: JennyOdellData, + }); + // The result caching system preserves the referential identity of + // unchanged nested result objects. + expect( + partialJennyResult.result.authorOfBook, + ).toBe(fullJennyResult.authorOfBook); + + const tedWithoutHobby = { + __typename: "Author", + name: "Ted Chiang", + }; + + cache.evict( + cache.identify(tedWithoutHobby), + "hobby", + ); + + expect(cache.diff({ + query, + returnPartialData: true, + optimistic: false, // required but not important + variables: { + isbn: "1529014514", + }, + })).toEqual({ + complete: false, + result: { + authorOfBook: tedWithoutHobby, + }, + }); + + cache.evict("ROOT_QUERY", "authorOfBook"); + expect(cache.gc().sort()).toEqual([ + 'Author:{"name":"Jenny Odell"}', + 'Author:{"name":"Ted Chiang"}', + ]); + expect(cache.extract()).toEqual({ + ROOT_QUERY: { + // Everything else has been removed. + __typename: "Query", + }, + }); + }); + it("supports cache.identify(object)", () => { const queryWithAliases: DocumentNode = gql` query { diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index e680fdded98..a66d1f6ff5b 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -102,23 +102,83 @@ export abstract class EntityStore implements NormalizedCache { } } - // TODO Allow deleting fields of this.data[dataId] according to their - // original field.name.value. - public delete(dataId: string): void { - const storeObject = this.data[dataId]; - - delete this.data[dataId]; - delete this.refs[dataId]; - // Note that we do not delete the this.rootIds[dataId] retainment - // count for this ID, since an object with the same ID could appear in - // the store again, and should not have to be retained again. - // delete this.rootIds[dataId]; - - if (this.depend && storeObject) { - dirty(this, dataId); - Object.keys(storeObject).forEach(fieldName => { - dirty(this, dataId, fieldName); + // If called with only one argument, removes the entire entity + // identified by dataId. If called with a fieldName as well, removes all + // fields of that entity whose names match fieldName, according to the + // fieldNameFromStoreName helper function. + public delete(dataId: string, fieldName?: string) { + const storeObject = this.get(dataId); + + if (storeObject) { + // In case someone passes in a storeFieldName (field.name.value + + // arguments key), normalize it down to just the field name. + fieldName = fieldName && fieldNameFromStoreName(fieldName); + + const storeNamesToDelete: string[] = []; + Object.keys(storeObject).forEach(storeFieldName => { + // If the field value has already been set to undefined, we do not + // need to delete it again. + if (storeObject[storeFieldName] !== void 0 && + // If no fieldName provided, delete all fields from storeObject. + // If provided, delete all fields matching fieldName. + (!fieldName || fieldName === fieldNameFromStoreName(storeFieldName))) { + storeNamesToDelete.push(storeFieldName); + } }); + + if (storeNamesToDelete.length) { + // If we only have to worry about the Root layer of the store, + // then we can safely delete fields within entities, or whole + // entities by ID. If this instanceof EntityStore.Layer, however, + // then we need to set the "deleted" values to undefined instead + // of actually deleting them, so the deletion does not un-shadow + // values inherited from lower layers of the store. + const canDelete = this instanceof EntityStore.Root; + const remove = (obj: Record, key: string) => { + if (canDelete) { + delete obj[key]; + } else { + obj[key] = void 0; + } + }; + + // Note that we do not delete the this.rootIds[dataId] retainment + // count for this ID, since an object with the same ID could appear in + // the store again, and should not have to be retained again. + // delete this.rootIds[dataId]; + delete this.refs[dataId]; + + const fieldsToDirty: Record = Object.create(null); + + if (fieldName) { + // If we have a fieldName and it matches more than zero fields, + // then we need to make a copy of this.data[dataId] without the + // fields that are getting deleted. + const cleaned = this.data[dataId] = { ...storeObject }; + storeNamesToDelete.forEach(storeFieldName => { + remove(cleaned, storeFieldName); + }); + // Although it would be logically correct to dirty each + // storeFieldName in the loop above, we know that they all have + // the same name, according to fieldNameFromStoreName. + fieldsToDirty[fieldName] = true; + } else { + // If no fieldName was provided, then we delete the whole entity + // from the cache. + remove(this.data, dataId); + storeNamesToDelete.forEach(storeFieldName => { + const fieldName = fieldNameFromStoreName(storeFieldName); + fieldsToDirty[fieldName] = true; + }); + } + + if (this.depend) { + dirty(this, dataId); + Object.keys(fieldsToDirty).forEach(fieldName => { + dirty(this, dataId, fieldName); + }); + } + } } } @@ -187,7 +247,7 @@ export abstract class EntityStore implements NormalizedCache { if (idsToRemove.length) { let root: EntityStore = this; while (root instanceof Layer) root = root.parent; - idsToRemove.forEach(root.delete, root); + idsToRemove.forEach(id => root.delete(id)); } return idsToRemove; } diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index dc8bdbd7c1c..6aa5c6263d8 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -206,12 +206,12 @@ export class InMemoryCache extends ApolloCache { return this.policies.identify(object); } - public evict(dataId: string): boolean { + public evict(dataId: string, fieldName?: string): boolean { if (this.optimisticData.has(dataId)) { // Note that this deletion does not trigger a garbage collection, which // is convenient in cases where you want to evict multiple entities before // performing a single garbage collection. - this.optimisticData.delete(dataId); + this.optimisticData.delete(dataId, fieldName); this.broadcastWatches(); return !this.optimisticData.has(dataId); } diff --git a/src/cache/inmemory/types.ts b/src/cache/inmemory/types.ts index 329ba077658..9d0255dbdde 100644 --- a/src/cache/inmemory/types.ts +++ b/src/cache/inmemory/types.ts @@ -22,7 +22,7 @@ export interface NormalizedCache { get(dataId: string): StoreObject; getFieldValue(dataId: string, storeFieldName: string): StoreValue; merge(dataId: string, incoming: StoreObject): void; - delete(dataId: string): void; + delete(dataId: string, fieldName?: string): void; clear(): void; // non-Map elements: From 5f153e85325b5efde661eaf8ff23738f5526757a Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Mon, 2 Dec 2019 18:30:44 -0500 Subject: [PATCH 4/4] Increase bundlesize limit from 23.75 to 23.9 kB. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d58314e87e4..aabbe5cedf9 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ { "name": "apollo-client", "path": "./dist/apollo-client.cjs.min.js", - "maxSize": "23.75 kB" + "maxSize": "23.9 kB" } ], "peerDependencies": {