diff --git a/CHANGELOG.md b/CHANGELOG.md
index ce9e73c6481..2bb9275272b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,10 +4,15 @@
## Improvements
+- `InMemoryCache` now supports tracing garbage collection and eviction. Note that the signature of the `evict` method has been simplified in a potentially backwards-incompatible way.
+ [@benjamn](https://github.com/benjamn) in [#5310](https://github.com/apollographql/apollo-client/pull/5310)
+
- Fully removed `prettier`. The Apollo Client team has decided to no longer automatically enforce code formatting across the codebase. In most cases existing code styles should be followed as much as possible, but this is not a hard and fast rule.
[@hwillson](https://github.com/hwillson) in [#5227](https://github.com/apollographql/apollo-client/pull/5227)
+
- Update the `fetchMore` type signature to accept `context`.
[@koenpunt](https://github.com/koenpunt) in [#5147](https://github.com/apollographql/apollo-client/pull/5147)
+
- Fix type for `Resolver` and use it in the definition of `Resolvers`.
[@peoplenarthax](https://github.com/peoplenarthax) in [#4943](https://github.com/apollographql/apollo-client/pull/4943)
@@ -15,10 +20,10 @@
- Removed `graphql-anywhere` since it's no longer used by Apollo Client.
[@hwillson](https://github.com/hwillson) in [#5159](https://github.com/apollographql/apollo-client/pull/5159)
+
- Removed `apollo-boost` since Apollo Client 3.0 provides a boost like getting started experience out of the box.
[@hwillson](https://github.com/hwillson) in [#5217](https://github.com/apollographql/apollo-client/pull/5217)
-
## Apollo Client (2.6.4)
### Apollo Client (2.6.4)
diff --git a/package.json b/package.json
index 030414c0c24..f37ff7cf041 100644
--- a/package.json
+++ b/package.json
@@ -45,7 +45,7 @@
{
"name": "apollo-client",
"path": "./lib/apollo-client.cjs.min.js",
- "maxSize": "16.55 kB"
+ "maxSize": "16.9 kB"
}
],
"dependencies": {
diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts
index ca2980fd8f9..064fb31848a 100644
--- a/src/cache/core/cache.ts
+++ b/src/cache/core/cache.ts
@@ -17,9 +17,7 @@ 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(
- query: Cache.EvictOptions,
- ): Cache.EvictionResult;
+ public abstract evict(dataId: string): boolean;
public abstract reset(): Promise;
// intializer / offline / ssr API
diff --git a/src/cache/core/types/Cache.ts b/src/cache/core/types/Cache.ts
index da6ef0bdd41..911e15c2d66 100644
--- a/src/cache/core/types/Cache.ts
+++ b/src/cache/core/types/Cache.ts
@@ -2,9 +2,6 @@ import { DataProxy } from './DataProxy';
export namespace Cache {
export type WatchCallback = (newData: any) => void;
- export interface EvictionResult {
- success: Boolean;
- }
export interface ReadOptions
extends DataProxy.Query {
@@ -27,11 +24,6 @@ export namespace Cache {
callback: WatchCallback;
}
- export interface EvictOptions
- extends DataProxy.Query {
- rootId?: string;
- }
-
export import DiffResult = DataProxy.DiffResult;
export import WriteQueryOptions = DataProxy.WriteQueryOptions;
export import WriteFragmentOptions = DataProxy.WriteFragmentOptions;
diff --git a/src/cache/inmemory/__tests__/entityCache.ts b/src/cache/inmemory/__tests__/entityCache.ts
new file mode 100644
index 00000000000..d7e921fb515
--- /dev/null
+++ b/src/cache/inmemory/__tests__/entityCache.ts
@@ -0,0 +1,745 @@
+import gql from 'graphql-tag';
+import { EntityCache, supportsResultCaching } from '../entityCache';
+import { InMemoryCache } from '../inMemoryCache';
+
+describe('EntityCache', () => {
+ it('should support result caching if so configured', () => {
+ const cacheWithResultCaching = new EntityCache.Root({
+ resultCaching: true,
+ });
+
+ const cacheWithoutResultCaching = new EntityCache.Root({
+ resultCaching: false,
+ });
+
+ expect(supportsResultCaching({ some: "arbitrary object " })).toBe(false);
+ expect(supportsResultCaching(cacheWithResultCaching)).toBe(true);
+ expect(supportsResultCaching(cacheWithoutResultCaching)).toBe(false);
+
+ const layerWithCaching = cacheWithResultCaching.addLayer("with caching", () => {});
+ expect(supportsResultCaching(layerWithCaching)).toBe(true);
+ const anotherLayer = layerWithCaching.addLayer("another layer", () => {});
+ expect(supportsResultCaching(anotherLayer)).toBe(true);
+ expect(
+ anotherLayer
+ .removeLayer("with caching")
+ .removeLayer("another layer")
+ ).toBe(cacheWithResultCaching);
+ expect(supportsResultCaching(cacheWithResultCaching)).toBe(true);
+
+ const layerWithoutCaching = cacheWithoutResultCaching.addLayer("with caching", () => {});
+ expect(supportsResultCaching(layerWithoutCaching)).toBe(false);
+ expect(layerWithoutCaching.removeLayer("with caching")).toBe(cacheWithoutResultCaching);
+ expect(supportsResultCaching(cacheWithoutResultCaching)).toBe(false);
+ });
+
+ function newBookAuthorCache() {
+ const cache = new InMemoryCache({
+ resultCaching: true,
+ dataIdFromObject(value: any) {
+ switch (value && value.__typename) {
+ case 'Book':
+ return 'Book:' + value.isbn;
+ case 'Author':
+ return 'Author:' + value.name;
+ }
+ },
+ });
+
+ const query = gql`
+ query {
+ book {
+ title
+ author {
+ name
+ }
+ }
+ }
+ `;
+
+ return {
+ cache,
+ query,
+ };
+ }
+
+ it('should reclaim no-longer-reachable, unretained entities', () => {
+ const { cache, query } = newBookAuthorCache();
+
+ cache.writeQuery({
+ query,
+ data: {
+ book: {
+ __typename: 'Book',
+ isbn: '9781451673319',
+ title: 'Fahrenheit 451',
+ author: {
+ __typename: 'Author',
+ name: 'Ray Bradbury',
+ }
+ },
+ },
+ });
+
+ expect(cache.extract()).toEqual({
+ ROOT_QUERY: {
+ 'book': {
+ __ref: "Book:9781451673319",
+ },
+ },
+ "Book:9781451673319": {
+ __typename: "Book",
+ title: "Fahrenheit 451",
+ author: {
+ __ref: 'Author:Ray Bradbury',
+ }
+ },
+ "Author:Ray Bradbury": {
+ __typename: "Author",
+ name: "Ray Bradbury",
+ },
+ });
+
+ cache.writeQuery({
+ query,
+ data: {
+ book: {
+ __typename: 'Book',
+ isbn: '0312429215',
+ title: '2666',
+ author: {
+ __typename: 'Author',
+ name: 'Roberto Bolaño',
+ },
+ },
+ },
+ });
+
+ const snapshot = cache.extract();
+
+ expect(snapshot).toEqual({
+ ROOT_QUERY: {
+ 'book': {
+ __ref: "Book:0312429215",
+ },
+ },
+ "Book:9781451673319": {
+ __typename: "Book",
+ title: "Fahrenheit 451",
+ author: {
+ __ref: 'Author:Ray Bradbury',
+ }
+ },
+ "Author:Ray Bradbury": {
+ __typename: "Author",
+ name: "Ray Bradbury",
+ },
+ "Book:0312429215": {
+ __typename: "Book",
+ author: {
+ __ref: "Author:Roberto Bolaño",
+ },
+ title: "2666",
+ },
+ "Author:Roberto Bolaño": {
+ __typename: "Author",
+ name: "Roberto Bolaño",
+ },
+ });
+
+ expect(cache.gc().sort()).toEqual([
+ 'Author:Ray Bradbury',
+ 'Book:9781451673319',
+ ]);
+
+ expect(cache.extract()).toEqual({
+ ROOT_QUERY: {
+ 'book': {
+ __ref: "Book:0312429215",
+ },
+ },
+ "Book:0312429215": {
+ __typename: "Book",
+ author: {
+ __ref: "Author:Roberto Bolaño",
+ },
+ title: "2666",
+ },
+ "Author:Roberto Bolaño": {
+ __typename: "Author",
+ name: "Roberto Bolaño",
+ },
+ });
+
+ // Nothing left to garbage collect.
+ expect(cache.gc()).toEqual([]);
+
+ // Go back to the pre-GC snapshot.
+ cache.restore(snapshot);
+ expect(cache.extract()).toEqual(snapshot);
+
+ // Reading a specific fragment causes it to be retained during garbage collection.
+ const authorNameFragment = gql`
+ fragment AuthorName on Author {
+ name
+ }
+ `;
+ const ray = cache.readFragment({
+ id: 'Author:Ray Bradbury',
+ fragment: authorNameFragment,
+ });
+
+ expect(cache.retain('Author:Ray Bradbury')).toBe(1);
+
+ expect(ray).toEqual({
+ __typename: 'Author',
+ name: 'Ray Bradbury',
+ });
+
+ expect(cache.gc()).toEqual([
+ // Only Fahrenheit 451 (the book) is reclaimed this time.
+ 'Book:9781451673319',
+ ]);
+
+ expect(cache.extract()).toEqual({
+ ROOT_QUERY: {
+ 'book': {
+ __ref: "Book:0312429215",
+ },
+ },
+ "Author:Ray Bradbury": {
+ __typename: "Author",
+ name: "Ray Bradbury",
+ },
+ "Book:0312429215": {
+ __typename: "Book",
+ author: {
+ __ref: "Author:Roberto Bolaño",
+ },
+ title: "2666",
+ },
+ "Author:Roberto Bolaño": {
+ __typename: "Author",
+ name: "Roberto Bolaño",
+ },
+ });
+
+ expect(cache.gc()).toEqual([]);
+
+ expect(cache.release('Author:Ray Bradbury')).toBe(0);
+
+ expect(cache.gc()).toEqual([
+ 'Author:Ray Bradbury',
+ ]);
+
+ expect(cache.gc()).toEqual([]);
+ });
+
+ it('should respect optimistic updates, when active', () => {
+ const { cache, query } = newBookAuthorCache();
+
+ cache.writeQuery({
+ query,
+ data: {
+ book: {
+ __typename: 'Book',
+ isbn: '9781451673319',
+ title: 'Fahrenheit 451',
+ author: {
+ __typename: 'Author',
+ name: 'Ray Bradbury',
+ }
+ },
+ },
+ });
+
+ expect(cache.gc()).toEqual([]);
+
+ // Orphan the F451 / Ray Bradbury data, but avoid collecting garbage yet.
+ cache.writeQuery({
+ query,
+ data: {
+ book: {
+ __typename: 'Book',
+ isbn: '1980719802',
+ title: '1984',
+ author: {
+ __typename: 'Author',
+ name: 'George Orwell',
+ },
+ }
+ }
+ });
+
+ cache.recordOptimisticTransaction(proxy => {
+ proxy.writeFragment({
+ id: 'Author:Ray Bradbury',
+ fragment: gql`
+ fragment AuthorBooks on Author {
+ books {
+ title
+ }
+ }
+ `,
+ data: {
+ books: [
+ {
+ __typename: 'Book',
+ isbn: '9781451673319',
+ },
+ ],
+ },
+ });
+ }, "ray books");
+
+ expect(cache.extract(true)).toEqual({
+ ROOT_QUERY: {
+ book: {
+ __ref: "Book:1980719802",
+ },
+ },
+ "Author:Ray Bradbury": {
+ __typename: "Author",
+ name: "Ray Bradbury",
+ books: [
+ {
+ __ref: "Book:9781451673319",
+ },
+ ],
+ },
+ "Book:9781451673319": {
+ __typename: "Book",
+ title: "Fahrenheit 451",
+ author: {
+ __ref: "Author:Ray Bradbury",
+ },
+ },
+ "Author:George Orwell": {
+ __typename: "Author",
+ name: "George Orwell",
+ },
+ "Book:1980719802": {
+ __typename: "Book",
+ title: "1984",
+ author: {
+ __ref: "Author:George Orwell",
+ },
+ },
+ });
+
+ // Nothing can be reclaimed while the optimistic update is retaining
+ // Fahrenheit 451.
+ expect(cache.gc()).toEqual([]);
+
+ cache.removeOptimistic("ray books");
+
+ expect(cache.extract(true)).toEqual({
+ ROOT_QUERY: {
+ book: {
+ __ref: "Book:1980719802",
+ },
+ },
+ "Author:Ray Bradbury": {
+ __typename: "Author",
+ name: "Ray Bradbury",
+ // Note that the optimistic books field has disappeared, as expected.
+ },
+ "Book:9781451673319": {
+ __typename: "Book",
+ title: "Fahrenheit 451",
+ author: {
+ __ref: "Author:Ray Bradbury",
+ },
+ },
+ "Author:George Orwell": {
+ __typename: "Author",
+ name: "George Orwell",
+ },
+ "Book:1980719802": {
+ __typename: "Book",
+ title: "1984",
+ author: {
+ __ref: "Author:George Orwell",
+ },
+ },
+ });
+
+ expect(cache.gc().sort()).toEqual([
+ "Author:Ray Bradbury",
+ "Book:9781451673319",
+ ]);
+
+ expect(cache.extract(true)).toEqual({
+ ROOT_QUERY: {
+ book: {
+ __ref: "Book:1980719802",
+ },
+ },
+ "Author:George Orwell": {
+ __typename: "Author",
+ name: "George Orwell",
+ },
+ "Book:1980719802": {
+ __typename: "Book",
+ title: "1984",
+ author: {
+ __ref: "Author:George Orwell",
+ },
+ },
+ });
+
+ expect(cache.gc()).toEqual([]);
+ });
+
+ it('should respect retain/release methods', () => {
+ const { query, cache } = newBookAuthorCache();
+
+ const eagerBookData = {
+ __typename: 'Book',
+ isbn: '1603589082',
+ title: 'Eager',
+ subtitle: 'The Surprising, Secret Life of Beavers and Why They Matter',
+ author: {
+ __typename: 'Author',
+ name: 'Ben Goldfarb',
+ },
+ };
+
+ const spinelessBookData = {
+ __typename: 'Book',
+ isbn: '0735211280',
+ title: 'Spineless',
+ subtitle: 'The Science of Jellyfish and the Art of Growing a Backbone',
+ author: {
+ __typename: 'Author',
+ name: 'Juli Berwald',
+ },
+ };
+
+ cache.writeQuery({
+ query,
+ data: {
+ book: spinelessBookData,
+ },
+ });
+
+ expect(cache.extract(true)).toEqual({
+ ROOT_QUERY: {
+ book: {
+ __ref: "Book:0735211280",
+ },
+ },
+ "Book:0735211280": {
+ __typename: "Book",
+ author: {
+ __ref: "Author:Juli Berwald",
+ },
+ title: "Spineless",
+ },
+ "Author:Juli Berwald": {
+ __typename: "Author",
+ name: "Juli Berwald",
+ },
+ });
+
+ cache.writeQuery({
+ query,
+ data: {
+ book: eagerBookData,
+ },
+ });
+
+ const snapshotWithBothBooksAndAuthors = {
+ ROOT_QUERY: {
+ book: {
+ __ref: "Book:1603589082",
+ },
+ },
+ "Book:0735211280": {
+ __typename: "Book",
+ author: {
+ __ref: "Author:Juli Berwald",
+ },
+ title: "Spineless",
+ },
+ "Author:Juli Berwald": {
+ __typename: "Author",
+ name: "Juli Berwald",
+ },
+ "Book:1603589082": {
+ __typename: "Book",
+ author: {
+ __ref: "Author:Ben Goldfarb",
+ },
+ title: "Eager",
+ },
+ "Author:Ben Goldfarb": {
+ __typename: "Author",
+ name: "Ben Goldfarb",
+ },
+ };
+
+ expect(cache.extract(true)).toEqual(snapshotWithBothBooksAndAuthors);
+
+ expect(cache.retain("Book:0735211280")).toBe(1);
+
+ expect(cache.gc()).toEqual([]);
+
+ expect(cache.retain("Author:Juli Berwald")).toBe(1);
+
+ cache.recordOptimisticTransaction(proxy => {
+ proxy.writeFragment({
+ id: "Author:Juli Berwald",
+ fragment: gql`
+ fragment AuthorBooks on Author {
+ books {
+ title
+ }
+ }
+ `,
+ data: {
+ books: [
+ {
+ __typename: 'Book',
+ isbn: '0735211280',
+ },
+ ],
+ },
+ });
+ }, "juli books");
+
+ // Retain the Spineless book on the optimistic layer (for the first time)
+ // but release it on the root layer.
+ expect(cache.retain("Book:0735211280", true)).toBe(1);
+ expect(cache.release("Book:0735211280")).toBe(0);
+
+ // The Spineless book is still protected by the reference from author Juli
+ // Berwald's optimistically-added author.books field.
+ expect(cache.gc()).toEqual([]);
+
+ expect(cache.extract(true)).toEqual({
+ ROOT_QUERY: {
+ book: {
+ __ref: "Book:1603589082",
+ },
+ },
+ "Book:0735211280": {
+ __typename: "Book",
+ author: {
+ __ref: "Author:Juli Berwald",
+ },
+ title: "Spineless",
+ },
+ "Author:Juli Berwald": {
+ __typename: "Author",
+ name: "Juli Berwald",
+ // Note this extra optimistic field.
+ books: [
+ {
+ __ref: "Book:0735211280",
+ },
+ ],
+ },
+ "Book:1603589082": {
+ __typename: "Book",
+ author: {
+ __ref: "Author:Ben Goldfarb",
+ },
+ title: "Eager",
+ },
+ "Author:Ben Goldfarb": {
+ __typename: "Author",
+ name: "Ben Goldfarb",
+ },
+ });
+
+ // A non-optimistic snapshot will not have the extra books field.
+ expect(cache.extract(false)).toEqual(snapshotWithBothBooksAndAuthors);
+
+ cache.removeOptimistic("juli books");
+
+ // The optimistic books field is gone now that we've removed the optimistic
+ // layer that added it.
+ expect(cache.extract(true)).toEqual(snapshotWithBothBooksAndAuthors);
+
+ // The Spineless book is no longer retained or kept alive by any other root
+ // IDs, so it can finally be collected.
+ expect(cache.gc()).toEqual([
+ "Book:0735211280",
+ ]);
+
+ expect(cache.release("Author:Juli Berwald")).toBe(0);
+
+ // Now that Juli Berwald's author entity is no longer retained, garbage
+ // collection cometh for her. Look out, Juli!
+ expect(cache.gc()).toEqual([
+ "Author:Juli Berwald",
+ ]);
+
+ expect(cache.gc()).toEqual([]);
+ });
+
+ it('allows cache eviction', () => {
+ const { cache, query } = newBookAuthorCache();
+
+ cache.writeQuery({
+ query,
+ data: {
+ book: {
+ __typename: "Book",
+ isbn: "031648637X",
+ title: "The Cuckoo's Calling",
+ author: {
+ __typename: "Author",
+ name: "Robert Galbraith",
+ },
+ },
+ },
+ });
+
+ expect(cache.evict("Author:J.K. Rowling")).toBe(false);
+
+ const bookAuthorFragment = gql`
+ fragment BookAuthor on Book {
+ author {
+ name
+ }
+ }
+ `;
+
+ const fragmentResult = cache.readFragment({
+ id: "Book:031648637X",
+ fragment: bookAuthorFragment,
+ });
+
+ expect(fragmentResult).toEqual({
+ __typename: "Book",
+ author: {
+ __typename: "Author",
+ name: "Robert Galbraith",
+ },
+ });
+
+ cache.recordOptimisticTransaction(proxy => {
+ proxy.writeFragment({
+ id: "Book:031648637X",
+ fragment: bookAuthorFragment,
+ data: {
+ ...fragmentResult,
+ author: {
+ __typename: "Author",
+ name: "J.K. Rowling",
+ },
+ },
+ });
+ }, "real name");
+
+ const snapshotWithBothNames = {
+ ROOT_QUERY: {
+ book: {
+ __ref: "Book:031648637X",
+ },
+ },
+ "Book:031648637X": {
+ __typename: "Book",
+ author: {
+ __ref: "Author:J.K. Rowling",
+ },
+ title: "The Cuckoo's Calling",
+ },
+ "Author:Robert Galbraith": {
+ __typename: "Author",
+ name: "Robert Galbraith",
+ },
+ "Author:J.K. Rowling": {
+ __typename: "Author",
+ name: "J.K. Rowling",
+ },
+ };
+
+ expect(cache.extract(true)).toEqual(snapshotWithBothNames);
+
+ expect(cache.gc()).toEqual([]);
+
+ expect(cache.retain('Author:Robert Galbraith')).toBe(1);
+
+ expect(cache.gc()).toEqual([]);
+
+ expect(cache.evict("Author:Robert Galbraith")).toBe(true);
+
+ expect(cache.gc()).toEqual([]);
+
+ cache.removeOptimistic("real name");
+
+ expect(cache.extract(true)).toEqual({
+ ROOT_QUERY: {
+ book: {
+ __ref: "Book:031648637X",
+ },
+ },
+ "Book:031648637X": {
+ __typename: "Book",
+ author: {
+ __ref: "Author:Robert Galbraith",
+ },
+ title: "The Cuckoo's Calling",
+ },
+ "Author:Robert Galbraith": {
+ __typename: "Author",
+ name: "Robert Galbraith",
+ },
+ });
+
+ cache.writeFragment({
+ id: "Book:031648637X",
+ fragment: bookAuthorFragment,
+ data: {
+ ...fragmentResult,
+ author: {
+ __typename: "Author",
+ name: "J.K. Rowling",
+ },
+ },
+ });
+
+ expect(cache.extract(true)).toEqual(snapshotWithBothNames);
+
+ expect(cache.retain("Author:Robert Galbraith")).toBe(2);
+
+ expect(cache.gc()).toEqual([]);
+
+ expect(cache.release("Author:Robert Galbraith")).toBe(1);
+ expect(cache.release("Author:Robert Galbraith")).toBe(0);
+
+ expect(cache.gc()).toEqual([
+ "Author:Robert Galbraith",
+ ]);
+
+ // If you're ever tempted to do this, you probably want to use cache.clear()
+ // instead, but evicting the ROOT_QUERY should work at least.
+ expect(cache.evict("ROOT_QUERY")).toBe(true);
+
+ expect(cache.extract(true)).toEqual({
+ "Book:031648637X": {
+ __typename: "Book",
+ author: {
+ __ref: "Author:J.K. Rowling",
+ },
+ title: "The Cuckoo's Calling",
+ },
+ "Author:J.K. Rowling": {
+ __typename: "Author",
+ name: "J.K. Rowling",
+ },
+ });
+
+ expect(cache.retain("Book:031648637X")).toBe(2);
+ expect(cache.release("Book:031648637X")).toBe(1);
+ expect(cache.release("Book:031648637X")).toBe(0);
+
+ expect(cache.gc().sort()).toEqual([
+ "Author:J.K. Rowling",
+ "Book:031648637X",
+ ]);
+ });
+});
diff --git a/src/cache/inmemory/__tests__/writeToStore.ts b/src/cache/inmemory/__tests__/writeToStore.ts
index 40164cafdf3..152ffda1138 100644
--- a/src/cache/inmemory/__tests__/writeToStore.ts
+++ b/src/cache/inmemory/__tests__/writeToStore.ts
@@ -5,6 +5,7 @@ import {
DefinitionNode,
OperationDefinitionNode,
ASTNode,
+ DocumentNode,
} from 'graphql';
import gql from 'graphql-tag';
@@ -1079,19 +1080,18 @@ describe('writing to the store', () => {
mutation.definitions.map((def: OperationDefinitionNode) => {
if (isOperationDefinition(def)) {
expect(
- writer
- .writeSelectionSetToStore({
- dataId: '5',
- selectionSet: def.selectionSet,
- result: cloneDeep(result),
- context: {
- store: defaultNormalizedCacheFactory(),
- processedData: {},
- variables,
- dataIdFromObject: () => '5',
- },
- })
- .toObject(),
+ writer.writeQueryToStore({
+ query: {
+ kind: 'Document',
+ definitions: [def],
+ } as DocumentNode,
+ dataId: '5',
+ result,
+ variables,
+ dataIdFromObject() {
+ return '5';
+ },
+ }).toObject(),
).toEqual({
'5': {
id: 'id',
diff --git a/src/cache/inmemory/entityCache.ts b/src/cache/inmemory/entityCache.ts
index 5ccf6e3e974..9b5f90f5097 100644
--- a/src/cache/inmemory/entityCache.ts
+++ b/src/cache/inmemory/entityCache.ts
@@ -1,5 +1,6 @@
import { NormalizedCache, NormalizedCacheObject, StoreObject } from './types';
import { wrap, OptimisticWrapperFunction } from 'optimism';
+import { isReference } from './helpers';
const hasOwn = Object.prototype.hasOwnProperty;
@@ -31,18 +32,22 @@ export abstract class EntityCache implements NormalizedCache {
}
public abstract addLayer(
- id: string,
+ layerId: string,
replay: (layer: EntityCache) => any,
): EntityCache;
- public abstract removeLayer(id: string): EntityCache;
+ public abstract removeLayer(layerId: string): EntityCache;
// Although the EntityCache class is abstract, it contains concrete
// implementations of the various NormalizedCache interface methods that
// are inherited by the Root and Layer subclasses.
public toObject(): NormalizedCacheObject {
- return this.data;
+ return { ...this.data };
+ }
+
+ public has(dataId: string): boolean {
+ return hasOwn.call(this.data, dataId);
}
public get(dataId: string): StoreObject {
@@ -53,12 +58,14 @@ export abstract class EntityCache implements NormalizedCache {
public set(dataId: string, value: StoreObject): void {
if (!hasOwn.call(this.data, dataId) || value !== this.data[dataId]) {
this.data[dataId] = value;
+ delete this.refs[dataId];
if (this.depend) this.depend.dirty(dataId);
}
}
public delete(dataId: string): void {
- this.data[dataId] = void 0;
+ delete this.data[dataId];
+ delete this.refs[dataId];
if (this.depend) this.depend.dirty(dataId);
}
@@ -78,6 +85,86 @@ export abstract class EntityCache implements NormalizedCache {
});
}
}
+
+ // Maps root entity IDs to the number of times they have been retained, minus
+ // the number of times they have been released. Retained entities keep other
+ // entities they reference (even indirectly) from being garbage collected.
+ private rootIds: {
+ [rootId: string]: number;
+ } = Object.create(null);
+
+ public retain(rootId: string): number {
+ return this.rootIds[rootId] = (this.rootIds[rootId] || 0) + 1;
+ }
+
+ public release(rootId: string): number {
+ if (this.rootIds[rootId] > 0) {
+ const count = --this.rootIds[rootId];
+ if (!count) delete this.rootIds[rootId];
+ return count;
+ }
+ return 0;
+ }
+
+ // This method will be overridden in the Layer class to merge root IDs for all
+ // layers (including the root).
+ public getRootIdSet() {
+ return new Set(Object.keys(this.rootIds));
+ }
+
+ // The goal of garbage collection is to remove IDs from the Root layer of the
+ // cache that are no longer reachable starting from any IDs that have been
+ // explicitly retained (see retain and release, above). Returns an array of
+ // dataId strings that were removed from the cache.
+ public gc() {
+ const ids = this.getRootIdSet();
+ const snapshot = this.toObject();
+ ids.forEach(id => {
+ if (hasOwn.call(snapshot, id)) {
+ // Because we are iterating over an ECMAScript Set, the IDs we add here
+ // will be visited in later iterations of the forEach loop only if they
+ // were not previously contained by the Set.
+ Object.keys(this.findChildRefIds(id)).forEach(ids.add, ids);
+ // By removing IDs from the snapshot object here, we protect them from
+ // getting removed from the root cache layer below.
+ delete snapshot[id];
+ }
+ });
+ const idsToRemove = Object.keys(snapshot);
+ if (idsToRemove.length) {
+ let root: EntityCache = this;
+ while (root instanceof Layer) root = root.parent;
+ idsToRemove.forEach(root.delete, root);
+ }
+ return idsToRemove;
+ }
+
+ // Lazily tracks { __ref: } strings contained by this.data[dataId].
+ private refs: {
+ [dataId: string]: Record;
+ } = Object.create(null);
+
+ public findChildRefIds(dataId: string): Record {
+ if (!hasOwn.call(this.refs, dataId)) {
+ const found = this.refs[dataId] = Object.create(null);
+ const workSet = new Set([this.data[dataId]]);
+ // Within the cache, only arrays and objects can contain child entity
+ // references, so we can prune the traversal using this predicate:
+ const canTraverse = (obj: any) => obj !== null && typeof obj === 'object';
+ workSet.forEach(obj => {
+ if (isReference(obj)) {
+ found[obj.__ref] = true;
+ } else if (canTraverse(obj)) {
+ Object.values(obj)
+ // No need to add primitive values to the workSet, since they cannot
+ // contain reference objects.
+ .filter(canTraverse)
+ .forEach(workSet.add, workSet);
+ }
+ });
+ }
+ return this.refs[dataId];
+ }
}
export namespace EntityCache {
@@ -107,14 +194,14 @@ export namespace EntityCache {
}
public addLayer(
- id: string,
+ layerId: string,
replay: (layer: EntityCache) => any,
): EntityCache {
// The replay function will be called in the Layer constructor.
- return new Layer(id, this, replay, this.sharedLayerDepend);
+ return new Layer(layerId, this, replay, this.sharedLayerDepend);
}
- public removeLayer(): Root {
+ public removeLayer(layerId: string): Root {
// Never remove the root layer.
return this;
}
@@ -125,9 +212,9 @@ export namespace EntityCache {
// of the EntityCache.Root class.
class Layer extends EntityCache {
constructor(
- private id: string,
- private parent: EntityCache,
- private replay: (layer: EntityCache) => any,
+ public readonly id: string,
+ public readonly parent: Layer | EntityCache.Root,
+ public readonly replay: (layer: EntityCache) => any,
public readonly depend: DependType,
) {
super();
@@ -135,17 +222,17 @@ class Layer extends EntityCache {
}
public addLayer(
- id: string,
+ layerId: string,
replay: (layer: EntityCache) => any,
): EntityCache {
- return new Layer(id, this, replay, this.depend);
+ return new Layer(layerId, this, replay, this.depend);
}
- public removeLayer(id: string): EntityCache {
+ public removeLayer(layerId: string): EntityCache {
// Remove all instances of the given id, not just the first one.
- const parent = this.parent.removeLayer(id);
+ const parent = this.parent.removeLayer(layerId);
- if (id === this.id) {
+ if (layerId === this.id) {
// Dirty every ID we're removing.
// TODO Some of these IDs could escape dirtying if value unchanged.
if (this.depend) {
@@ -168,6 +255,26 @@ class Layer extends EntityCache {
};
}
+ public has(dataId: string): boolean {
+ // Because the Layer implementation of the delete method uses void 0 to
+ // indicate absence, that's what we need to check for here, rather than
+ // calling super.has(dataId).
+ if (hasOwn.call(this.data, dataId) && this.data[dataId] === void 0) {
+ return false;
+ }
+ return this.parent.has(dataId);
+ }
+
+ 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;
+ }
+
+ // All the other inherited accessor methods work as-is, but the get method
+ // needs to fall back to this.parent.get when accessing a missing dataId.
public get(dataId: string): StoreObject {
if (hasOwn.call(this.data, dataId)) {
return super.get(dataId);
@@ -183,6 +290,22 @@ class Layer extends EntityCache {
}
return this.parent.get(dataId);
}
+
+ // Return a Set of all the ID strings that have been retained by this
+ // Layer *and* any layers/roots beneath it.
+ public getRootIdSet(): Set {
+ const ids = this.parent.getRootIdSet();
+ super.getRootIdSet().forEach(ids.add, ids);
+ return ids;
+ }
+
+ public findChildRefIds(dataId: string): Record {
+ const fromParent = this.parent.findChildRefIds(dataId);
+ return hasOwn.call(this.data, dataId) ? {
+ ...fromParent,
+ ...super.findChildRefIds(dataId),
+ } : fromParent;
+ }
}
export function supportsResultCaching(store: any): store is EntityCache {
diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts
index a6f74cf9eff..83dd15885cb 100644
--- a/src/cache/inmemory/inMemoryCache.ts
+++ b/src/cache/inmemory/inMemoryCache.ts
@@ -3,7 +3,6 @@ import './fixPolyfills';
import { DocumentNode } from 'graphql';
import { wrap } from 'optimism';
-import { InvariantError } from 'ts-invariant';
import { KeyTrie } from 'optimism';
import { Cache, ApolloCache, Transaction } from '../core';
@@ -175,8 +174,40 @@ export class InMemoryCache extends ApolloCache {
};
}
- public evict(query: Cache.EvictOptions): Cache.EvictionResult {
- throw new InvariantError(`eviction is not implemented on InMemory Cache`);
+ // Request garbage collection of unreachable normalized entities.
+ public gc() {
+ return this.optimisticData.gc();
+ }
+
+ // Call this method to ensure the given root ID remains in the cache after
+ // garbage collection, along with its transitive child entities. Note that
+ // the cache automatically retains all directly written entities. By default,
+ // the retainment persists after optimistic updates are removed. Pass true
+ // for the optimistic argument if you would prefer for the retainment to be
+ // discarded when the top-most optimistic layer is removed. Returns the
+ // resulting (non-negative) retainment count.
+ public retain(rootId: string, optimistic?: boolean): number {
+ return (optimistic ? this.optimisticData : this.data).retain(rootId);
+ }
+
+ // Call this method to undo the effect of the retain method, above. Once the
+ // retainment count falls to zero, the given ID will no longer be preserved
+ // during garbage collection, though it may still be preserved by other safe
+ // entities that refer to it. Returns the resulting (non-negative) retainment
+ // count, in case that's useful.
+ public release(rootId: string, optimistic?: boolean): number {
+ return (optimistic ? this.optimisticData : this.data).release(rootId);
+ }
+
+ public evict(dataId: 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);
+ return !this.optimisticData.has(dataId);
+ }
+ return false;
}
public reset(): Promise {
diff --git a/src/cache/inmemory/types.ts b/src/cache/inmemory/types.ts
index ba037841174..ab5e5102d82 100644
--- a/src/cache/inmemory/types.ts
+++ b/src/cache/inmemory/types.ts
@@ -16,6 +16,7 @@ export declare type IdGetter = (
* StoreObjects from the cache
*/
export interface NormalizedCache {
+ has(dataId: string): boolean;
get(dataId: string): StoreObject;
set(dataId: string, value: StoreObject): void;
delete(dataId: string): void;
@@ -30,6 +31,17 @@ export interface NormalizedCache {
* replace the state of the store
*/
replace(newData: NormalizedCacheObject): void;
+
+ /**
+ * Retain (or release) a given root ID to protect (or expose) it and its
+ * transitive child entities from (or to) garbage collection. The current
+ * retainment count is returned by both methods. Note that releasing a root
+ * ID does not cause that entity to be garbage collected, but merely removes
+ * it from the set of root IDs that will be considered during the next
+ * mark-and-sweep collection.
+ */
+ retain(rootId: string): number;
+ release(rootId: string): number;
}
/**
diff --git a/src/cache/inmemory/writeToStore.ts b/src/cache/inmemory/writeToStore.ts
index 1c8bf29c9d4..68c03119a8e 100644
--- a/src/cache/inmemory/writeToStore.ts
+++ b/src/cache/inmemory/writeToStore.ts
@@ -76,6 +76,12 @@ export class StoreWriter {
dataIdFromObject?: IdGetter;
}): NormalizedCache {
const operationDefinition = getOperationDefinition(query)!;
+
+ // Any IDs written explicitly to the cache (including ROOT_QUERY, most
+ // frequently) will be retained as reachable root IDs on behalf of their
+ // owner DocumentNode objects, until/unless evicted for all owners.
+ store.retain(dataId);
+
return this.writeSelectionSetToStore({
result,
dataId,
@@ -93,7 +99,7 @@ export class StoreWriter {
});
}
- public writeSelectionSetToStore({
+ private writeSelectionSetToStore({
result,
dataId,
selectionSet,