diff --git a/CHANGELOG.md b/CHANGELOG.md index 8563a5a5c09..3ea42ff5e21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ **Note:** This is a cumulative changelog that outlines all of the Apollo Client project child package changes that were bundled into a specific `apollo-client` release. +## Apollo Client vNEXT + +### Apollo Cache In-Memory + +- Support `new InMemoryCache({ freezeResults: true })` to help enforce immutability.
+ [@benjamn](https://github.com/benjamn) in [#4514](https://github.com/apollographql/apollo-client/pull/4514) + ## Apollo Client 2.5.1 ### apollo-client 2.5.1 diff --git a/package.json b/package.json index 0b6e23580df..5a610388858 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ { "name": "apollo-cache-inmemory", "path": "./packages/apollo-cache-inmemory/lib/bundle.cjs.min.js", - "maxSize": "4.9 kB" + "maxSize": "4.95 kB" }, { "name": "apollo-client", diff --git a/packages/apollo-cache-inmemory/src/__tests__/__snapshots__/cache.ts.snap b/packages/apollo-cache-inmemory/src/__tests__/__snapshots__/cache.ts.snap index 0cd8b06df71..d972da111c5 100644 --- a/packages/apollo-cache-inmemory/src/__tests__/__snapshots__/cache.ts.snap +++ b/packages/apollo-cache-inmemory/src/__tests__/__snapshots__/cache.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Cache writeFragment will write some deeply nested data into the store at any id (1/2) 1`] = ` +exports[`Cache writeFragment will write some deeply nested data into the store at any id (1/3) 1`] = ` Object { "bar": Object { "i": 7, @@ -17,7 +17,7 @@ Object { } `; -exports[`Cache writeFragment will write some deeply nested data into the store at any id (1/2) 2`] = ` +exports[`Cache writeFragment will write some deeply nested data into the store at any id (1/3) 2`] = ` Object { "bar": Object { "i": 7, @@ -38,7 +38,7 @@ Object { } `; -exports[`Cache writeFragment will write some deeply nested data into the store at any id (1/2) 3`] = ` +exports[`Cache writeFragment will write some deeply nested data into the store at any id (1/3) 3`] = ` Object { "bar": Object { "i": 10, @@ -59,7 +59,7 @@ Object { } `; -exports[`Cache writeFragment will write some deeply nested data into the store at any id (1/2) 4`] = ` +exports[`Cache writeFragment will write some deeply nested data into the store at any id (1/3) 4`] = ` Object { "bar": Object { "i": 10, @@ -80,7 +80,7 @@ Object { } `; -exports[`Cache writeFragment will write some deeply nested data into the store at any id (1/2) 5`] = ` +exports[`Cache writeFragment will write some deeply nested data into the store at any id (1/3) 5`] = ` Object { "bar": Object { "i": 7, @@ -101,7 +101,7 @@ Object { } `; -exports[`Cache writeFragment will write some deeply nested data into the store at any id (1/2) 6`] = ` +exports[`Cache writeFragment will write some deeply nested data into the store at any id (1/3) 6`] = ` Object { "bar": Object { "i": 10, @@ -122,7 +122,7 @@ Object { } `; -exports[`Cache writeFragment will write some deeply nested data into the store at any id (2/2) 1`] = ` +exports[`Cache writeFragment will write some deeply nested data into the store at any id (2/3) 1`] = ` Object { "bar": Object { "i": 7, @@ -139,7 +139,7 @@ Object { } `; -exports[`Cache writeFragment will write some deeply nested data into the store at any id (2/2) 2`] = ` +exports[`Cache writeFragment will write some deeply nested data into the store at any id (2/3) 2`] = ` Object { "bar": Object { "i": 7, @@ -160,7 +160,7 @@ Object { } `; -exports[`Cache writeFragment will write some deeply nested data into the store at any id (2/2) 3`] = ` +exports[`Cache writeFragment will write some deeply nested data into the store at any id (2/3) 3`] = ` Object { "bar": Object { "i": 10, @@ -181,7 +181,7 @@ Object { } `; -exports[`Cache writeFragment will write some deeply nested data into the store at any id (2/2) 4`] = ` +exports[`Cache writeFragment will write some deeply nested data into the store at any id (2/3) 4`] = ` Object { "bar": Object { "i": 10, @@ -202,7 +202,7 @@ Object { } `; -exports[`Cache writeFragment will write some deeply nested data into the store at any id (2/2) 5`] = ` +exports[`Cache writeFragment will write some deeply nested data into the store at any id (2/3) 5`] = ` Object { "bar": Object { "i": 7, @@ -223,7 +223,129 @@ Object { } `; -exports[`Cache writeFragment will write some deeply nested data into the store at any id (2/2) 6`] = ` +exports[`Cache writeFragment will write some deeply nested data into the store at any id (2/3) 6`] = ` +Object { + "bar": Object { + "i": 10, + "j": 11, + "k": 12, + }, + "foo": Object { + "e": 4, + "f": 5, + "g": 6, + "h": Object { + "generated": false, + "id": "bar", + "type": "id", + "typename": "Bar", + }, + }, +} +`; + +exports[`Cache writeFragment will write some deeply nested data into the store at any id (3/3) 1`] = ` +Object { + "bar": Object { + "i": 7, + }, + "foo": Object { + "e": 4, + "h": Object { + "generated": false, + "id": "bar", + "type": "id", + "typename": undefined, + }, + }, +} +`; + +exports[`Cache writeFragment will write some deeply nested data into the store at any id (3/3) 2`] = ` +Object { + "bar": Object { + "i": 7, + "j": 8, + "k": 9, + }, + "foo": Object { + "e": 4, + "f": 5, + "g": 6, + "h": Object { + "generated": false, + "id": "bar", + "type": "id", + "typename": undefined, + }, + }, +} +`; + +exports[`Cache writeFragment will write some deeply nested data into the store at any id (3/3) 3`] = ` +Object { + "bar": Object { + "i": 10, + "j": 8, + "k": 9, + }, + "foo": Object { + "e": 4, + "f": 5, + "g": 6, + "h": Object { + "generated": false, + "id": "bar", + "type": "id", + "typename": undefined, + }, + }, +} +`; + +exports[`Cache writeFragment will write some deeply nested data into the store at any id (3/3) 4`] = ` +Object { + "bar": Object { + "i": 10, + "j": 11, + "k": 12, + }, + "foo": Object { + "e": 4, + "f": 5, + "g": 6, + "h": Object { + "generated": false, + "id": "bar", + "type": "id", + "typename": undefined, + }, + }, +} +`; + +exports[`Cache writeFragment will write some deeply nested data into the store at any id (3/3) 5`] = ` +Object { + "bar": Object { + "i": 7, + "j": 8, + "k": 9, + }, + "foo": Object { + "e": 4, + "f": 5, + "g": 6, + "h": Object { + "generated": false, + "id": "bar", + "type": "id", + "typename": "Bar", + }, + }, +} +`; + +exports[`Cache writeFragment will write some deeply nested data into the store at any id (3/3) 6`] = ` Object { "bar": Object { "i": 10, diff --git a/packages/apollo-cache-inmemory/src/__tests__/__snapshots__/mapCache.ts.snap b/packages/apollo-cache-inmemory/src/__tests__/__snapshots__/mapCache.ts.snap index c3711026b09..4682281f7f0 100644 --- a/packages/apollo-cache-inmemory/src/__tests__/__snapshots__/mapCache.ts.snap +++ b/packages/apollo-cache-inmemory/src/__tests__/__snapshots__/mapCache.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (1/2) 1`] = ` +exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (1/3) 1`] = ` Object { "bar": Object { "i": 7, @@ -17,7 +17,7 @@ Object { } `; -exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (1/2) 2`] = ` +exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (1/3) 2`] = ` Object { "bar": Object { "i": 7, @@ -38,7 +38,7 @@ Object { } `; -exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (1/2) 3`] = ` +exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (1/3) 3`] = ` Object { "bar": Object { "i": 10, @@ -59,7 +59,7 @@ Object { } `; -exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (1/2) 4`] = ` +exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (1/3) 4`] = ` Object { "bar": Object { "i": 10, @@ -80,7 +80,7 @@ Object { } `; -exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (1/2) 5`] = ` +exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (1/3) 5`] = ` Object { "bar": Object { "i": 7, @@ -101,7 +101,7 @@ Object { } `; -exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (1/2) 6`] = ` +exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (1/3) 6`] = ` Object { "bar": Object { "i": 10, @@ -122,7 +122,7 @@ Object { } `; -exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (2/2) 1`] = ` +exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (2/3) 1`] = ` Object { "bar": Object { "i": 7, @@ -139,7 +139,7 @@ Object { } `; -exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (2/2) 2`] = ` +exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (2/3) 2`] = ` Object { "bar": Object { "i": 7, @@ -160,7 +160,7 @@ Object { } `; -exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (2/2) 3`] = ` +exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (2/3) 3`] = ` Object { "bar": Object { "i": 10, @@ -181,7 +181,7 @@ Object { } `; -exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (2/2) 4`] = ` +exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (2/3) 4`] = ` Object { "bar": Object { "i": 10, @@ -202,7 +202,7 @@ Object { } `; -exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (2/2) 5`] = ` +exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (2/3) 5`] = ` Object { "bar": Object { "i": 7, @@ -223,7 +223,129 @@ Object { } `; -exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (2/2) 6`] = ` +exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (2/3) 6`] = ` +Object { + "bar": Object { + "i": 10, + "j": 11, + "k": 12, + }, + "foo": Object { + "e": 4, + "f": 5, + "g": 6, + "h": Object { + "generated": false, + "id": "bar", + "type": "id", + "typename": "Bar", + }, + }, +} +`; + +exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (3/3) 1`] = ` +Object { + "bar": Object { + "i": 7, + }, + "foo": Object { + "e": 4, + "h": Object { + "generated": false, + "id": "bar", + "type": "id", + "typename": undefined, + }, + }, +} +`; + +exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (3/3) 2`] = ` +Object { + "bar": Object { + "i": 7, + "j": 8, + "k": 9, + }, + "foo": Object { + "e": 4, + "f": 5, + "g": 6, + "h": Object { + "generated": false, + "id": "bar", + "type": "id", + "typename": undefined, + }, + }, +} +`; + +exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (3/3) 3`] = ` +Object { + "bar": Object { + "i": 10, + "j": 8, + "k": 9, + }, + "foo": Object { + "e": 4, + "f": 5, + "g": 6, + "h": Object { + "generated": false, + "id": "bar", + "type": "id", + "typename": undefined, + }, + }, +} +`; + +exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (3/3) 4`] = ` +Object { + "bar": Object { + "i": 10, + "j": 11, + "k": 12, + }, + "foo": Object { + "e": 4, + "f": 5, + "g": 6, + "h": Object { + "generated": false, + "id": "bar", + "type": "id", + "typename": undefined, + }, + }, +} +`; + +exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (3/3) 5`] = ` +Object { + "bar": Object { + "i": 7, + "j": 8, + "k": 9, + }, + "foo": Object { + "e": 4, + "f": 5, + "g": 6, + "h": Object { + "generated": false, + "id": "bar", + "type": "id", + "typename": "Bar", + }, + }, +} +`; + +exports[`MapCache Cache writeFragment will write some deeply nested data into the store at any id (3/3) 6`] = ` Object { "bar": Object { "i": 10, diff --git a/packages/apollo-cache-inmemory/src/__tests__/cache.ts b/packages/apollo-cache-inmemory/src/__tests__/cache.ts index f0cb6e27ed1..58e89177908 100644 --- a/packages/apollo-cache-inmemory/src/__tests__/cache.ts +++ b/packages/apollo-cache-inmemory/src/__tests__/cache.ts @@ -25,6 +25,12 @@ describe('Cache', () => { resultCaching: false, }).restore(cloneDeep(data)) ), + initialDataForCaches.map( + data => new InMemoryCache({ + addTypename: false, + freezeResults: true, + }).restore(cloneDeep(data)) + ), ]; cachesList.forEach((caches, i) => { @@ -48,6 +54,11 @@ describe('Cache', () => { ...config, resultCaching: false, }), + new InMemoryCache({ + addTypename: false, + ...config, + freezeResults: true, + }), ]; caches.forEach((cache, i) => { diff --git a/packages/apollo-cache-inmemory/src/__tests__/roundtrip.ts b/packages/apollo-cache-inmemory/src/__tests__/roundtrip.ts index ad4a337c711..30a70ae8914 100644 --- a/packages/apollo-cache-inmemory/src/__tests__/roundtrip.ts +++ b/packages/apollo-cache-inmemory/src/__tests__/roundtrip.ts @@ -16,8 +16,25 @@ import { const fragmentMatcherFunction = new HeuristicFragmentMatcher().match; +function assertDeeplyFrozen(value: any, stack: any[] = []) { + if ( + value !== null && + typeof value === 'object' && + stack.indexOf(value) < 0 + ) { + expect(Object.isExtensible(value)).toBe(false); + expect(Object.isFrozen(value)).toBe(true); + stack.push(value); + Object.keys(value).forEach(key => { + assertDeeplyFrozen(value[key], stack); + }); + expect(stack.pop()).toBe(value); + } +} + function storeRoundtrip(query: DocumentNode, result: any, variables = {}) { const reader = new StoreReader(); + const immutableReader = new StoreReader({ freezeResults: true }); const writer = new StoreWriter(); const store = writer.writeQueryToStore({ @@ -41,6 +58,22 @@ function storeRoundtrip(query: DocumentNode, result: any, variables = {}) { expect(store).toBeInstanceOf(DepTrackingCache); expect(reader.readQueryFromStore(readOptions)).toBe(reconstructedResult); + const immutableResult = immutableReader.readQueryFromStore(readOptions); + expect(immutableResult).toEqual(reconstructedResult); + expect(immutableReader.readQueryFromStore(readOptions)).toBe(immutableResult); + if (process.env.NODE_ENV !== 'production') { + try { + // Note: this illegal assignment will only throw in strict mode, but that's + // safe to assume because this test file is a module. + (immutableResult as any).illegal = "this should not work"; + throw new Error("unreached"); + } catch (e) { + expect(e.message).not.toMatch(/unreached/); + expect(e).toBeInstanceOf(TypeError); + } + assertDeeplyFrozen(immutableResult); + } + // Now make sure subtrees of the result are identical even after we write // an additional bogus field to the store. writer.writeQueryToStore({ @@ -203,6 +236,19 @@ describe('roundtrip', () => { }); it('with GraphQLJSON scalar type', () => { + const updateClub = { + uid: '1d7f836018fc11e68d809dfee940f657', + name: 'Eple', + settings: { + name: 'eple', + currency: 'AFN', + calendarStretch: 2, + defaultPreAllocationPeriod: 1, + confirmationEmailCopy: null, + emailDomains: null, + }, + }; + storeRoundtrip( gql` { @@ -214,20 +260,14 @@ describe('roundtrip', () => { } `, { - updateClub: { - uid: '1d7f836018fc11e68d809dfee940f657', - name: 'Eple', - settings: { - name: 'eple', - currency: 'AFN', - calendarStretch: 2, - defaultPreAllocationPeriod: 1, - confirmationEmailCopy: null, - emailDomains: null, - }, - }, + updateClub, }, ); + + // Just because we read from the store using { freezeResults: true }, the + // original data should not be frozen. + expect(Object.isExtensible(updateClub)).toBe(true); + expect(Object.isFrozen(updateClub)).toBe(false); }); describe('directives', () => { diff --git a/packages/apollo-cache-inmemory/src/inMemoryCache.ts b/packages/apollo-cache-inmemory/src/inMemoryCache.ts index 3d4af3f4f4a..85e3ba0088e 100644 --- a/packages/apollo-cache-inmemory/src/inMemoryCache.ts +++ b/packages/apollo-cache-inmemory/src/inMemoryCache.ts @@ -26,6 +26,7 @@ import { ObjectCache } from './objectCache'; export interface InMemoryCacheConfig extends ApolloReducerConfig { resultCaching?: boolean; + freezeResults?: boolean; } const defaultConfig: InMemoryCacheConfig = { @@ -33,6 +34,7 @@ const defaultConfig: InMemoryCacheConfig = { dataIdFromObject: defaultDataIdFromObject, addTypename: true, resultCaching: true, + freezeResults: false, }; export function defaultDataIdFromObject(result: any): string | null { @@ -128,8 +130,11 @@ export class InMemoryCache extends ApolloCache { // original this.data cache object. this.optimisticData = this.data; - this.storeReader = new StoreReader(this.cacheKeyRoot); this.storeWriter = new StoreWriter(); + this.storeReader = new StoreReader({ + cacheKeyRoot: this.cacheKeyRoot, + freezeResults: config.freezeResults, + }); const cache = this; const { maybeBroadcastWatch } = cache; diff --git a/packages/apollo-cache-inmemory/src/readFromStore.ts b/packages/apollo-cache-inmemory/src/readFromStore.ts index 495b120a1c8..01b14678778 100644 --- a/packages/apollo-cache-inmemory/src/readFromStore.ts +++ b/packages/apollo-cache-inmemory/src/readFromStore.ts @@ -21,6 +21,7 @@ import { shouldInclude, toIdValue, mergeDeepArray, + maybeDeepFreeze, } from 'apollo-utilities'; import { Cache } from 'apollo-cache'; @@ -93,15 +94,24 @@ type ExecSelectionSetOptions = { execContext: ExecContext; }; +export interface StoreReaderConfig { + cacheKeyRoot?: CacheKeyNode; + freezeResults?: boolean; +}; + export class StoreReader { - constructor( - private cacheKeyRoot = new CacheKeyNode, - ) { - const reader = this; + private freezeResults: boolean; + + constructor({ + cacheKeyRoot = new CacheKeyNode, + freezeResults = false, + }: StoreReaderConfig = {}) { const { executeStoreQuery, executeSelectionSet, - } = reader; + } = this; + + this.freezeResults = freezeResults; this.executeStoreQuery = wrap((options: ExecStoreQueryOptions) => { return executeStoreQuery.call(this, options); @@ -117,7 +127,7 @@ export class StoreReader { // underlying store is capable of tracking dependencies and invalidating // the cache when relevant data have changed. if (contextValue.store instanceof DepTrackingCache) { - return reader.cacheKeyRoot.lookup( + return cacheKeyRoot.lookup( query, contextValue.store, fragmentMatcher, @@ -138,7 +148,7 @@ export class StoreReader { execContext, }: ExecSelectionSetOptions) { if (execContext.contextValue.store instanceof DepTrackingCache) { - return reader.cacheKeyRoot.lookup( + return cacheKeyRoot.lookup( selectionSet, execContext.contextValue.store, execContext.fragmentMatcher, @@ -376,6 +386,10 @@ export class StoreReader { // defensive shallow copies than necessary. finalResult.result = mergeDeepArray(objectsToMerge); + if (this.freezeResults && process.env.NODE_ENV !== 'production') { + Object.freeze(finalResult.result); + } + return finalResult; } @@ -417,6 +431,9 @@ export class StoreReader { // Handle all scalar types here if (!field.selectionSet) { assertSelectionSetForIdValue(field, readStoreResult.result); + if (this.freezeResults && process.env.NODE_ENV !== 'production') { + maybeDeepFreeze(readStoreResult); + } return readStoreResult; } @@ -495,6 +512,10 @@ export class StoreReader { return item; }); + if (this.freezeResults && process.env.NODE_ENV !== 'production') { + Object.freeze(result); + } + return { result, missing }; } }