diff --git a/package-lock.json b/package-lock.json index d1f9f3a2a62..11b7c112b95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6654,7 +6654,8 @@ "fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true }, "fast-levenshtein": { "version": "2.0.6", diff --git a/package.json b/package.json index 29da62edc3e..8b1fc04951d 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,6 @@ "@wry/context": "^0.6.0", "@wry/equality": "^0.4.0", "@wry/trie": "^0.3.0", - "fast-json-stable-stringify": "^2.0.0", "graphql-tag": "^2.12.3", "hoist-non-react-statics": "^3.3.2", "optimism": "^0.16.1", diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index d41a41b752a..cf064e3d32a 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -68,6 +68,7 @@ Array [ "MissingFieldError", "Policies", "cacheSlot", + "canonicalStringify", "defaultDataIdFromObject", "fieldNameFromStoreName", "isReference", diff --git a/src/cache/index.ts b/src/cache/index.ts index d57d526ef8b..5e3f221a44f 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -38,4 +38,8 @@ export { Policies, } from './inmemory/policies'; +export { + canonicalStringify, +} from './inmemory/object-canon'; + export * from './inmemory/types'; diff --git a/src/cache/inmemory/inMemoryCache.ts b/src/cache/inmemory/inMemoryCache.ts index fe7eb284bdd..0f059478c0d 100644 --- a/src/cache/inmemory/inMemoryCache.ts +++ b/src/cache/inmemory/inMemoryCache.ts @@ -28,6 +28,7 @@ import { TypePolicies, } from './policies'; import { hasOwn } from './helpers'; +import { canonicalStringify } from './object-canon'; export interface InMemoryCacheConfig extends ApolloReducerConfig { resultCaching?: boolean; @@ -262,6 +263,7 @@ export class InMemoryCache extends ApolloCache { // Request garbage collection of unreachable normalized entities. public gc() { + canonicalStringify.reset(); return this.optimisticData.gc(); } @@ -322,6 +324,7 @@ export class InMemoryCache extends ApolloCache { public reset(): Promise { this.init(); this.broadcastWatches(); + canonicalStringify.reset(); return Promise.resolve(); } diff --git a/src/cache/inmemory/object-canon.ts b/src/cache/inmemory/object-canon.ts index 0be454bc0d7..d9a2747e2a7 100644 --- a/src/cache/inmemory/object-canon.ts +++ b/src/cache/inmemory/object-canon.ts @@ -2,7 +2,7 @@ import { Trie } from "@wry/trie"; import { canUseWeakMap } from "../../utilities"; import { objToStr } from "./helpers"; -function isObjectOrArray(value: any): boolean { +function isObjectOrArray(value: any): value is object { return !!value && typeof value === "object"; } @@ -109,7 +109,7 @@ export class ObjectCanon { switch (objToStr.call(value)) { case "[object Array]": { if (this.known.has(value)) return value; - const array: any[] = value.map(this.admit, this); + const array: any[] = (value as any[]).map(this.admit, this); // Arrays are looked up in the Trie using their recursively // canonicalized elements, and the known version of the array is // preserved as node.array. @@ -134,7 +134,7 @@ export class ObjectCanon { array.push(keys.json); const firstValueIndex = array.length; keys.sorted.forEach(key => { - array.push(this.admit(value[key])); + array.push(this.admit((value as any)[key])); }); // Objects are looked up in the Trie by their prototype (which // is *not* recursively canonicalized), followed by a JSON @@ -193,3 +193,31 @@ type SortedKeysInfo = { sorted: string[]; json: string; }; + +// Since the keys of canonical objects are always created in lexicographically +// sorted order, we can use the ObjectCanon to implement a fast and stable +// version of JSON.stringify, which automatically sorts object keys. +export const canonicalStringify = Object.assign(function (value: any): string { + if (isObjectOrArray(value)) { + const canonical = stringifyCanon.admit(value); + let json = stringifyCache.get(canonical); + if (json === void 0) { + stringifyCache.set( + canonical, + json = JSON.stringify(canonical), + ); + } + return json; + } + return JSON.stringify(value); +}, { + reset() { + stringifyCanon = new ObjectCanon; + }, +}); + +// Can be reset by calling canonicalStringify.reset(). +let stringifyCanon = new ObjectCanon; + +// Needs no resetting, thanks to weakness. +const stringifyCache = new WeakMap(); diff --git a/src/cache/inmemory/policies.ts b/src/cache/inmemory/policies.ts index f0a92829522..749b8af893f 100644 --- a/src/cache/inmemory/policies.ts +++ b/src/cache/inmemory/policies.ts @@ -48,6 +48,12 @@ import { } from '../core/types/common'; import { WriteContext } from './writeToStore'; +// Upgrade to a faster version of the default stable JSON.stringify function +// used by getStoreKeyName. This function is used when computing storeFieldName +// strings (when no keyArgs has been configured for a field). +import { canonicalStringify } from './object-canon'; +getStoreKeyName.setStringify(canonicalStringify); + export type TypePolicies = { [__typename: string]: TypePolicy; } diff --git a/src/utilities/graphql/storeUtils.ts b/src/utilities/graphql/storeUtils.ts index 31c9513fb8d..70e0bc0eb5b 100644 --- a/src/utilities/graphql/storeUtils.ts +++ b/src/utilities/graphql/storeUtils.ts @@ -17,7 +17,6 @@ import { SelectionSetNode, } from 'graphql'; -import stringify from 'fast-json-stable-stringify'; import { InvariantError } from 'ts-invariant'; import { FragmentMap, getFragmentFromSelection } from './fragments'; @@ -177,7 +176,7 @@ const KNOWN_DIRECTIVES: string[] = [ 'export', ]; -export function getStoreKeyName( +export const getStoreKeyName = Object.assign(function ( fieldName: string, args?: Record | null, directives?: Directives, @@ -202,7 +201,7 @@ export function getStoreKeyName( filteredArgs[key] = args[key]; }); - return `${directives['connection']['key']}(${JSON.stringify( + return `${directives['connection']['key']}(${stringify( filteredArgs, )})`; } else { @@ -224,7 +223,7 @@ export function getStoreKeyName( Object.keys(directives).forEach(key => { if (KNOWN_DIRECTIVES.indexOf(key) !== -1) return; if (directives[key] && Object.keys(directives[key]).length) { - completeFieldName += `@${key}(${JSON.stringify(directives[key])})`; + completeFieldName += `@${key}(${stringify(directives[key])})`; } else { completeFieldName += `@${key}`; } @@ -232,6 +231,28 @@ export function getStoreKeyName( } return completeFieldName; +}, { + setStringify(s: typeof stringify) { + const previous = stringify; + stringify = s; + return previous; + }, +}); + +// Default stable JSON.stringify implementation. Can be updated/replaced with +// something better by calling getStoreKeyName.setStringify. +let stringify = function defaultStringify(value: any): string { + return JSON.stringify(value, stringifyReplacer); +}; + +function stringifyReplacer(_key: string, value: any): any { + if (value && typeof value === "object" && !Array.isArray(value)) { + value = Object.keys(value).sort().reduce((copy, key) => { + copy[key] = value[key]; + return copy; + }, {} as Record); + } + return value; } export function argumentsObjectFromField(