From 475ff2f8d9715e17f89777027c136c11dc401a1f Mon Sep 17 00:00:00 2001 From: msand Date: Wed, 28 Aug 2024 09:44:48 +0200 Subject: [PATCH 1/2] Make it possible to extend ApolloCache in a InMemoryCache compatible way --- src/cache/core/types/Cache.ts | 39 +++++++++++------ src/cache/index.ts | 1 + src/cache/inmemory/__tests__/optimistic.ts | 4 +- src/cache/inmemory/entityStore.ts | 6 +-- src/cache/inmemory/inMemoryCache.ts | 41 +++++++++--------- src/core/QueryManager.ts | 4 +- src/core/__tests__/ObservableQuery.ts | 12 +++--- src/core/index.ts | 3 +- src/link/persisted-queries/index.ts | 2 +- src/link/retry/__tests__/delayFunction.ts | 2 +- .../hoc/__tests__/queries/index.test.tsx | 2 +- src/react/ssr/__tests__/useLazyQuery.test.tsx | 4 +- src/utilities/common/__tests__/mergeDeep.ts | 42 ++++++++++++++----- 13 files changed, 103 insertions(+), 59 deletions(-) diff --git a/src/cache/core/types/Cache.ts b/src/cache/core/types/Cache.ts index 0fa70742e15..328bfd58051 100644 --- a/src/cache/core/types/Cache.ts +++ b/src/cache/core/types/Cache.ts @@ -1,15 +1,19 @@ -import { DataProxy } from "./DataProxy.js"; +import type { DataProxy } from "./DataProxy.js"; import type { AllFieldsModifier, Modifiers } from "./common.js"; import type { ApolloCache } from "../cache.js"; export namespace Cache { + // @ts-ignore + const _ = ""; // Make typescript export something export type WatchCallback = ( diff: Cache.DiffResult, lastDiff?: Cache.DiffResult ) => void; - export interface ReadOptions - extends DataProxy.Query { + export type ReadOptions = DataProxy.Query< + TVariables, + TData + > & { rootId?: string; previousResult?: any; optimistic: boolean; @@ -22,7 +26,7 @@ export namespace Cache { * the risk of memory leaks. */ canonizeResults?: boolean; - } + }; export interface WriteOptions extends Omit, "id">, @@ -104,12 +108,23 @@ export namespace Cache { ) => any; } - export import DiffResult = DataProxy.DiffResult; - export import ReadQueryOptions = DataProxy.ReadQueryOptions; - export import ReadFragmentOptions = DataProxy.ReadFragmentOptions; - export import WriteQueryOptions = DataProxy.WriteQueryOptions; - export import WriteFragmentOptions = DataProxy.WriteFragmentOptions; - export import UpdateQueryOptions = DataProxy.UpdateQueryOptions; - export import UpdateFragmentOptions = DataProxy.UpdateFragmentOptions; - export import Fragment = DataProxy.Fragment; + export type DiffResult = DataProxy.DiffResult; + export type ReadQueryOptions = DataProxy.ReadQueryOptions< + TData, + TVariables + >; + export type ReadFragmentOptions = + DataProxy.ReadFragmentOptions; + export type WriteQueryOptions = + DataProxy.WriteQueryOptions; + export type WriteFragmentOptions = + DataProxy.WriteFragmentOptions; + export type UpdateQueryOptions = + DataProxy.UpdateQueryOptions; + export type UpdateFragmentOptions = + DataProxy.UpdateFragmentOptions; + export type Fragment = DataProxy.Fragment< + TVariables, + TData + >; } diff --git a/src/cache/index.ts b/src/cache/index.ts index 1f55d8c16df..491676336e0 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -6,6 +6,7 @@ export type { WatchFragmentResult, } from "./core/cache.js"; export { ApolloCache } from "./core/cache.js"; +// eslint-disable-next-line @typescript-eslint/consistent-type-exports export { Cache } from "./core/types/Cache.js"; export type { DataProxy } from "./core/types/DataProxy.js"; export type { diff --git a/src/cache/inmemory/__tests__/optimistic.ts b/src/cache/inmemory/__tests__/optimistic.ts index 4c10b978e27..c655219d1f6 100644 --- a/src/cache/inmemory/__tests__/optimistic.ts +++ b/src/cache/inmemory/__tests__/optimistic.ts @@ -1,6 +1,8 @@ import gql from "graphql-tag"; import { InMemoryCache } from "../inMemoryCache"; +import { ApolloCache } from "../../core/cache"; +import type { NormalizedCacheObject } from "../types"; describe("optimistic cache layers", () => { it("return === results for repeated reads", () => { @@ -28,7 +30,7 @@ describe("optimistic cache layers", () => { } `; - function readOptimistic(cache: InMemoryCache) { + function readOptimistic(cache: ApolloCache) { return cache.readQuery<{ book: any }>({ query }, true); } diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index 6c85d1f5866..f088ca1be01 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -34,9 +34,9 @@ import type { } from "../core/types/common.js"; import type { DocumentNode, FieldNode, SelectionSetNode } from "graphql"; -const DELETE: DeleteModifier = Object.create(null); -const delModifier: Modifier = () => DELETE; -const INVALIDATE: InvalidateModifier = Object.create(null); +export const DELETE: DeleteModifier = Object.create(null); +export const delModifier: Modifier = () => DELETE; +export const INVALIDATE: InvalidateModifier = Object.create(null); export abstract class EntityStore implements NormalizedCache { protected data: NormalizedCacheObject = Object.create(null); diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index fe62023f165..cddbcee955d 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -32,23 +32,23 @@ import type { OperationVariables } from "../../core/index.js"; import { getInMemoryCacheMemoryInternals } from "../../utilities/caching/getMemoryInternals.js"; type BroadcastOptions = Pick< - Cache.BatchOptions, + Cache.BatchOptions>, "optimistic" | "onWatchUpdated" >; export class InMemoryCache extends ApolloCache { - private data!: EntityStore; - private optimisticData!: EntityStore; + data!: EntityStore; + optimisticData!: EntityStore; - protected config: InMemoryCacheConfig; - private watches = new Set(); - private addTypename: boolean; + config: InMemoryCacheConfig; + watches = new Set(); + addTypename: boolean; - private storeReader!: StoreReader; - private storeWriter!: StoreWriter; - private addTypenameTransform = new DocumentTransform(addTypenameToDocument); + storeReader!: StoreReader; + storeWriter!: StoreWriter; + addTypenameTransform = new DocumentTransform(addTypenameToDocument); - private maybeBroadcastWatch!: OptimisticWrapperFunction< + maybeBroadcastWatch!: OptimisticWrapperFunction< [Cache.WatchOptions, BroadcastOptions?], any, [Cache.WatchOptions] @@ -80,7 +80,7 @@ export class InMemoryCache extends ApolloCache { this.init(); } - private init() { + init() { // Passing { resultCaching: false } in the InMemoryCache constructor options // will completely disable dependency tracking, which will improve memory // usage but worsen the performance of repeated reads. @@ -99,7 +99,7 @@ export class InMemoryCache extends ApolloCache { this.resetResultCache(); } - private resetResultCache(resetResultIdentities?: boolean) { + resetResultCache(resetResultIdentities?: boolean) { const previousReader = this.storeReader; const { fragments } = this.config; @@ -411,10 +411,13 @@ export class InMemoryCache extends ApolloCache { } } - private txCount = 0; + txCount = 0; public batch( - options: Cache.BatchOptions + options: Cache.BatchOptions< + ApolloCache, + TUpdateResult + > ): TUpdateResult { const { update, @@ -515,7 +518,7 @@ export class InMemoryCache extends ApolloCache { } public performTransaction( - update: (cache: InMemoryCache) => any, + update: (cache: ApolloCache) => any, optimisticId?: string | null ) { return this.batch({ @@ -528,18 +531,18 @@ export class InMemoryCache extends ApolloCache { return this.addTypenameToDocument(this.addFragmentsToDocument(document)); } - protected broadcastWatches(options?: BroadcastOptions) { + broadcastWatches(options?: BroadcastOptions) { if (!this.txCount) { this.watches.forEach((c) => this.maybeBroadcastWatch(c, options)); } } - private addFragmentsToDocument(document: DocumentNode) { + addFragmentsToDocument(document: DocumentNode) { const { fragments } = this.config; return fragments ? fragments.transform(document) : document; } - private addTypenameToDocument(document: DocumentNode) { + addTypenameToDocument(document: DocumentNode) { if (this.addTypename) { return this.addTypenameTransform.transformDocument(document); } @@ -552,7 +555,7 @@ export class InMemoryCache extends ApolloCache { // simpler to check for changes after recomputing a result but before // broadcasting it, but this wrapping approach allows us to skip both // the recomputation and the broadcast, in most cases. - private broadcastWatch(c: Cache.WatchOptions, options?: BroadcastOptions) { + broadcastWatch(c: Cache.WatchOptions, options?: BroadcastOptions) { const { lastDiff } = c; // Both WatchOptions and DiffOptions extend ReadOptions, and DiffOptions diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index ea91c0abdea..83548f84ea3 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1705,9 +1705,9 @@ interface FetchConcastInfo { // Metadata properties that can be returned in addition to the Concast. fromLink: boolean; } -interface SourcesAndInfo extends FetchConcastInfo { +export interface SourcesAndInfo extends FetchConcastInfo { sources: ConcastSourcesArray>; } -interface ConcastAndInfo extends FetchConcastInfo { +export interface ConcastAndInfo extends FetchConcastInfo { concast: Concast>; } diff --git a/src/core/__tests__/ObservableQuery.ts b/src/core/__tests__/ObservableQuery.ts index 3b639e14b58..684b4c06af6 100644 --- a/src/core/__tests__/ObservableQuery.ts +++ b/src/core/__tests__/ObservableQuery.ts @@ -39,11 +39,13 @@ import { SubscriptionObserver } from "zen-observable-ts"; import { waitFor } from "@testing-library/react"; import { ObservableStream } from "../../testing/internal"; -export const mockFetchQuery = (queryManager: QueryManager) => { - const fetchConcastWithInfo = queryManager["fetchConcastWithInfo"]; - const fetchQueryByPolicy: QueryManager["fetchQueryByPolicy"] = ( - queryManager as any - ).fetchQueryByPolicy; +export const mockFetchQuery = ( + queryManager: QueryManager +) => { + const fetchConcastWithInfo: QueryManager["fetchConcastWithInfo"] = + queryManager["fetchConcastWithInfo"]; + const fetchQueryByPolicy: QueryManager["fetchQueryByPolicy"] = + queryManager["fetchQueryByPolicy"]; const mock = < T extends typeof fetchConcastWithInfo | typeof fetchQueryByPolicy, diff --git a/src/core/index.ts b/src/core/index.ts index 785b2d03efc..cfe826cadec 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -43,8 +43,9 @@ export type { WatchFragmentOptions, WatchFragmentResult, } from "../cache/index.js"; +// eslint-disable-next-line @typescript-eslint/consistent-type-exports +export { Cache } from "../cache/index.js"; export { - Cache, ApolloCache, InMemoryCache, MissingFieldError, diff --git a/src/link/persisted-queries/index.ts b/src/link/persisted-queries/index.ts index e631098652d..dbf2b1886bc 100644 --- a/src/link/persisted-queries/index.ts +++ b/src/link/persisted-queries/index.ts @@ -206,7 +206,7 @@ export const createPersistedQueryLink = ( (networkError.result.errors as GraphQLFormattedError[]); } if (isNonEmptyArray(networkErrors)) { - graphQLErrors.push(...networkErrors); + graphQLErrors.push(...(networkErrors as GraphQLFormattedError[])); } const disablePayload: ErrorResponse = { diff --git a/src/link/retry/__tests__/delayFunction.ts b/src/link/retry/__tests__/delayFunction.ts index b575afd680f..1a5b067b71e 100644 --- a/src/link/retry/__tests__/delayFunction.ts +++ b/src/link/retry/__tests__/delayFunction.ts @@ -8,7 +8,7 @@ describe("buildDelayFunction", () => { } function delayRange(delayFunction: SimpleDelayFunction, count: number) { - const results = []; + const results: number[] = []; for (let i = 1; i <= count; i++) { results.push(delayFunction(i)); } diff --git a/src/react/hoc/__tests__/queries/index.test.tsx b/src/react/hoc/__tests__/queries/index.test.tsx index 45eaba87b96..2a78d89b917 100644 --- a/src/react/hoc/__tests__/queries/index.test.tsx +++ b/src/react/hoc/__tests__/queries/index.test.tsx @@ -550,7 +550,7 @@ describe("queries", () => { }); const Container = graphql(query)(() => null); - let errorCaught = null; + let errorCaught: unknown = null; try { const { unmount } = render( diff --git a/src/react/ssr/__tests__/useLazyQuery.test.tsx b/src/react/ssr/__tests__/useLazyQuery.test.tsx index c558acf9446..526a7bc6b48 100644 --- a/src/react/ssr/__tests__/useLazyQuery.test.tsx +++ b/src/react/ssr/__tests__/useLazyQuery.test.tsx @@ -1,5 +1,5 @@ /** @jest-environment node */ -import React from "react"; +import React, { ReactElement } from "react"; import { DocumentNode } from "graphql"; import gql from "graphql-tag"; import { mockSingleLink } from "../../../testing"; @@ -44,7 +44,7 @@ describe("useLazyQuery Hook SSR", () => { }); const Component = () => { - let html = null; + let html: ReactElement | null = null; const [execute, { loading, called, data }] = useLazyQuery(CAR_QUERY); if (!loading && !called) { diff --git a/src/utilities/common/__tests__/mergeDeep.ts b/src/utilities/common/__tests__/mergeDeep.ts index fcdc680e720..fa73406661f 100644 --- a/src/utilities/common/__tests__/mergeDeep.ts +++ b/src/utilities/common/__tests__/mergeDeep.ts @@ -1,5 +1,25 @@ import { mergeDeep, mergeDeepArray, DeepMerger } from "../mergeDeep"; +type Value = { + value: number; +}; + +type Nested = { + [p: `nested${number}`]: Value; + value?: number; +}; + +type FromNested = { + [p: `from${number}`]: Value | Nested; + nested: Nested; + value?: number; +}; + +type UniqueConflict = { + [p: `unique${number}`]: Value | FromNested; + conflict: FromNested; +}; + describe("mergeDeep", function () { it("should return an object if first argument falsy", function () { expect(mergeDeep()).toEqual({}); @@ -41,15 +61,15 @@ describe("mergeDeep", function () { }); it("should resolve conflicts among more than two objects", function () { - const sources = []; + const sources: UniqueConflict[] = []; for (let i = 0; i < 100; ++i) { sources.push({ - ["unique" + i]: { value: i }, + [`unique${i}`]: { value: i }, conflict: { - ["from" + i]: { value: i }, + [`from${i}`]: { value: i }, nested: { - ["nested" + i]: { value: i }, + [`nested${i}`]: { value: i }, }, }, }); @@ -58,17 +78,17 @@ describe("mergeDeep", function () { const merged = mergeDeep(...sources); sources.forEach((source, i) => { - expect(merged["unique" + i].value).toBe(i); - expect(source["unique" + i]).toBe(merged["unique" + i]); + expect(merged[`unique${i}`].value).toBe(i); + expect(source[`unique${i}`]).toBe(merged[`unique${i}`]); expect(merged.conflict).not.toBe(source.conflict); - expect(merged.conflict["from" + i].value).toBe(i); - expect(merged.conflict["from" + i]).toBe(source.conflict["from" + i]); + expect(merged.conflict[`from${i}`].value).toBe(i); + expect(merged.conflict[`from${i}`]).toBe(source.conflict[`from${i}`]); expect(merged.conflict.nested).not.toBe(source.conflict.nested); - expect(merged.conflict.nested["nested" + i].value).toBe(i); - expect(merged.conflict.nested["nested" + i]).toBe( - source.conflict.nested["nested" + i] + expect(merged.conflict.nested[`nested${i}`].value).toBe(i); + expect(merged.conflict.nested[`nested${i}`]).toBe( + source.conflict.nested[`nested${i}`] ); }); }); From fbd6be184c8cecfad5c4062b939a328905ed6932 Mon Sep 17 00:00:00 2001 From: msand Date: Thu, 29 Aug 2024 15:37:57 +0200 Subject: [PATCH 2/2] Use weakMap for private parts --- scripts/memory/tests.js | 3 +- src/__tests__/ApolloClient.ts | 5 +- src/__tests__/__snapshots__/exports.ts.snap | 3 + src/__tests__/client.ts | 21 +- src/__tests__/resultCacheCleaning.ts | 3 +- src/cache/core/cache.ts | 2 +- src/cache/index.ts | 1 + src/cache/inmemory/__tests__/cache.ts | 64 +-- src/cache/inmemory/__tests__/entityStore.ts | 9 +- src/cache/inmemory/__tests__/readFromStore.ts | 7 +- src/cache/inmemory/entityStore.ts | 2 + src/cache/inmemory/inMemoryCache.ts | 392 ++++++++++-------- src/cache/inmemory/policies.ts | 3 +- src/cache/inmemory/privates.ts | 49 +++ src/cache/inmemory/readFromStore.ts | 9 +- src/core/__tests__/QueryManager/index.ts | 4 +- src/core/index.ts | 2 +- src/utilities/caching/getMemoryInternals.ts | 13 +- 18 files changed, 367 insertions(+), 225 deletions(-) create mode 100644 src/cache/inmemory/privates.ts diff --git a/scripts/memory/tests.js b/scripts/memory/tests.js index 0edccf5e02b..cb7bea4c260 100644 --- a/scripts/memory/tests.js +++ b/scripts/memory/tests.js @@ -1,5 +1,6 @@ const assert = require("assert"); const { + $, ApolloClient, InMemoryCache, gql, @@ -182,7 +183,7 @@ describe("garbage collection", () => { }); function register(suffix) { - const reader = cache["storeReader"]; + const reader = $(cache)["storeReader"]; registry.register(reader, "StoreReader" + suffix); registry.register(reader.canon, "ObjectCanon" + suffix); } diff --git a/src/__tests__/ApolloClient.ts b/src/__tests__/ApolloClient.ts index 2eb82151380..d858aa08c41 100644 --- a/src/__tests__/ApolloClient.ts +++ b/src/__tests__/ApolloClient.ts @@ -1,6 +1,7 @@ import gql from "graphql-tag"; import { + $, ApolloClient, ApolloError, DefaultOptions, @@ -2745,7 +2746,7 @@ describe("ApolloClient", () => { `, }); - expect((client.cache as any).data.data).toEqual({ + expect($(client.cache).data["data"]).toEqual({ ROOT_QUERY: { __typename: "Query", a: 1, @@ -2753,7 +2754,7 @@ describe("ApolloClient", () => { }); await client.clearStore(); - expect((client.cache as any).data.data).toEqual({}); + expect($(client.cache).data["data"]).toEqual({}); }); }); diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index d3ce1568654..6b60357433c 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -2,6 +2,7 @@ exports[`exports of public entry points @apollo/client 1`] = ` Array [ + "$", "ApolloCache", "ApolloClient", "ApolloConsumer", @@ -73,6 +74,7 @@ Array [ exports[`exports of public entry points @apollo/client/cache 1`] = ` Array [ + "$", "ApolloCache", "Cache", "EntityStore", @@ -92,6 +94,7 @@ Array [ exports[`exports of public entry points @apollo/client/core 1`] = ` Array [ + "$", "ApolloCache", "ApolloClient", "ApolloError", diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index fd49358b6d4..f904e5bba01 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -30,6 +30,7 @@ import { import { ApolloLink } from "../link/core"; import { createFragmentRegistry, + EntityStore, InMemoryCache, makeVar, PossibleTypesMap, @@ -45,6 +46,8 @@ import { } from "../testing"; import { spyOnConsole } from "../testing/internal"; import { waitFor } from "@testing-library/react"; +import { $ } from "../cache/inmemory/privates"; +import { LayerType } from "../cache/inmemory/entityStore"; describe("client", () => { it("can be loaded via require", () => { @@ -2434,10 +2437,11 @@ describe("client", () => { }); { - const { data, optimisticData } = client.cache as any; + const { data, optimisticData } = $(client.cache); expect(optimisticData).not.toBe(data); - expect(optimisticData.parent).toBe(data.stump); - expect(optimisticData.parent.parent).toBe(data); + const layer = optimisticData as unknown as LayerType; + expect(layer.parent).toBe((data as EntityStore.Root).stump); + expect((layer.parent as LayerType).parent).toBe(data); } mutatePromise @@ -2445,8 +2449,8 @@ describe("client", () => { reject(new Error("Returned a result when it should not have.")); }) .catch((_: ApolloError) => { - const { data, optimisticData } = client.cache as any; - expect(optimisticData).toBe(data.stump); + const { data, optimisticData } = $(client.cache); + expect(optimisticData).toBe((data as EntityStore.Root).stump); resolve(); }); } @@ -3525,7 +3529,8 @@ describe("@connection", () => { const aResults = watch(aQuery); const bResults = watch(bQuery); - expect(cache["watches"].size).toBe(2); + const watches = $(cache)["watches"]; + expect(watches.size).toBe(2); expect(aResults).toEqual([]); expect(bResults).toEqual([]); @@ -3543,10 +3548,10 @@ describe("@connection", () => { expect(aResults).toEqual([]); expect(bResults).toEqual([]); - expect(cache["watches"].size).toBe(0); + expect(watches.size).toBe(0); const abResults = watch(abQuery); expect(abResults).toEqual([]); - expect(cache["watches"].size).toBe(1); + expect(watches.size).toBe(1); await wait(); diff --git a/src/__tests__/resultCacheCleaning.ts b/src/__tests__/resultCacheCleaning.ts index a1ecb53bd28..69d8b74e7bc 100644 --- a/src/__tests__/resultCacheCleaning.ts +++ b/src/__tests__/resultCacheCleaning.ts @@ -3,6 +3,7 @@ import { makeExecutableSchema } from "@graphql-tools/schema"; import { ApolloClient, Resolvers, gql } from "../core"; import { InMemoryCache, NormalizedCacheObject } from "../cache"; import { SchemaLink } from "../link/schema"; +import { $ } from "../cache/inmemory/privates"; describe("resultCache cleaning", () => { const fragments = gql` @@ -150,7 +151,7 @@ describe("resultCache cleaning", () => { }); afterEach(() => { - const storeReader = (client.cache as InMemoryCache)["storeReader"]; + const { storeReader } = $(client.cache); expect(storeReader["executeSubSelectedArray"].size).toBeGreaterThan(0); expect(storeReader["executeSelectionSet"].size).toBeGreaterThan(0); client.cache.evict({ diff --git a/src/cache/core/cache.ts b/src/cache/core/cache.ts index cb953152c45..f55b38057e7 100644 --- a/src/cache/core/cache.ts +++ b/src/cache/core/cache.ts @@ -273,7 +273,7 @@ export abstract class ApolloCache implements DataProxy { // Make sure we compute the same (===) fragment query document every // time we receive the same fragment in readFragment. - private getFragmentDoc = wrap(getFragmentQueryDocument, { + getFragmentDoc = wrap(getFragmentQueryDocument, { max: cacheSizes["cache.fragmentQueryDocuments"] || defaultCacheSizes["cache.fragmentQueryDocuments"], diff --git a/src/cache/index.ts b/src/cache/index.ts index 491676336e0..fa93f49f358 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -32,6 +32,7 @@ export { } from "./inmemory/helpers.js"; export { InMemoryCache } from "./inmemory/inMemoryCache.js"; +export { $ } from "./inmemory/privates.js"; export type { ReactiveVar } from "./inmemory/reactiveVars.js"; export { makeVar, cacheSlot } from "./inmemory/reactiveVars.js"; diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts index 2d426ed7207..87305a2ffe6 100644 --- a/src/cache/inmemory/__tests__/cache.ts +++ b/src/cache/inmemory/__tests__/cache.ts @@ -20,6 +20,7 @@ import { ObjectCanon } from "../object-canon"; import { TypePolicies } from "../policies"; import { spyOnConsole } from "../../../testing/internal"; import { defaultCacheSizes } from "../../../utilities"; +import { $ } from "../privates"; disableFragmentWarnings(); @@ -1498,13 +1499,15 @@ describe("Cache", () => { } `; - const originalReader = cache["storeReader"]; + const privates = $(cache); + + const originalReader = privates.storeReader; expect(originalReader).toBeInstanceOf(StoreReader); - const originalWriter = cache["storeWriter"]; + const originalWriter = privates.storeWriter; expect(originalWriter).toBeInstanceOf(StoreWriter); - const originalMBW = cache["maybeBroadcastWatch"]; + const originalMBW = privates.maybeBroadcastWatch; expect(typeof originalMBW).toBe("function"); const originalCanon = originalReader.canon; @@ -1534,12 +1537,12 @@ describe("Cache", () => { c: "see", }); - expect(originalReader).not.toBe(cache["storeReader"]); - expect(originalWriter).not.toBe(cache["storeWriter"]); - expect(originalMBW).not.toBe(cache["maybeBroadcastWatch"]); + expect(originalReader).not.toBe(privates.storeReader); + expect(originalWriter).not.toBe(privates.storeWriter); + expect(originalMBW).not.toBe(privates.maybeBroadcastWatch); // The cache.storeReader.canon is preserved by default, but can be dropped // by passing resetResultIdentities:true to cache.gc. - expect(originalCanon).toBe(cache["storeReader"].canon); + expect(originalCanon).toBe(privates.storeReader.canon); }); }); @@ -2122,10 +2125,11 @@ describe("Cache", () => { describe("resultCacheMaxSize", () => { it("uses default max size on caches if resultCacheMaxSize is not configured", () => { const cache = new InMemoryCache(); - expect(cache["maybeBroadcastWatch"].options.max).toBe( + const privates = $(cache); + expect(privates.maybeBroadcastWatch.options.max).toBe( defaultCacheSizes["inMemoryCache.maybeBroadcastWatch"] ); - expect(cache["storeReader"]["executeSelectionSet"].options.max).toBe( + expect(privates.storeReader["executeSelectionSet"].options.max).toBe( defaultCacheSizes["inMemoryCache.executeSelectionSet"] ); expect(cache["getFragmentDoc"].options.max).toBe( @@ -2136,8 +2140,9 @@ describe("resultCacheMaxSize", () => { it("configures max size on caches when resultCacheMaxSize is set", () => { const resultCacheMaxSize = 12345; const cache = new InMemoryCache({ resultCacheMaxSize }); - expect(cache["maybeBroadcastWatch"].options.max).toBe(resultCacheMaxSize); - expect(cache["storeReader"]["executeSelectionSet"].options.max).toBe( + const privates = $(cache); + expect(privates.maybeBroadcastWatch.options.max).toBe(resultCacheMaxSize); + expect(privates.storeReader["executeSelectionSet"].options.max).toBe( resultCacheMaxSize ); expect(cache["getFragmentDoc"].options.max).toBe( @@ -2400,7 +2405,7 @@ describe("InMemoryCache#broadcastWatches", function () { [canonicalCache, nonCanonicalCache].forEach((cache) => { // Hack: delete every watch.lastDiff, so subsequent results will be // broadcast, even though they are deeply equal to the previous results. - cache["watches"].forEach((watch) => { + $(cache)["watches"].forEach((watch) => { delete watch.lastDiff; }); }); @@ -3813,19 +3818,20 @@ describe("ReactiveVar and makeVar", () => { expect(diffs.length).toBe(5); - expect(cache["watches"].size).toBe(5); + const watches = $(cache)["watches"]; + expect(watches.size).toBe(5); expect(spy).not.toBeCalled(); unwatchers.pop()!(); - expect(cache["watches"].size).toBe(4); + expect(watches.size).toBe(4); expect(spy).not.toBeCalled(); unwatchers.shift()!(); - expect(cache["watches"].size).toBe(3); + expect(watches.size).toBe(3); expect(spy).not.toBeCalled(); unwatchers.pop()!(); - expect(cache["watches"].size).toBe(2); + expect(watches.size).toBe(2); expect(spy).not.toBeCalled(); expect(diffs.length).toBe(5); @@ -3835,7 +3841,7 @@ describe("ReactiveVar and makeVar", () => { expect(unwatchers.length).toBe(3); unwatchers.forEach((unwatch) => unwatch()); - expect(cache["watches"].size).toBe(0); + expect(watches.size).toBe(0); expect(spy).toBeCalledTimes(1); expect(spy).toBeCalledWith(cache); }); @@ -3865,7 +3871,8 @@ describe("ReactiveVar and makeVar", () => { watch("a"); watch("d"); - expect(cache["watches"].size).toBe(5); + const watches = $(cache)["watches"]; + expect(watches.size).toBe(5); expect(diffCounts).toEqual({ a: 2, b: 1, @@ -3875,7 +3882,7 @@ describe("ReactiveVar and makeVar", () => { unwatchers.a.forEach((unwatch) => unwatch()); unwatchers.a.length = 0; - expect(cache["watches"].size).toBe(3); + expect(watches.size).toBe(3); nameVar("Hugh"); expect(diffCounts).toEqual({ @@ -3886,7 +3893,7 @@ describe("ReactiveVar and makeVar", () => { }); cache.reset({ discardWatches: true }); - expect(cache["watches"].size).toBe(0); + expect(watches.size).toBe(0); expect(diffCounts).toEqual({ a: 2, @@ -3926,7 +3933,7 @@ describe("ReactiveVar and makeVar", () => { }); nameVar("Trevor"); - expect(cache["watches"].size).toBe(2); + expect(watches.size).toBe(2); expect(diffCounts).toEqual({ a: 2, b: 2, @@ -3937,7 +3944,7 @@ describe("ReactiveVar and makeVar", () => { }); cache.reset({ discardWatches: true }); - expect(cache["watches"].size).toBe(0); + expect(watches.size).toBe(0); nameVar("Danielle"); expect(diffCounts).toEqual({ @@ -3949,7 +3956,7 @@ describe("ReactiveVar and makeVar", () => { f: 2, }); - expect(cache["watches"].size).toBe(0); + expect(watches.size).toBe(0); }); it("should recall forgotten vars once cache has watches again", () => { @@ -3974,22 +3981,23 @@ describe("ReactiveVar and makeVar", () => { expect(diffs.length).toBe(3); expect(names()).toEqual(["Ben", "Ben", "Ben"]); - expect(cache["watches"].size).toBe(3); + const watches = $(cache)["watches"]; + expect(watches.size).toBe(3); expect(spy).not.toBeCalled(); unwatchers.pop()!(); - expect(cache["watches"].size).toBe(2); + expect(watches.size).toBe(2); expect(spy).not.toBeCalled(); unwatchers.shift()!(); - expect(cache["watches"].size).toBe(1); + expect(watches.size).toBe(1); expect(spy).not.toBeCalled(); nameVar("Hugh"); expect(names()).toEqual(["Ben", "Ben", "Ben", "Hugh"]); unwatchers.pop()!(); - expect(cache["watches"].size).toBe(0); + expect(watches.size).toBe(0); expect(spy).toBeCalledTimes(1); expect(spy).toBeCalledWith(cache); @@ -3999,7 +4007,7 @@ describe("ReactiveVar and makeVar", () => { // Call watch(false) to avoid immediate delivery of the "ignored" name. unwatchers.push(watch(false)); - expect(cache["watches"].size).toBe(1); + expect(watches.size).toBe(1); expect(names()).toEqual(["Ben", "Ben", "Ben", "Hugh"]); // This is the test that would fail if cache.watch did not call diff --git a/src/cache/inmemory/__tests__/entityStore.ts b/src/cache/inmemory/__tests__/entityStore.ts index 72af8c300d7..e32468fd8ba 100644 --- a/src/cache/inmemory/__tests__/entityStore.ts +++ b/src/cache/inmemory/__tests__/entityStore.ts @@ -16,6 +16,7 @@ import { TypedDocumentNode } from "@graphql-typed-document-node/core"; import { stringifyForDisplay } from "../../../utilities"; import { InvariantError } from "../../../utilities/globals"; import { spyOnConsole } from "../../../testing/internal"; +import { $ } from "../privates"; describe("EntityStore", () => { it("should support result caching if so configured", () => { @@ -220,13 +221,13 @@ describe("EntityStore", () => { // Nothing left to collect, but let's also reset the result cache to // demonstrate that the recomputed cache results are unchanged. - const originalReader = cache["storeReader"]; + const { storeReader: originalReader } = $(cache); expect( cache.gc({ resetResultCache: true, }) ).toEqual([]); - expect(cache["storeReader"]).not.toBe(originalReader); + expect($(cache).storeReader).not.toBe(originalReader); const resultAfterResetResultCache = read(); expect(resultAfterResetResultCache).toBe(resultBeforeGC); expect(resultAfterResetResultCache).toBe(resultAfterGC); @@ -1000,7 +1001,7 @@ describe("EntityStore", () => { expect(cache.gc()).toEqual([]); const willId = cache.identify(data.parent)!; - const store = cache["data"]; + const { data: store } = $(cache); const storeRootData = store["data"]; // Hacky way of injecting a stray __ref field into the Will Smith Person // object, clearing store.refs (which was populated by the previous GC). @@ -2675,7 +2676,7 @@ describe("EntityStore", () => { }, }); - const store = cache["data"]; + const { data: store } = $(cache); const query = gql` query Book($isbn: string) { diff --git a/src/cache/inmemory/__tests__/readFromStore.ts b/src/cache/inmemory/__tests__/readFromStore.ts index d936ad73734..a20daba12be 100644 --- a/src/cache/inmemory/__tests__/readFromStore.ts +++ b/src/cache/inmemory/__tests__/readFromStore.ts @@ -18,6 +18,7 @@ import { TypedDocumentNode, } from "../../../core"; import { defaultCacheSizes } from "../../../utilities"; +import { $ } from "../privates"; describe("resultCacheMaxSize", () => { const cache = new InMemoryCache(); @@ -2207,7 +2208,8 @@ describe("reading from the store", () => { }, }); - const canon = cache["storeReader"].canon; + const { storeReader } = $(cache); + const canon = storeReader.canon; const query = gql` query { @@ -2263,7 +2265,8 @@ describe("reading from the store", () => { }, }); - const canon = cache["storeReader"].canon; + const { storeReader } = $(cache); + const canon = storeReader.canon; const fragment = gql` fragment CountFragment on Query { diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index f088ca1be01..8543f1ffa10 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -878,3 +878,5 @@ export function supportsResultCaching(store: any): store is EntityStore { // When result caching is disabled, store.depend will be null. return !!(store instanceof EntityStore && store.group.caching); } + +export type LayerType = Layer; diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index cddbcee955d..232f2f0828b 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -4,7 +4,6 @@ import { invariant } from "../../utilities/globals/index.js"; import "./fixPolyfills.js"; import type { DocumentNode } from "graphql"; -import type { OptimisticWrapperFunction } from "optimism"; import { wrap } from "optimism"; import { equal } from "@wry/equality"; @@ -30,30 +29,69 @@ import { Policies } from "./policies.js"; import { hasOwn, normalizeConfig, shouldCanonizeResults } from "./helpers.js"; import type { OperationVariables } from "../../core/index.js"; import { getInMemoryCacheMemoryInternals } from "../../utilities/caching/getMemoryInternals.js"; +import type { FragmentRegistryAPI } from "./fragmentRegistry.js"; +import type { + BroadcastOptions, + MaybeBroadcastWatch, + PrivateParts, +} from "./privates.js"; +import { $, privateParts } from "./privates.js"; + +function getMaybeBroadcastWatch( + resultCacheMaxSize: number, + cache: InMemoryCache +): MaybeBroadcastWatch { + return wrap( + (c: Cache.WatchOptions, options?: BroadcastOptions) => { + return cache.broadcastWatch(c, options); + }, + { + max: resultCacheMaxSize, + makeCacheKey: (c: Cache.WatchOptions) => { + // Return a cache key (thus enabling result caching) only if we're + // currently using a data store that can track cache dependencies. + const { optimisticData, data } = $(cache); + const store = c.optimistic ? optimisticData : data; + if (supportsResultCaching(store)) { + const { optimistic, id, variables } = c; + return store.makeCacheKey( + c.query, + // Different watches can have the same query, optimistic + // status, rootId, and variables, but if their callbacks are + // different, the (identical) result needs to be delivered to + // each distinct callback. The easiest way to achieve that + // separation is to include c.callback in the cache key for + // maybeBroadcastWatch calls. See issue #5733. + c.callback, + canonicalStringify({ optimistic, id, variables }) + ); + } + }, + } + ); +} -type BroadcastOptions = Pick< - Cache.BatchOptions>, - "optimistic" | "onWatchUpdated" ->; +function getStoreReader( + cache: InMemoryCache, + addTypename: boolean, + resultCacheMaxSize: number | undefined, + canonizeResults: boolean, + resetResultIdentities: boolean | undefined, + previousReader: StoreReader | undefined, + fragments: FragmentRegistryAPI | undefined +) { + return new StoreReader({ + cache, + addTypename, + resultCacheMaxSize, + canonizeResults, + canon: + resetResultIdentities ? void 0 : previousReader && previousReader.canon, + fragments, + }); +} export class InMemoryCache extends ApolloCache { - data!: EntityStore; - optimisticData!: EntityStore; - - config: InMemoryCacheConfig; - watches = new Set(); - addTypename: boolean; - - storeReader!: StoreReader; - storeWriter!: StoreWriter; - addTypenameTransform = new DocumentTransform(addTypenameToDocument); - - maybeBroadcastWatch!: OptimisticWrapperFunction< - [Cache.WatchOptions, BroadcastOptions?], - any, - [Cache.WatchOptions] - >; - // Override the default value, since InMemoryCache result objects are frozen // in development and expected to remain logically immutable in production. public readonly assumeImmutableResults = true; @@ -65,113 +103,125 @@ export class InMemoryCache extends ApolloCache { public readonly makeVar = makeVar; - constructor(config: InMemoryCacheConfig = {}) { + constructor(options: InMemoryCacheConfig = {}) { super(); - this.config = normalizeConfig(config); - this.addTypename = !!this.config.addTypename; - - this.policies = new Policies({ - cache: this, - dataIdFromObject: this.config.dataIdFromObject, - possibleTypes: this.config.possibleTypes, - typePolicies: this.config.typePolicies, - }); - - this.init(); - } - - init() { - // Passing { resultCaching: false } in the InMemoryCache constructor options - // will completely disable dependency tracking, which will improve memory - // usage but worsen the performance of repeated reads. - const rootStore = (this.data = new EntityStore.Root({ - policies: this.policies, - resultCaching: this.config.resultCaching, + const cache = this; + const config = normalizeConfig(options); + const { + typePolicies, + resultCaching, + fragments, + dataIdFromObject, + possibleTypes, + addTypename, + resultCacheMaxSize, + } = config; + const max = + resultCacheMaxSize || + cacheSizes["inMemoryCache.maybeBroadcastWatch"] || + defaultCacheSizes["inMemoryCache.maybeBroadcastWatch"]; + const canonizeResults = shouldCanonizeResults(config); + + const policies = (this.policies = new Policies({ + cache, + dataIdFromObject, + possibleTypes, + typePolicies, })); - // When no optimistic writes are currently active, cache.optimisticData === - // cache.data, so there are no additional layers on top of the actual data. - // When an optimistic update happens, this.optimisticData will become a - // linked list of EntityStore Layer objects that terminates with the - // original this.data cache object. - this.optimisticData = rootStore.stump; - - this.resetResultCache(); - } - - resetResultCache(resetResultIdentities?: boolean) { - const previousReader = this.storeReader; - const { fragments } = this.config; - - // The StoreWriter is mostly stateless and so doesn't really need to be - // reset, but it does need to have its writer.storeReader reference updated, - // so it's simpler to update this.storeWriter as well. - this.storeWriter = new StoreWriter( - this, - (this.storeReader = new StoreReader({ - cache: this, - addTypename: this.addTypename, - resultCacheMaxSize: this.config.resultCacheMaxSize, - canonizeResults: shouldCanonizeResults(this.config), - canon: - resetResultIdentities ? void 0 : ( - previousReader && previousReader.canon - ), - fragments, - })), + const data = new EntityStore.Root({ + policies, + resultCaching, + }); + const optimisticData = data.stump; + + const storeReader = getStoreReader( + cache, + addTypename, + resultCacheMaxSize, + canonizeResults, + false, + undefined, fragments ); - this.maybeBroadcastWatch = wrap( - (c: Cache.WatchOptions, options?: BroadcastOptions) => { - return this.broadcastWatch(c, options); + const storeWriter = new StoreWriter(cache, storeReader, fragments); + + const maybeBroadcastWatch = getMaybeBroadcastWatch(max, cache); + + const privates: PrivateParts = { + txCount: 0, + data, + optimisticData, + storeReader, + storeWriter, + maybeBroadcastWatch, + config, + addTypename, + watches: new Set(), + addTypenameTransform: new DocumentTransform(addTypenameToDocument), + init() { + // Passing { resultCaching: false } in the InMemoryCache constructor options + // will completely disable dependency tracking, which will improve memory + // usage but worsen the performance of repeated reads. + const rootStore = (this.data = new EntityStore.Root({ + policies, + resultCaching, + })); + + // When no optimistic writes are currently active, cache.optimisticData === + // cache.data, so there are no additional layers on top of the actual data. + // When an optimistic update happens, this.optimisticData will become a + // linked list of EntityStore Layer objects that terminates with the + // original this.data cache object. + this.optimisticData = rootStore.stump; + + this.resetResultCache(); }, - { - max: - this.config.resultCacheMaxSize || - cacheSizes["inMemoryCache.maybeBroadcastWatch"] || - defaultCacheSizes["inMemoryCache.maybeBroadcastWatch"], - makeCacheKey: (c: Cache.WatchOptions) => { - // Return a cache key (thus enabling result caching) only if we're - // currently using a data store that can track cache dependencies. - const store = c.optimistic ? this.optimisticData : this.data; - if (supportsResultCaching(store)) { - const { optimistic, id, variables } = c; - return store.makeCacheKey( - c.query, - // Different watches can have the same query, optimistic - // status, rootId, and variables, but if their callbacks are - // different, the (identical) result needs to be delivered to - // each distinct callback. The easiest way to achieve that - // separation is to include c.callback in the cache key for - // maybeBroadcastWatch calls. See issue #5733. - c.callback, - canonicalStringify({ optimistic, id, variables }) - ); - } - }, - } - ); - - // Since we have thrown away all the cached functions that depend on the - // CacheGroup dependencies maintained by EntityStore, we should also reset - // all CacheGroup dependency information. - new Set([this.data.group, this.optimisticData.group]).forEach((group) => - group.resetCaching() - ); + resetResultCache(resetResultIdentities?: boolean) { + const previousReader = this.storeReader; + const { fragments } = config; + + // The StoreWriter is mostly stateless and so doesn't really need to be + // reset, but it does need to have its writer.storeReader reference updated, + // so it's simpler to update this.storeWriter as well. + const storeReader = (this.storeReader = getStoreReader( + cache, + addTypename, + resultCacheMaxSize, + canonizeResults, + resetResultIdentities, + previousReader, + fragments + )); + this.storeWriter = new StoreWriter(cache, storeReader, fragments); + this.maybeBroadcastWatch = getMaybeBroadcastWatch(max, cache); + + // Since we have thrown away all the cached functions that depend on the + // CacheGroup dependencies maintained by EntityStore, we should also reset + // all CacheGroup dependency information. + new Set([this.data.group, this.optimisticData.group]).forEach((group) => + group.resetCaching() + ); + }, + }; + privateParts.set(this, privates); + privates.init(); } public restore(data: NormalizedCacheObject): this { - this.init(); + const _ = $(this); + _.init(); // Since calling this.init() discards/replaces the entire StoreReader, along // with the result caches it maintains, this.data.replace(data) won't have // to bother deleting the old data. - if (data) this.data.replace(data); + if (data) _.data.replace(data); return this; } public extract(optimistic: boolean = false): NormalizedCacheObject { - return (optimistic ? this.optimisticData : this.data).extract(); + const _ = $(this); + return (optimistic ? _.optimisticData : _.data).extract(); } public read(options: Cache.ReadOptions): T | null { @@ -186,11 +236,12 @@ export class InMemoryCache extends ApolloCache { returnPartialData = false, } = options; try { + const _ = $(this); return ( - this.storeReader.diffQueryAgainstStore({ + _.storeReader.diffQueryAgainstStore({ ...options, - store: options.optimistic ? this.optimisticData : this.data, - config: this.config, + store: options.optimistic ? _.optimisticData : _.data, + config: _.config, returnPartialData, }).result || null ); @@ -208,11 +259,12 @@ export class InMemoryCache extends ApolloCache { } public write(options: Cache.WriteOptions): Reference | undefined { + const _ = $(this); try { - ++this.txCount; - return this.storeWriter.writeToStore(this.data, options); + ++_.txCount; + return _.storeWriter.writeToStore(_.data, options); } finally { - if (!--this.txCount && options.broadcast !== false) { + if (!--_.txCount && options.broadcast !== false) { this.broadcastWatches(); } } @@ -233,17 +285,18 @@ export class InMemoryCache extends ApolloCache { // that nothing was modified. return false; } + const _ = $(this); const store = ( options.optimistic // Defaults to false. ) ? - this.optimisticData - : this.data; + _.optimisticData + : _.data; try { - ++this.txCount; + ++_.txCount; return store.modify(options.id || "ROOT_QUERY", options.fields); } finally { - if (!--this.txCount && options.broadcast !== false) { + if (!--_.txCount && options.broadcast !== false) { this.broadcastWatches(); } } @@ -252,18 +305,20 @@ export class InMemoryCache extends ApolloCache { public diff( options: Cache.DiffOptions ): Cache.DiffResult { - return this.storeReader.diffQueryAgainstStore({ + const _ = $(this); + return _.storeReader.diffQueryAgainstStore({ ...options, - store: options.optimistic ? this.optimisticData : this.data, + store: options.optimistic ? _.optimisticData : _.data, rootId: options.id || "ROOT_QUERY", - config: this.config, + config: _.config, }); } public watch( watch: Cache.WatchOptions ): () => void { - if (!this.watches.size) { + const _ = $(this); + if (!_.watches.size) { // In case we previously called forgetCache(this) because // this.watches became empty (see below), reattach this cache to any // reactive variables on which it previously depended. It might seem @@ -276,21 +331,21 @@ export class InMemoryCache extends ApolloCache { // reactive variables, now that it has a watcher to notify. recallCache(this); } - this.watches.add(watch); + _.watches.add(watch); if (watch.immediate) { - this.maybeBroadcastWatch(watch); + _.maybeBroadcastWatch(watch); } return () => { // Once we remove the last watch from this.watches, cache.broadcastWatches // no longer does anything, so we preemptively tell the reactive variable // system to exclude this cache from future broadcasts. - if (this.watches.delete(watch) && !this.watches.size) { + if (_.watches.delete(watch) && !_.watches.size) { forgetCache(this); } // Remove this watch from the LRU cache managed by the // maybeBroadcastWatch OptimisticWrapperFunction, to prevent memory // leaks involving the closure of watch.callback. - this.maybeBroadcastWatch.forget(watch); + _.maybeBroadcastWatch.forget(watch); }; } @@ -305,14 +360,15 @@ export class InMemoryCache extends ApolloCache { }) { canonicalStringify.reset(); print.reset(); - this.addTypenameTransform.resetCache(); - this.config.fragments?.resetCaches(); - const ids = this.optimisticData.gc(); - if (options && !this.txCount) { + const _ = $(this); + _.addTypenameTransform.resetCache(); + _.config.fragments?.resetCaches(); + const ids = _.optimisticData.gc(); + if (options && !_.txCount) { if (options.resetResultCache) { - this.resetResultCache(options.resetResultIdentities); + _.resetResultCache(options.resetResultIdentities); } else if (options.resetResultIdentities) { - this.storeReader.resetCanon(); + _.storeReader.resetCanon(); } } return ids; @@ -326,7 +382,8 @@ export class InMemoryCache extends ApolloCache { // 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); + const _ = $(this); + return (optimistic ? _.optimisticData : _.data).retain(rootId); } // Call this method to undo the effect of the retain method, above. Once the @@ -335,7 +392,8 @@ export class InMemoryCache extends ApolloCache { // 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); + const _ = $(this); + return (optimistic ? _.optimisticData : _.data).release(rootId); } // Returns the canonical ID for a given StoreObject, obeying typePolicies @@ -362,33 +420,35 @@ export class InMemoryCache extends ApolloCache { } options = { ...options, id: "ROOT_QUERY" }; } + const _ = $(this); try { // It's unlikely that the eviction will end up invoking any other // cache update operations while it's running, but {in,de}crementing // this.txCount still seems like a good idea, for uniformity with // the other update methods. - ++this.txCount; + ++_.txCount; // Pass this.data as a limit on the depth of the eviction, so evictions // during optimistic updates (when this.data is temporarily set equal to // this.optimisticData) do not escape their optimistic Layer. - return this.optimisticData.evict(options, this.data); + return _.optimisticData.evict(options, _.data); } finally { - if (!--this.txCount && options.broadcast !== false) { + if (!--_.txCount && options.broadcast !== false) { this.broadcastWatches(); } } } public reset(options?: Cache.ResetOptions): Promise { - this.init(); + const _ = $(this); + _.init(); canonicalStringify.reset(); if (options && options.discardWatches) { // Similar to what happens in the unsubscribe function returned by // cache.watch, applied to all current watches. - this.watches.forEach((watch) => this.maybeBroadcastWatch.forget(watch)); - this.watches.clear(); + _.watches.forEach((watch) => _.maybeBroadcastWatch.forget(watch)); + _.watches.clear(); forgetCache(this); } else { // Calling this.init() above unblocks all maybeBroadcastWatch caching, so @@ -404,15 +464,14 @@ export class InMemoryCache extends ApolloCache { } public removeOptimistic(idToRemove: string) { - const newOptimisticData = this.optimisticData.removeLayer(idToRemove); - if (newOptimisticData !== this.optimisticData) { - this.optimisticData = newOptimisticData; + const _ = $(this); + const newOptimisticData = _.optimisticData.removeLayer(idToRemove); + if (newOptimisticData !== _.optimisticData) { + _.optimisticData = newOptimisticData; this.broadcastWatches(); } } - txCount = 0; - public batch( options: Cache.BatchOptions< ApolloCache, @@ -427,24 +486,25 @@ export class InMemoryCache extends ApolloCache { } = options; let updateResult: TUpdateResult; + const _ = $(this); const perform = (layer?: EntityStore): TUpdateResult => { - const { data, optimisticData } = this; - ++this.txCount; + const { data, optimisticData } = _; + ++_.txCount; if (layer) { - this.data = this.optimisticData = layer; + _.data = _.optimisticData = layer; } try { return (updateResult = update(this)); } finally { - --this.txCount; - this.data = data; - this.optimisticData = optimisticData; + --_.txCount; + _.data = data; + _.optimisticData = optimisticData; } }; const alreadyDirty = new Set(); - if (onWatchUpdated && !this.txCount) { + if (onWatchUpdated && !_.txCount) { // If an options.onWatchUpdated callback is provided, we want to call it // with only the Cache.WatchOptions objects affected by options.update, // but there might be dirty watchers already waiting to be broadcast that @@ -467,14 +527,14 @@ export class InMemoryCache extends ApolloCache { // Note that there can be multiple layers with the same optimistic ID. // When removeOptimistic(id) is called for that id, all matching layers // will be removed, and the remaining layers will be reapplied. - this.optimisticData = this.optimisticData.addLayer(optimistic, perform); + _.optimisticData = _.optimisticData.addLayer(optimistic, perform); } else if (optimistic === false) { // Ensure both this.data and this.optimisticData refer to the root // (non-optimistic) layer of the cache during the update. Note that // this.data could be a Layer if we are currently executing an optimistic // update function, but otherwise will always be an EntityStore.Root // instance. - perform(this.data); + perform(_.data); } else { // Otherwise, leave this.data and this.optimisticData unchanged and run // the update with broadcast batching. @@ -482,7 +542,7 @@ export class InMemoryCache extends ApolloCache { } if (typeof removeOptimistic === "string") { - this.optimisticData = this.optimisticData.removeLayer(removeOptimistic); + _.optimisticData = _.optimisticData.removeLayer(removeOptimistic); } // Note: if this.txCount > 0, then alreadyDirty.size === 0, so this code @@ -505,7 +565,7 @@ export class InMemoryCache extends ApolloCache { // Silently re-dirty any watches that were already dirty before the update // was performed, and were not broadcast just now. if (alreadyDirty.size) { - alreadyDirty.forEach((watch) => this.maybeBroadcastWatch.dirty(watch)); + alreadyDirty.forEach((watch) => _.maybeBroadcastWatch.dirty(watch)); } } else { // If alreadyDirty is empty or we don't have an onWatchUpdated @@ -532,19 +592,21 @@ export class InMemoryCache extends ApolloCache { } broadcastWatches(options?: BroadcastOptions) { - if (!this.txCount) { - this.watches.forEach((c) => this.maybeBroadcastWatch(c, options)); + const _ = $(this); + if (!_.txCount) { + _.watches.forEach((c) => _.maybeBroadcastWatch(c, options)); } } addFragmentsToDocument(document: DocumentNode) { - const { fragments } = this.config; + const { fragments } = $(this).config; return fragments ? fragments.transform(document) : document; } addTypenameToDocument(document: DocumentNode) { - if (this.addTypename) { - return this.addTypenameTransform.transformDocument(document); + const _ = $(this); + if (_.addTypename) { + return _.addTypenameTransform.transformDocument(document); } return document; } diff --git a/src/cache/inmemory/policies.ts b/src/cache/inmemory/policies.ts index 3c68f35eb9d..98d491266a8 100644 --- a/src/cache/inmemory/policies.ts +++ b/src/cache/inmemory/policies.ts @@ -52,6 +52,7 @@ import { keyArgsFnFromSpecifier, keyFieldsFnFromSpecifier, } from "./key-extractor.js"; +import { $ } from "./privates.js"; export type TypePolicies = { [__typename: string]: TypePolicy; @@ -381,7 +382,7 @@ export class Policies { function () { const options = normalizeReadFieldOptions(arguments, storeObject); return policies.readField(options, { - store: policies.cache["data"], + store: $(policies.cache)["data"], variables: options.variables, }); }, diff --git a/src/cache/inmemory/privates.ts b/src/cache/inmemory/privates.ts new file mode 100644 index 00000000000..d414a111e6f --- /dev/null +++ b/src/cache/inmemory/privates.ts @@ -0,0 +1,49 @@ +import type { Cache } from "../core/types/Cache.js"; +import type { ApolloCache } from "../core/cache.js"; +import type { InMemoryCacheConfig, NormalizedCacheObject } from "./types.js"; +import type { OptimisticWrapperFunction } from "optimism"; +import type { EntityStore } from "./entityStore.js"; +import type { StoreReader } from "./readFromStore.js"; +import type { StoreWriter } from "./writeToStore.js"; +import type { DocumentTransform } from "../../utilities/index.js"; + +export type BroadcastOptions = Pick< + Cache.BatchOptions>, + "optimistic" | "onWatchUpdated" +>; + +export type MaybeBroadcastWatch = OptimisticWrapperFunction< + [Cache.WatchOptions, BroadcastOptions?], + any, + [Cache.WatchOptions] +>; + +export interface PrivateParts { + // Do not touch, what would you're priest say? + data: EntityStore; + optimisticData: EntityStore; + config: InMemoryCacheConfig; + watches: Set; + addTypename: boolean; + txCount: number; + storeReader: StoreReader; + storeWriter: StoreWriter; + addTypenameTransform: DocumentTransform; + maybeBroadcastWatch: MaybeBroadcastWatch; + init: () => void; + resetResultCache: (resetResultIdentities?: boolean) => void; +} + +export const privateParts = new WeakMap< + ApolloCache, + PrivateParts +>(); + +/** + * @experimental + * @internal + * This is not a stable API + * Use at your own risk! + */ +export const $ = (cache: ApolloCache): PrivateParts => + privateParts.get(cache)!; diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index d89876d85c9..13da19bd1d8 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -159,6 +159,10 @@ export class StoreReader { // by recreating the whole `StoreReader` in // `InMemoryCache.resetResultsCache` // (triggered from `InMemoryCache.gc` with `resetResultCache: true`) + const max = + this.config.resultCacheMaxSize || + cacheSizes["inMemoryCache.executeSelectionSet"] || + defaultCacheSizes["inMemoryCache.executeSelectionSet"]; this.executeSelectionSet = wrap( (options) => { const { canonizeResults } = options.context; @@ -195,10 +199,7 @@ export class StoreReader { return this.execSelectionSetImpl(options); }, { - max: - this.config.resultCacheMaxSize || - cacheSizes["inMemoryCache.executeSelectionSet"] || - defaultCacheSizes["inMemoryCache.executeSelectionSet"], + max: max, keyArgs: execSelectionSetKeyArgs, // Note that the parameters of makeCacheKey are determined by the // array returned by keyArgs. diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index e0bda61a9a5..2394e2ba8ac 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -50,6 +50,7 @@ import { itAsync, subscribeAndCount } from "../../../testing/core"; import { ApolloClient } from "../../../core"; import { mockFetchQuery } from "../ObservableQuery"; import { Concast, print } from "../../../utilities"; +import { $ } from "../../../cache/inmemory/privates"; interface MockedMutation { reject: (reason: any) => any; @@ -6020,8 +6021,7 @@ describe("QueryManager", () => { }); }) .then(() => { - // @ts-ignore - expect(cache.watches.size).toBe(0); + expect($(cache).watches.size).toBe(0); }) .then(resolve, reject); } diff --git a/src/core/index.ts b/src/core/index.ts index cfe826cadec..2f4329d990c 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -44,7 +44,7 @@ export type { WatchFragmentResult, } from "../cache/index.js"; // eslint-disable-next-line @typescript-eslint/consistent-type-exports -export { Cache } from "../cache/index.js"; +export { $, Cache } from "../cache/index.js"; export { ApolloCache, InMemoryCache, diff --git a/src/utilities/caching/getMemoryInternals.ts b/src/utilities/caching/getMemoryInternals.ts index ac28989c37b..cfbd8d9dfe1 100644 --- a/src/utilities/caching/getMemoryInternals.ts +++ b/src/utilities/caching/getMemoryInternals.ts @@ -8,6 +8,7 @@ import type { import type { ApolloClient } from "../../core/index.js"; import type { CacheSizes } from "./sizes.js"; import { cacheSizes, defaultCacheSizes } from "./sizes.js"; +import { $ } from "../../cache/inmemory/privates.js"; const globalCaches: { print?: () => number; @@ -161,7 +162,9 @@ function _getApolloCacheMemoryInternals(this: ApolloCache) { } function _getInMemoryCacheMemoryInternals(this: InMemoryCache) { - const fragments = this.config.fragments as + const { config, addTypenameTransform, storeReader, maybeBroadcastWatch } = + $(this); + const fragments = config.fragments as | undefined | { findFragmentSpreads?: Function; @@ -171,15 +174,15 @@ function _getInMemoryCacheMemoryInternals(this: InMemoryCache) { return { ..._getApolloCacheMemoryInternals.apply(this as any), - addTypenameDocumentTransform: transformInfo(this["addTypenameTransform"]), + addTypenameDocumentTransform: transformInfo(addTypenameTransform), inMemoryCache: { executeSelectionSet: getWrapperInformation( - this["storeReader"]["executeSelectionSet"] + storeReader["executeSelectionSet"] ), executeSubSelectedArray: getWrapperInformation( - this["storeReader"]["executeSubSelectedArray"] + storeReader["executeSubSelectedArray"] ), - maybeBroadcastWatch: getWrapperInformation(this["maybeBroadcastWatch"]), + maybeBroadcastWatch: getWrapperInformation(maybeBroadcastWatch), }, fragmentRegistry: { findFragmentSpreads: getWrapperInformation(