diff --git a/.changeset/new-humans-carry.md b/.changeset/new-humans-carry.md new file mode 100644 index 0000000000..d191bc57cc --- /dev/null +++ b/.changeset/new-humans-carry.md @@ -0,0 +1,5 @@ +--- +'@urql/exchange-graphcache': patch +--- + +Refactor parts of Graphcache for a minor performance boost and bundlesize reductions. diff --git a/exchanges/graphcache/benchmark/suite.js b/exchanges/graphcache/benchmark/suite.js index bdb72add95..461802b41e 100644 --- a/exchanges/graphcache/benchmark/suite.js +++ b/exchanges/graphcache/benchmark/suite.js @@ -87,60 +87,6 @@ suite('10,000 entries write', () => { }); }); -suite('100 entries GC', () => { - const urqlStore = new Store(); - const apolloCache = new InMemoryCache({ resultCaching: false }); - - benchmark('apollo', () => { - apolloCache.writeQuery({ query: TodosQuery, data: { todos: hundredEntries } }) - apolloCache.writeQuery({ query: TodosQuery, data: { todos: [] } }) - apolloCache.gc(); - }); - - benchmark('urql', () => { - write(urqlStore, { query: TodosQuery }, { todos: hundredEntries }); - write(urqlStore, { query: TodosQuery }, { todos: [] }); - urqlStore.gcScheduled = false; - urqlStore.gc(); - }); -}); - -suite('1,000 entries GC', () => { - const urqlStore = new Store(); - const apolloCache = new InMemoryCache({ resultCaching: false }); - - benchmark('apollo', () => { - apolloCache.writeQuery({ query: TodosQuery, data: { todos: thousandEntries } }) - apolloCache.writeQuery({ query: TodosQuery, data: { todos: [] } }) - apolloCache.gc(); - }); - - benchmark('urql', () => { - write(urqlStore, { query: TodosQuery }, { todos: thousandEntries }); - write(urqlStore, { query: TodosQuery }, { todos: [] }); - urqlStore.gcScheduled = false; - urqlStore.gc(); - }); -}); - -suite('10,000 entries GC', () => { - const urqlStore = new Store(); - const apolloCache = new InMemoryCache({ resultCaching: false }); - - benchmark('apollo', () => { - apolloCache.writeQuery({ query: TodosQuery, data: { todos: tenThousandEntries } }) - apolloCache.writeQuery({ query: TodosQuery, data: { todos: [] } }) - apolloCache.gc(); - }); - - benchmark('urql', () => { - write(urqlStore, { query: TodosQuery }, { todos: tenThousandEntries }); - write(urqlStore, { query: TodosQuery }, { todos: [] }); - urqlStore.gcScheduled = false; - urqlStore.gc(); - }); -}); - suite('100 entries read', () => { const urqlStore = new Store(); const apolloCache = new InMemoryCache({ resultCaching: false });; diff --git a/exchanges/graphcache/src/ast/node.test.ts b/exchanges/graphcache/src/ast/node.test.ts deleted file mode 100644 index eeef258eab..0000000000 --- a/exchanges/graphcache/src/ast/node.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { unwrapType } from './node'; -import { GraphQLScalarType, GraphQLNonNull, GraphQLList } from 'graphql'; - -describe('unwrapType', () => { - it('Should return the type if not wrapped', () => { - const type = new GraphQLScalarType({ - name: 'String', - serialize: () => null, - }); - - expect(unwrapType(type)).toBe(type); - }); - - it('Should unwrap non-nullable types', () => { - const scalar = new GraphQLScalarType({ - name: 'MyScalar', - serialize: () => null, - }); - const type = new GraphQLNonNull(scalar); - - expect(unwrapType(type)).toBe(scalar); - }); - - it('Should unwrap list types', () => { - const scalar = new GraphQLScalarType({ - name: 'MyScalar', - serialize: () => null, - }); - const type = new GraphQLList(scalar); - - expect(unwrapType(type)).toBe(scalar); - }); - - it('Should unwrap nested types', () => { - const scalar = new GraphQLScalarType({ - name: 'MyScalar', - serialize: () => null, - }); - - const nonNullList = new GraphQLNonNull(scalar); - - const type = new GraphQLList(nonNullList); - - expect(unwrapType(type)).toBe(scalar); - }); -}); diff --git a/exchanges/graphcache/src/ast/node.ts b/exchanges/graphcache/src/ast/node.ts index d773f86bcd..193414569d 100644 --- a/exchanges/graphcache/src/ast/node.ts +++ b/exchanges/graphcache/src/ast/node.ts @@ -6,14 +6,10 @@ import { InlineFragmentNode, FieldNode, FragmentDefinitionNode, - GraphQLOutputType, Kind, - isWrappingType, - GraphQLWrappingType, } from 'graphql'; export type SelectionSet = ReadonlyArray; -export type GraphQLFlatType = Exclude; /** Returns the name of a given node */ export const getName = (node: { name: NameNode }): string => node.name.value; @@ -23,20 +19,18 @@ export const getFragmentTypeName = (node: FragmentDefinitionNode): string => /** Returns either the field's name or the field's alias */ export const getFieldAlias = (node: FieldNode): string => - node.alias !== undefined ? node.alias.value : getName(node); + node.alias ? node.alias.value : getName(node); /** Returns the SelectionSet for a given inline or defined fragment node */ export const getSelectionSet = (node: { selectionSet?: SelectionSetNode; -}): SelectionSet => - node.selectionSet !== undefined ? node.selectionSet.selections : []; +}): SelectionSet => (node.selectionSet ? node.selectionSet.selections : []); export const getTypeCondition = ({ typeCondition, }: { typeCondition?: NamedTypeNode; -}): string | null => - typeCondition !== undefined ? getName(typeCondition) : null; +}): string | null => (typeCondition ? getName(typeCondition) : null); export const isFieldNode = (node: SelectionNode): node is FieldNode => node.kind === Kind.FIELD; @@ -44,13 +38,3 @@ export const isFieldNode = (node: SelectionNode): node is FieldNode => export const isInlineFragment = ( node: SelectionNode ): node is InlineFragmentNode => node.kind === Kind.INLINE_FRAGMENT; - -export const unwrapType = ( - type: null | undefined | GraphQLOutputType -): GraphQLFlatType | null => { - if (isWrappingType(type)) { - return unwrapType(type.ofType); - } - - return type || null; -}; diff --git a/exchanges/graphcache/src/ast/schemaPredicates.ts b/exchanges/graphcache/src/ast/schemaPredicates.ts index bcc3db1166..6e0f2333b7 100644 --- a/exchanges/graphcache/src/ast/schemaPredicates.ts +++ b/exchanges/graphcache/src/ast/schemaPredicates.ts @@ -17,8 +17,7 @@ export const isFieldNullable = ( fieldName: string ): boolean => { const field = getField(schema, typename, fieldName); - if (field === undefined) return false; - return isNullableType(field.type); + return !!field && isNullableType(field.type); }; export const isListNullable = ( @@ -27,7 +26,7 @@ export const isListNullable = ( fieldName: string ): boolean => { const field = getField(schema, typename, fieldName); - if (field === undefined) return false; + if (!field) return false; const ofType = isNonNullType(field.type) ? field.type.ofType : field.type; return isListType(ofType) && isNullableType(ofType.ofType); }; @@ -69,7 +68,7 @@ const getField = ( expectObjectType(object, typename); const field = object.getFields()[fieldName]; - if (field === undefined) { + if (!field) { warn( 'Invalid field: The field `' + fieldName + @@ -80,8 +79,6 @@ const getField = ( 'Traversal will continue, however this may lead to undefined behavior!', 4 ); - - return undefined; } return field; diff --git a/exchanges/graphcache/src/ast/traversal.ts b/exchanges/graphcache/src/ast/traversal.ts index 4f58e10221..df0ee7215e 100644 --- a/exchanges/graphcache/src/ast/traversal.ts +++ b/exchanges/graphcache/src/ast/traversal.ts @@ -46,29 +46,24 @@ export const shouldInclude = ( vars: Variables ): boolean => { const { directives } = node; - if (directives === undefined) { - return true; - } + if (!directives) return true; // Finds any @include or @skip directive that forces the node to be skipped for (let i = 0, l = directives.length; i < l; i++) { const directive = directives[i]; const name = getName(directive); - // Ignore other directives - const isInclude = name === 'include'; - if (!isInclude && name !== 'skip') continue; - - // Get the first argument and expect it to be named "if" - const arg = directive.arguments ? directive.arguments[0] : null; - if (!arg || getName(arg) !== 'if') continue; - - const value = valueFromASTUntyped(arg.value, vars); - if (typeof value !== 'boolean' && value !== null) continue; - - // Return whether this directive forces us to skip - // `@include(if: false)` or `@skip(if: true)` - return isInclude ? !!value : !value; + if ( + (name === 'include' || name === 'skip') && + directive.arguments && + directive.arguments[0] && + getName(directive.arguments[0]) === 'if' + ) { + // Return whether this directive forces us to skip + // `@include(if: false)` or `@skip(if: true)` + const value = valueFromASTUntyped(directive.arguments[0].value, vars); + return name === 'include' ? !!value : !value; + } } return true; diff --git a/exchanges/graphcache/src/ast/variables.ts b/exchanges/graphcache/src/ast/variables.ts index c05af732db..2831d0ceb8 100644 --- a/exchanges/graphcache/src/ast/variables.ts +++ b/exchanges/graphcache/src/ast/variables.ts @@ -14,19 +14,16 @@ export const getFieldArguments = ( node: FieldNode, vars: Variables ): null | Variables => { - if (node.arguments === undefined || node.arguments.length === 0) { - return null; - } - const args = makeDict(); let argsSize = 0; - - for (let i = 0, l = node.arguments.length; i < l; i++) { - const arg = node.arguments[i]; - const value = valueFromASTUntyped(arg.value, vars); - if (value !== undefined && value !== null) { - args[getName(arg)] = value; - argsSize++; + if (node.arguments && node.arguments.length) { + for (let i = 0, l = node.arguments.length; i < l; i++) { + const arg = node.arguments[i]; + const value = valueFromASTUntyped(arg.value, vars); + if (value !== undefined && value !== null) { + args[getName(arg)] = value; + argsSize++; + } } } @@ -38,24 +35,20 @@ export const normalizeVariables = ( node: OperationDefinitionNode, input: void | object ): Variables => { - if (node.variableDefinitions === undefined) { - return {}; - } - const args: Variables = (input as Variables) || {}; - - return node.variableDefinitions.reduce((vars, def) => { - const name = getName(def.variable); - let value = args[name]; - if (value === undefined) { - if (def.defaultValue !== undefined) { + const vars = makeDict(); + if (node.variableDefinitions) { + for (let i = 0, l = node.variableDefinitions.length; i < l; i++) { + const def = node.variableDefinitions[i]; + const name = getName(def.variable); + let value = args[name]; + if (value === undefined && def.defaultValue) { value = valueFromASTUntyped(def.defaultValue, args); - } else { - return vars; } + + vars[name] = value; } + } - vars[name] = value; - return vars; - }, makeDict()); + return vars; }; diff --git a/exchanges/graphcache/src/cacheExchange.ts b/exchanges/graphcache/src/cacheExchange.ts index 979fb06a9d..0d66a67e14 100644 --- a/exchanges/graphcache/src/cacheExchange.ts +++ b/exchanges/graphcache/src/cacheExchange.ts @@ -61,37 +61,6 @@ const addCacheOutcome = (op: Operation, outcome: CacheOutcome): Operation => ({ }, }); -// Returns the given operation with added __typename fields on its query -const addTypeNames = (op: Operation): Operation => ({ - ...op, - query: formatDocument(op.query), -}); - -// Retrieves the requestPolicy from an operation -const getRequestPolicy = (op: Operation) => op.context.requestPolicy; - -// Returns whether an operation is a query -const isQueryOperation = (op: Operation): boolean => - op.operationName === 'query'; - -// Returns whether an operation is a mutation -const isMutationOperation = (op: Operation): boolean => - op.operationName === 'mutation'; - -// Returns whether an operation is a subscription -const isSubscriptionOperation = (op: Operation): boolean => - op.operationName === 'subscription'; - -// Returns whether an operation can potentially be read from cache -const isCacheableQuery = (op: Operation): boolean => { - return isQueryOperation(op) && getRequestPolicy(op) !== 'network-only'; -}; - -// Returns whether an operation potentially triggers an optimistic update -const isOptimisticMutation = (op: Operation): boolean => { - return isMutationOperation(op) && getRequestPolicy(op) !== 'network-only'; -}; - // Copy an operation and change the requestPolicy to skip the cache const toRequestPolicy = ( operation: Operation, @@ -129,9 +98,8 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ let hydration: void | Promise; if (opts.storage) { - const storage = opts.storage; - hydration = storage.read().then(entries => { - hydrateData(store.data, storage, entries); + hydration = opts.storage.read().then(entries => { + hydrateData(store.data, opts!.storage!, entries); }); } @@ -143,11 +111,11 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ pendingOperations: Set, dependencies: void | Set ) => { - if (dependencies !== undefined) { + if (dependencies) { // Collect operations that will be updated due to cache changes dependencies.forEach(dep => { const keys = deps[dep]; - if (keys !== undefined) { + if (keys) { deps[dep] = []; for (let i = 0, l = keys.length; i < l; i++) { pendingOperations.add(keys[i]); @@ -165,7 +133,7 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ pendingOperations.forEach(key => { if (key !== operation.key) { const op = ops.get(key); - if (op !== undefined) { + if (op) { ops.delete(key); client.reexecuteOperation(toRequestPolicy(op, 'cache-first')); } @@ -175,7 +143,7 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ // This registers queries with the data layer to ensure commutativity const prepareCacheForResult = (operation: Operation) => { - if (isQueryOperation(operation)) { + if (operation.operationName === 'query') { reserveLayer(store.data, operation.key); } else if (operation.operationName === 'teardown') { noopDataState(store.data, operation.key); @@ -185,7 +153,10 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ // This executes an optimistic update for mutations and registers it if necessary const optimisticUpdate = (operation: Operation) => { const { key } = operation; - if (isOptimisticMutation(operation)) { + if ( + operation.operationName === 'mutation' && + operation.context.requestPolicy !== 'network-only' + ) { const { dependencies } = writeOptimistic(store, operation, key); if (dependencies.size !== 0) { optimisticKeysToDependencies.set(key, dependencies); @@ -201,13 +172,12 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ // This updates the known dependencies for the passed operation const updateDependencies = (op: Operation, dependencies: Set) => { dependencies.forEach(dep => { - const keys = deps[dep] || (deps[dep] = []); - keys.push(op.key); + (deps[dep] || (deps[dep] = [])).push(op.key); if (!ops.has(op.key)) { ops.set( op.key, - getRequestPolicy(op) === 'network-only' + op.context.requestPolicy === 'network-only' ? toRequestPolicy(op, 'cache-and-network') : op ); @@ -220,78 +190,71 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ const operationResultFromCache = ( operation: Operation ): OperationResultWithMeta => { - const { data, dependencies, partial } = query(store, operation); - let cacheOutcome: CacheOutcome; - - if (data === null) { - cacheOutcome = 'miss'; - } else { - updateDependencies(operation, dependencies); - cacheOutcome = - !partial || getRequestPolicy(operation) === 'cache-only' - ? 'hit' - : 'partial'; + const res = query(store, operation); + const cacheOutcome: CacheOutcome = res.data + ? !res.partial || operation.context.requestPolicy === 'cache-only' + ? 'hit' + : 'partial' + : 'miss'; + + if (res.data) { + updateDependencies(operation, res.dependencies); } return { outcome: cacheOutcome, operation, - data, + data: res.data, }; }; // Take any OperationResult and update the cache with it const updateCacheWithResult = (result: OperationResult): OperationResult => { const { operation, error, extensions } = result; - const isQuery = isQueryOperation(operation); - let { data } = result; // Clear old optimistic values from the store const { key } = operation; const pendingOperations = new Set(); - if (isMutationOperation(operation)) { + if (operation.operationName === 'mutation') { + // Collect previous dependencies that have been written for optimistic updates collectPendingOperations( pendingOperations, optimisticKeysToDependencies.get(key) ); optimisticKeysToDependencies.delete(key); - } else if (isSubscriptionOperation(operation)) { + } else if (operation.operationName === 'subscription') { // If we're writing a subscription, we ad-hoc reserve a layer reserveLayer(store.data, operation.key); } - let writeDependencies: Set | void; let queryDependencies: Set | void; - if (data !== null && data !== undefined) { - writeDependencies = write(store, operation, data, key).dependencies; - - if (isQuery) { - const queryResult = query(store, operation); - data = queryResult.data; + if (result.data) { + // Write the result to cache and collect all dependencies that need to be + // updated + const writeDependencies = write(store, operation, result.data, key) + .dependencies; + collectPendingOperations(pendingOperations, writeDependencies); + + const queryResult = query(store, operation, result.data); + result.data = queryResult.data; + if (operation.operationName === 'query') { + // Collect the query's dependencies for future pending operation updates queryDependencies = queryResult.dependencies; - } else { - data = query(store, operation, data).data; + collectPendingOperations(pendingOperations, queryDependencies); } } else { noopDataState(store.data, operation.key); } - // Collect all write dependencies and query dependencies for queries - collectPendingOperations(pendingOperations, writeDependencies); - if (isQuery) { - collectPendingOperations(pendingOperations, queryDependencies); - } - // Execute all pending operations related to changed dependencies executePendingOperations(result.operation, pendingOperations); - // Update this operation's dependencies if it's a query - if (isQuery && queryDependencies !== undefined) { + if (queryDependencies) { updateDependencies(result.operation, queryDependencies); } - return { data, error, extensions, operation }; + return { data: result.data, error, extensions, operation }; }; return ops$ => { @@ -310,7 +273,11 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ const inputOps$ = pipe( concat([bufferedOps$, sharedOps$]), - map(addTypeNames), + // Returns the given operation with added __typename fields on its query + map(op => ({ + ...op, + query: formatDocument(op.query), + })), tap(optimisticUpdate), share ); @@ -318,7 +285,11 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ // Filter by operations that are cacheable and attempt to query them from the cache const cache$ = pipe( inputOps$, - filter(op => isCacheableQuery(op)), + filter( + op => + op.operationName === 'query' && + op.context.requestPolicy !== 'network-only' + ), map(operationResultFromCache), share ); @@ -327,7 +298,7 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ const cacheOps$ = pipe( cache$, filter(res => res.outcome === 'miss'), - map(res => addCacheOutcome(res.operation, res.outcome)) + map(res => addCacheOutcome(res.operation, 'miss')) ); // Resolve OperationResults that the cache was able to assemble completely and trigger @@ -338,7 +309,6 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ map( (res: OperationResultWithMeta): OperationResult => { const { operation, outcome } = res; - const policy = getRequestPolicy(operation); const result: OperationResult = { operation: addCacheOutcome(operation, outcome), data: res.data, @@ -347,8 +317,9 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ }; if ( - policy === 'cache-and-network' || - (policy === 'cache-first' && outcome === 'partial') + operation.context.requestPolicy === 'cache-and-network' || + (operation.context.requestPolicy === 'cache-first' && + outcome === 'partial') ) { result.stale = true; client.reexecuteOperation( @@ -367,7 +338,13 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ merge([ pipe( inputOps$, - filter(op => !isCacheableQuery(op)) + filter( + op => + !( + op.operationName === 'query' && + op.context.requestPolicy !== 'network-only' + ) + ) ), cacheOps$, ]), diff --git a/exchanges/graphcache/src/index.ts b/exchanges/graphcache/src/index.ts index 9217895ecd..6dfa3b4171 100644 --- a/exchanges/graphcache/src/index.ts +++ b/exchanges/graphcache/src/index.ts @@ -1,5 +1,5 @@ -export * from './operations'; export * from './types'; -export { Store, initDataState, clearDataState } from './store'; +export { query, write, writeOptimistic } from './operations'; +export { Store, noopDataState, reserveLayer } from './store'; export { cacheExchange } from './cacheExchange'; export { populateExchange } from '@urql/exchange-populate'; diff --git a/exchanges/graphcache/src/operations/invalidate.ts b/exchanges/graphcache/src/operations/invalidate.ts index 83b1acdb4d..6ad3ef52e9 100644 --- a/exchanges/graphcache/src/operations/invalidate.ts +++ b/exchanges/graphcache/src/operations/invalidate.ts @@ -1,94 +1,22 @@ -import { FieldNode } from 'graphql'; -import { getSelectionSet, getName, SelectionSet } from '../ast/node'; - -import { - getMainOperation, - normalizeVariables, - getFragments, - getFieldArguments, -} from '../ast'; - -import { EntityField, OperationRequest, Variables, Fragments } from '../types'; - import * as InMemoryData from '../store/data'; -import { Store, keyOfField } from '../store'; -import { isFieldAvailableOnType } from '../ast'; -import { SelectionIterator } from './shared'; - -interface Context { - store: Store; - variables: Variables; - fragments: Fragments; -} +import { OperationRequest } from '../types'; +import { Store } from '../store'; +import { read } from './query'; export const invalidate = (store: Store, request: OperationRequest) => { - const operation = getMainOperation(request.query); - - const ctx: Context = { - variables: normalizeVariables(operation, request.variables), - fragments: getFragments(request.query), - store, - }; - - invalidateSelection( - ctx, - ctx.store.getRootKey('query'), - getSelectionSet(operation) - ); -}; - -export const invalidateSelection = ( - ctx: Context, - entityKey: string, - select: SelectionSet -) => { - const isQuery = entityKey === 'Query'; - - let typename: EntityField; - if (!isQuery) { - typename = InMemoryData.readRecord(entityKey, '__typename'); - if (typeof typename !== 'string') { - return; + const dependencies = InMemoryData.forkDependencies(); + read(store, request); + + dependencies.forEach(dependency => { + if (dependency.startsWith(`${store.data.queryRootKey}.`)) { + const fieldKey = dependency.slice(`${store.data.queryRootKey}.`.length); + InMemoryData.writeLink(store.data.queryRootKey, fieldKey); + InMemoryData.writeRecord(store.data.queryRootKey, fieldKey); } else { - InMemoryData.writeRecord(entityKey, '__typename', undefined); + store.invalidate(dependency); } - } else { - typename = entityKey; - } + }); - const iter = new SelectionIterator(typename, entityKey, select, ctx); - - let node: FieldNode | void; - while ((node = iter.next()) !== undefined) { - const fieldName = getName(node); - const fieldKey = keyOfField( - fieldName, - getFieldArguments(node, ctx.variables) - ); - - if (process.env.NODE_ENV !== 'production' && ctx.store.schema && typename) { - isFieldAvailableOnType(ctx.store.schema, typename, fieldName); - } - - if (node.selectionSet === undefined) { - InMemoryData.writeRecord(entityKey, fieldKey, undefined); - } else { - const fieldSelect = getSelectionSet(node); - const link = InMemoryData.readLink(entityKey, fieldKey); - - InMemoryData.writeLink(entityKey, fieldKey, undefined); - InMemoryData.writeRecord(entityKey, fieldKey, undefined); - - if (Array.isArray(link)) { - for (let i = 0, l = link.length; i < l; i++) { - const childLink = link[i]; - if (childLink !== null) { - invalidateSelection(ctx, childLink, fieldSelect); - } - } - } else if (link) { - invalidateSelection(ctx, link, fieldSelect); - } - } - } + InMemoryData.unforkDependencies(); + InMemoryData.gc(store.data); }; diff --git a/exchanges/graphcache/src/operations/query.ts b/exchanges/graphcache/src/operations/query.ts index 678242dbbd..f570e68713 100644 --- a/exchanges/graphcache/src/operations/query.ts +++ b/exchanges/graphcache/src/operations/query.ts @@ -1,4 +1,5 @@ import { FieldNode, DocumentNode, FragmentDefinitionNode } from 'graphql'; + import { getSelectionSet, getName, @@ -6,7 +7,6 @@ import { getFragmentTypeName, getFieldAlias, } from '../ast'; -import { warn, pushDebugNode } from '../helpers/help'; import { getFragments, @@ -16,7 +16,6 @@ import { } from '../ast'; import { - Fragments, Variables, Data, DataField, @@ -35,8 +34,16 @@ import { } from '../store'; import * as InMemoryData from '../store/data'; +import { warn, pushDebugNode } from '../helpers/help'; import { makeDict } from '../helpers/dict'; -import { SelectionIterator, ensureData } from './shared'; + +import { + Context, + SelectionIterator, + ensureData, + makeContext, + updateContext, +} from './shared'; import { isFieldAvailableOnType, @@ -50,17 +57,6 @@ export interface QueryResult { data: null | Data; } -interface Context { - parentTypeName: string; - parentKey: string; - parentFieldKey: string; - fieldName: string; - store: Store; - variables: Variables; - fragments: Fragments; - partial: boolean; -} - export const query = ( store: Store, request: OperationRequest, @@ -78,19 +74,16 @@ export const read = ( input?: Data ): QueryResult => { const operation = getMainOperation(request.query); - const rootKey = store.getRootKey(operation.operation); + const rootKey = store.rootFields[operation.operation]; const rootSelect = getSelectionSet(operation); - const ctx: Context = { - parentTypeName: rootKey, - parentKey: rootKey, - parentFieldKey: '', - fieldName: '', + const ctx = makeContext( store, - variables: normalizeVariables(operation, request.variables), - fragments: getFragments(request.query), - partial: false, - }; + normalizeVariables(operation, request.variables), + getFragments(request.query), + rootKey, + rootKey + ); if (process.env.NODE_ENV !== 'production') { pushDebugNode(rootKey, operation); @@ -98,7 +91,7 @@ export const read = ( let data = input || makeDict(); data = - rootKey !== ctx.store.getRootKey('query') + rootKey !== ctx.store.rootFields['query'] ? readRoot(ctx, rootKey, rootSelect, data) : readSelection(ctx, rootKey, rootSelect, data); @@ -209,16 +202,13 @@ export const readFragment = ( pushDebugNode(typename, fragment); } - const ctx: Context = { - parentTypeName: typename, - parentKey: entityKey, - parentFieldKey: '', - fieldName: '', - variables: variables || {}, - fragments, - partial: false, + const ctx = makeContext( store, - }; + variables || {}, + fragments, + typename, + entityKey + ); return ( readSelection(ctx, entityKey, getSelectionSet(fragment), makeDict()) || null @@ -227,21 +217,38 @@ export const readFragment = ( const readSelection = ( ctx: Context, - entityKey: string, + key: string, select: SelectionSet, - data: Data + data: Data, + result?: Data ): Data | undefined => { const { store } = ctx; - const isQuery = entityKey === store.getRootKey('query'); + const isQuery = key === store.rootFields['query']; - // Get the __typename field for a given entity to check that it exists + const entityKey = (result && store.keyOfEntity(result)) || key; const typename = !isQuery - ? InMemoryData.readRecord(entityKey, '__typename') - : entityKey; - if (typeof typename !== 'string') { + ? InMemoryData.readRecord(entityKey, '__typename') || + (result && result.__typename) + : key; + + if ( + typeof typename !== 'string' || + (result && typename !== result.__typename) + ) { + // TODO: This may be an invalid error for resolvers that return interfaces + warn( + 'Invalid resolver data: The resolver at `' + + entityKey + + '` returned an ' + + 'invalid typename that could not be reconciled with the cache.', + 8 + ); + return undefined; } + // The following closely mirrors readSelection, but differs only slightly for the + // sake of resolving from an existing resolver result data.__typename = typename; const iter = new SelectionIterator(typename, entityKey, select, ctx); @@ -254,8 +261,10 @@ const readSelection = ( const fieldArgs = getFieldArguments(node, ctx.variables); const fieldAlias = getFieldAlias(node); const fieldKey = keyOfField(fieldName, fieldArgs); - const fieldValue = InMemoryData.readRecord(entityKey, fieldKey); const key = joinKeys(entityKey, fieldKey); + const fieldValue = InMemoryData.readRecord(entityKey, fieldKey); + const resultValue = result ? result[fieldName] : undefined; + const resolvers = store.resolvers[typename]; if (process.env.NODE_ENV !== 'production' && store.schema && typename) { isFieldAvailableOnType(store.schema, typename, fieldName); @@ -265,14 +274,13 @@ const readSelection = ( // means that the value is missing from the cache let dataFieldValue: void | DataField; - const resolvers = store.resolvers[typename]; - if (resolvers !== undefined && typeof resolvers[fieldName] === 'function') { + if (resultValue !== undefined && node.selectionSet === undefined) { + // The field is a scalar and can be retrieved directly from the result + dataFieldValue = resultValue; + } else if (resolvers && typeof resolvers[fieldName] === 'function') { // We have to update the information in context to reflect the info // that the resolver will receive - ctx.parentTypeName = typename; - ctx.parentKey = entityKey; - ctx.parentFieldKey = key; - ctx.fieldName = fieldName; + updateContext(ctx, typename, entityKey, key, fieldName); // We have a resolver for this field. // Prepare the actual fieldValue, so that the resolver can use it @@ -310,112 +318,6 @@ const readSelection = ( // current field return undefined; } - } else if (node.selectionSet === undefined) { - // The field is a scalar and can be retrieved directly - dataFieldValue = fieldValue; - } else { - // We have a selection set which means that we'll be checking for links - const link = InMemoryData.readLink(entityKey, fieldKey); - if (link !== undefined) { - dataFieldValue = resolveLink( - ctx, - link, - typename, - fieldName, - getSelectionSet(node), - data[fieldAlias] as Data - ); - } else if (typeof fieldValue === 'object' && fieldValue !== null) { - // The entity on the field was invalid but can still be recovered - dataFieldValue = fieldValue; - } - } - - // Now that dataFieldValue has been retrieved it'll be set on data - // If it's uncached (undefined) but nullable we can continue assembling - // a partial query result - if ( - dataFieldValue === undefined && - store.schema && - isFieldNullable(store.schema, typename, fieldName) - ) { - // The field is uncached but we have a schema that says it's nullable - // Set the field to null and continue - hasPartials = true; - data[fieldAlias] = null; - } else if (dataFieldValue === undefined) { - // The field is uncached and not nullable; return undefined - return undefined; - } else { - // Otherwise continue as usual - hasFields = true; - data[fieldAlias] = dataFieldValue; - } - } - - if (hasPartials) ctx.partial = true; - return isQuery && hasPartials && !hasFields ? undefined : data; -}; - -const readResolverResult = ( - ctx: Context, - key: string, - select: SelectionSet, - data: Data, - result: Data -): Data | undefined => { - const { store } = ctx; - const entityKey = store.keyOfEntity(result) || key; - const resolvedTypename = result.__typename; - const typename = - InMemoryData.readRecord(entityKey, '__typename') || resolvedTypename; - - if ( - typeof typename !== 'string' || - (resolvedTypename && typename !== resolvedTypename) - ) { - // TODO: This may be an invalid error for resolvers that return interfaces - warn( - 'Invalid resolver data: The resolver at `' + - entityKey + - '` returned an ' + - 'invalid typename that could not be reconciled with the cache.', - 8 - ); - - return undefined; - } - - // The following closely mirrors readSelection, but differs only slightly for the - // sake of resolving from an existing resolver result - data.__typename = typename; - const iter = new SelectionIterator(typename, entityKey, select, ctx); - - let node: FieldNode | void; - let hasFields = false; - let hasPartials = false; - while ((node = iter.next()) !== undefined) { - // Derive the needed data from our node. - const fieldName = getName(node); - const fieldAlias = getFieldAlias(node); - const fieldKey = keyOfField( - fieldName, - getFieldArguments(node, ctx.variables) - ); - const key = joinKeys(entityKey, fieldKey); - const fieldValue = InMemoryData.readRecord(entityKey, fieldKey); - const resultValue = result[fieldName]; - - if (process.env.NODE_ENV !== 'production' && store.schema && typename) { - isFieldAvailableOnType(store.schema, typename, fieldName); - } - - // We temporarily store the data field in here, but undefined - // means that the value is missing from the cache - let dataFieldValue: void | DataField; - if (resultValue !== undefined && node.selectionSet === undefined) { - // The field is a scalar and can be retrieved directly from the result - dataFieldValue = resultValue; } else if (node.selectionSet === undefined) { // The field is a scalar but isn't on the result, so it's retrieved from the cache dataFieldValue = fieldValue; @@ -454,7 +356,7 @@ const readResolverResult = ( // a partial query result if ( dataFieldValue === undefined && - store.schema !== undefined && + store.schema && isFieldNullable(store.schema, typename, fieldName) ) { // The field is uncached but we have a schema that says it's nullable @@ -472,7 +374,7 @@ const readResolverResult = ( } if (hasPartials) ctx.partial = true; - return !hasFields ? undefined : data; + return isQuery && hasPartials && !hasFields ? undefined : data; }; const resolveResolverResult = ( @@ -518,7 +420,7 @@ const resolveResolverResult = ( const data = prevData === undefined ? makeDict() : prevData; return typeof result === 'string' ? readSelection(ctx, result, select, data) - : readResolverResult(ctx, key, select, data, result); + : readSelection(ctx, key, select, data, result); } else { warn( 'Invalid resolver value: The field at `' + diff --git a/exchanges/graphcache/src/operations/shared.ts b/exchanges/graphcache/src/operations/shared.ts index db278565b9..da7c8f4771 100644 --- a/exchanges/graphcache/src/operations/shared.ts +++ b/exchanges/graphcache/src/operations/shared.ts @@ -16,12 +16,50 @@ import { Fragments, Variables, DataField, NullArray, Data } from '../types'; import { getFieldArguments, shouldInclude, isInterfaceOfType } from '../ast'; -interface Context { +export interface Context { store: Store; variables: Variables; fragments: Fragments; + parentTypeName: string; + parentKey: string; + parentFieldKey: string; + fieldName: string; + partial: boolean; + optimistic: boolean; } +export const makeContext = ( + store: Store, + variables: Variables, + fragments: Fragments, + typename: string, + entityKey: string, + optimistic?: boolean +): Context => ({ + store, + variables, + fragments, + parentTypeName: typename, + parentKey: entityKey, + parentFieldKey: '', + fieldName: '', + partial: false, + optimistic: !!optimistic, +}); + +export const updateContext = ( + ctx: Context, + typename: string, + entityKey: string, + fieldKey: string, + fieldName: string +) => { + ctx.parentTypeName = typename; + ctx.parentKey = entityKey; + ctx.parentFieldKey = fieldKey; + ctx.fieldName = fieldName; +}; + const isFragmentHeuristicallyMatching = ( node: InlineFragmentNode | FragmentDefinitionNode, typename: void | string, diff --git a/exchanges/graphcache/src/operations/write.ts b/exchanges/graphcache/src/operations/write.ts index 5d57ebd68e..ea8afaa8db 100644 --- a/exchanges/graphcache/src/operations/write.ts +++ b/exchanges/graphcache/src/operations/write.ts @@ -15,14 +15,7 @@ import { import { invariant, warn, pushDebugNode } from '../helpers/help'; -import { - NullArray, - Fragments, - Variables, - Data, - Link, - OperationRequest, -} from '../types'; +import { NullArray, Variables, Data, Link, OperationRequest } from '../types'; import { Store, @@ -35,23 +28,18 @@ import { import * as InMemoryData from '../store/data'; import { makeDict } from '../helpers/dict'; -import { SelectionIterator, ensureData } from './shared'; +import { + Context, + SelectionIterator, + ensureData, + makeContext, + updateContext, +} from './shared'; export interface WriteResult { dependencies: Set; } -interface Context { - parentTypeName: string; - parentKey: string; - parentFieldKey: string; - fieldName: string; - store: Store; - variables: Variables; - fragments: Fragments; - optimistic?: boolean; -} - /** Writes a request given its response to the store */ export const write = ( store: Store, @@ -72,29 +60,21 @@ export const startWrite = ( ) => { const operation = getMainOperation(request.query); const result: WriteResult = { dependencies: getCurrentDependencies() }; + const operationName = store.rootFields[operation.operation]; - const select = getSelectionSet(operation); - const operationName = store.getRootKey(operation.operation); - - const ctx: Context = { - parentTypeName: operationName, - parentKey: operationName, - parentFieldKey: '', - fieldName: '', + const ctx = makeContext( store, - variables: normalizeVariables(operation, request.variables), - fragments: getFragments(request.query), - }; + normalizeVariables(operation, request.variables), + getFragments(request.query), + operationName, + operationName + ); if (process.env.NODE_ENV !== 'production') { pushDebugNode(operationName, operation); } - if (operationName === ctx.store.getRootKey('query')) { - writeSelection(ctx, operationName, select, data); - } else { - writeRoot(ctx, operationName, select, data); - } + writeSelection(ctx, operationName, getSelectionSet(operation), data); return result; }; @@ -108,11 +88,10 @@ export const writeOptimistic = ( const operation = getMainOperation(request.query); const result: WriteResult = { dependencies: getCurrentDependencies() }; + const operationName = store.rootFields[operation.operation]; - const mutationRootKey = store.getRootKey('mutation'); - const operationName = store.getRootKey(operation.operation); invariant( - operationName === mutationRootKey, + operationName === store.rootFields['mutation'], 'writeOptimistic(...) was called with an operation that is not a mutation.\n' + 'This case is unsupported and should never occur.', 10 @@ -122,48 +101,17 @@ export const writeOptimistic = ( pushDebugNode(operationName, operation); } - const ctx: Context = { - parentTypeName: mutationRootKey, - parentKey: mutationRootKey, - parentFieldKey: '', - fieldName: '', - variables: normalizeVariables(operation, request.variables), - fragments: getFragments(request.query), + const ctx = makeContext( store, - optimistic: true, - }; - - const data = makeDict(); - const iter = new SelectionIterator( + normalizeVariables(operation, request.variables), + getFragments(request.query), operationName, operationName, - getSelectionSet(operation), - ctx + true ); - let node: FieldNode | void; - while ((node = iter.next()) !== undefined) { - if (node.selectionSet !== undefined) { - const fieldName = getName(node); - const resolver = ctx.store.optimisticMutations[fieldName]; - - if (resolver !== undefined) { - // We have to update the context to reflect up-to-date ResolveInfo - ctx.fieldName = fieldName; - - const fieldArgs = getFieldArguments(node, ctx.variables); - const resolverValue = resolver(fieldArgs || makeDict(), ctx.store, ctx); - const resolverData = ensureData(resolverValue); - writeRootField(ctx, resolverData, getSelectionSet(node)); - data[fieldName] = resolverValue; - const updater = ctx.store.updates[mutationRootKey][fieldName]; - if (updater !== undefined) { - updater(data, fieldArgs || makeDict(), ctx.store, ctx); - } - } - } - } - + const data = makeDict(); + writeSelection(ctx, operationName, getSelectionSet(operation), data); clearDataState(); return result; }; @@ -202,43 +150,48 @@ export const writeFragment = ( pushDebugNode(typename, fragment); } - const ctx: Context = { - parentTypeName: typename, - parentKey: entityKey, - parentFieldKey: '', - fieldName: '', - variables: variables || {}, - fragments, + const ctx = makeContext( store, - }; + variables || {}, + fragments, + typename, + entityKey + ); writeSelection(ctx, entityKey, getSelectionSet(fragment), writeData); }; const writeSelection = ( ctx: Context, - entityKey: string, + entityKey: undefined | string, select: SelectionSet, data: Data ) => { - const isQuery = entityKey === ctx.store.getRootKey('query'); - const typename = isQuery ? entityKey : data.__typename; - if (typeof typename !== 'string') return; - - InMemoryData.writeRecord(entityKey, '__typename', typename); + const isQuery = entityKey === ctx.store.rootFields['query']; + const isRoot = !isQuery && !!ctx.store.rootNames[entityKey!]; + const typename = isRoot || isQuery ? entityKey : data.__typename; + if (!typename) { + return; + } else if (!isRoot && !isQuery && entityKey) { + InMemoryData.writeRecord(entityKey, '__typename', typename); + } - const iter = new SelectionIterator(typename, entityKey, select, ctx); + const iter = new SelectionIterator( + typename, + entityKey || typename, + select, + ctx + ); let node: FieldNode | void; - while ((node = iter.next()) !== undefined) { + while ((node = iter.next())) { const fieldName = getName(node); const fieldArgs = getFieldArguments(node, ctx.variables); const fieldKey = keyOfField(fieldName, fieldArgs); const fieldValue = data[getFieldAlias(node)]; - const key = joinKeys(entityKey, fieldKey); if (process.env.NODE_ENV !== 'production') { - if (fieldValue === undefined) { + if (!isRoot && fieldValue === undefined) { const advice = ctx.optimistic ? '\nYour optimistic result may be missing a field!' : ''; @@ -264,32 +217,72 @@ const writeSelection = ( } } - if (node.selectionSet === undefined) { - // This is a leaf node, so we're setting the field's value directly - InMemoryData.writeRecord(entityKey, fieldKey, fieldValue); - } else { + if (node.selectionSet) { + let fieldData: Data | NullArray | null; + // Process optimistic updates, if this is a `writeOptimistic` operation + // otherwise read the field value from data and write it + if (ctx.optimistic && isRoot) { + const resolver = ctx.store.optimisticMutations[fieldName]; + if (!resolver) continue; + // We have to update the context to reflect up-to-date ResolveInfo + updateContext(ctx, typename, typename, fieldKey, fieldName); + fieldData = ensureData( + resolver(fieldArgs || makeDict(), ctx.store, ctx) + ); + data[fieldName] = fieldData; + } else { + fieldData = ensureData(fieldValue); + } + // Process the field and write links for the child entities that have been written - const fieldData = ensureData(fieldValue); - const link = writeField(ctx, key, getSelectionSet(node), fieldData); - InMemoryData.writeLink(entityKey, fieldKey, link); + if (entityKey && !isRoot) { + const key = joinKeys(entityKey, fieldKey); + const link = writeField(ctx, getSelectionSet(node), fieldData, key); + InMemoryData.writeLink(entityKey || typename, fieldKey, link); + } else { + writeField(ctx, getSelectionSet(node), fieldData); + } + } else if (entityKey && !isRoot) { + // This is a leaf node, so we're setting the field's value directly + InMemoryData.writeRecord(entityKey || typename, fieldKey, fieldValue); + } + + if (isRoot) { + // We have to update the context to reflect up-to-date ResolveInfo + updateContext( + ctx, + typename, + typename, + joinKeys(typename, fieldKey), + fieldName + ); + + // We run side-effect updates after the default, normalized updates + // so that the data is already available in-store if necessary + const updater = ctx.store.updates[typename][fieldName]; + if (updater) { + updater(data, fieldArgs || makeDict(), ctx.store, ctx); + } } } }; const writeField = ( ctx: Context, - parentFieldKey: string, select: SelectionSet, - data: null | Data | NullArray + data: null | Data | NullArray, + parentFieldKey?: string ): Link => { if (Array.isArray(data)) { const newData = new Array(data.length); for (let i = 0, l = data.length; i < l; i++) { const item = data[i]; // Append the current index to the parentFieldKey fallback - const indexKey = joinKeys(parentFieldKey, `${i}`); + const indexKey = parentFieldKey + ? joinKeys(parentFieldKey, `${i}`) + : undefined; // Recursively write array data - const links = writeField(ctx, indexKey, select, item); + const links = writeField(ctx, select, item, indexKey); // Link cannot be expressed as a recursive type newData[i] = links as string | null; } @@ -300,10 +293,10 @@ const writeField = ( } const entityKey = ctx.store.keyOfEntity(data); - const key = entityKey !== null ? entityKey : parentFieldKey; const typename = data.__typename; if ( + parentFieldKey && ctx.store.keys[data.__typename] === undefined && entityKey === null && typeof typename === 'string' && @@ -328,71 +321,7 @@ const writeField = ( ); } - writeSelection(ctx, key, select, data); - return key; -}; - -// This is like writeSelection but assumes no parent entity exists -const writeRoot = ( - ctx: Context, - typename: string, - select: SelectionSet, - data: Data -) => { - const isRootField = - typename === ctx.store.getRootKey('mutation') || - typename === ctx.store.getRootKey('subscription'); - - const iter = new SelectionIterator(typename, typename, select, ctx); - - let node: FieldNode | void; - while ((node = iter.next()) !== undefined) { - const fieldName = getName(node); - const fieldArgs = getFieldArguments(node, ctx.variables); - const fieldKey = joinKeys(typename, keyOfField(fieldName, fieldArgs)); - if (node.selectionSet !== undefined) { - const fieldValue = ensureData(data[getFieldAlias(node)]); - writeRootField(ctx, fieldValue, getSelectionSet(node)); - } - - if (isRootField) { - // We have to update the context to reflect up-to-date ResolveInfo - ctx.parentTypeName = typename; - ctx.parentKey = typename; - ctx.parentFieldKey = fieldKey; - ctx.fieldName = fieldName; - - // We run side-effect updates after the default, normalized updates - // so that the data is already available in-store if necessary - const updater = ctx.store.updates[typename][fieldName]; - if (updater !== undefined) { - updater(data, fieldArgs || makeDict(), ctx.store, ctx); - } - } - } -}; - -// This is like writeField but doesn't fall back to a generated key -const writeRootField = ( - ctx: Context, - data: null | Data | NullArray, - select: SelectionSet -) => { - if (Array.isArray(data)) { - const newData = new Array(data.length); - for (let i = 0, l = data.length; i < l; i++) - newData[i] = writeRootField(ctx, data[i], select); - return newData; - } else if (data === null) { - return; - } - - // Write entity to key that falls back to the given parentFieldKey - const entityKey = ctx.store.keyOfEntity(data); - if (entityKey !== null) { - writeSelection(ctx, entityKey, select, data); - } else { - const typename = data.__typename; - writeRoot(ctx, typename, select, data); - } + const childKey = entityKey || parentFieldKey; + writeSelection(ctx, childKey, select, data); + return childKey || null; }; diff --git a/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap b/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap index 60a8cb57f4..7efbf04637 100644 --- a/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap +++ b/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap @@ -6,7 +6,6 @@ Object { "r|Appointment:1.__typename": "Appointment", "r|Appointment:1.id": "1", "r|Appointment:1.info": "urql meeting", - "r|Query.__typename": "Query", } `; @@ -16,6 +15,5 @@ Object { "r|Appointment:1.__typename": undefined, "r|Appointment:1.id": undefined, "r|Appointment:1.info": undefined, - "r|Query.__typename": "Query", } `; diff --git a/exchanges/graphcache/src/store/data.ts b/exchanges/graphcache/src/store/data.ts index 66644a8a37..3dea12d506 100644 --- a/exchanges/graphcache/src/store/data.ts +++ b/exchanges/graphcache/src/store/data.ts @@ -49,6 +49,7 @@ export interface InMemoryData { let currentData: null | InMemoryData = null; let currentDependencies: null | Set = null; +let previousDependencies: null | Set = null; let currentOptimisticKey: null | number = null; const makeNodeMap = (): NodeMap => ({ @@ -63,6 +64,7 @@ export const initDataState = ( isOptimistic?: boolean ) => { currentData = data; + previousDependencies = currentDependencies; currentDependencies = new Set(); if (process.env.NODE_ENV !== 'production') { currentDebugStack.length = 0; @@ -103,16 +105,11 @@ export const clearDataState = () => { // Determine whether the current operation has been a commutative layer if (layerKey && data.optimisticOrder.indexOf(layerKey) > -1) { - // Find the lowest index of the commutative layers - // The first part of `optimisticOrder` are the non-commutative layers - const commutativeIndex = - data.optimisticOrder.length - data.commutativeKeys.size; - // Squash all layers in reverse order (low priority upwards) that have // been written already let i = data.optimisticOrder.length; while ( - --i >= commutativeIndex && + --i >= 0 && data.refLock[data.optimisticOrder[i]] && data.commutativeKeys.has(data.optimisticOrder[i]) ) { @@ -166,6 +163,16 @@ export const getCurrentDependencies = (): Set => { return currentDependencies; }; +export const forkDependencies = (): Set => { + previousDependencies = currentDependencies; + return (currentDependencies = new Set()); +}; + +export const unforkDependencies = () => { + currentDependencies = previousDependencies; + previousDependencies = null; +}; + export const make = (queryRootKey: string): InMemoryData => ({ persistenceScheduled: false, persistenceBatch: makeDict(), @@ -404,7 +411,7 @@ export const readLink = ( export const writeRecord = ( entityKey: string, fieldKey: string, - value: EntityField + value?: EntityField ) => { updateDependencies(entityKey, fieldKey); setNode(currentData!.records, entityKey, fieldKey, value); @@ -422,7 +429,7 @@ export const hasField = (entityKey: string, fieldKey: string): boolean => export const writeLink = ( entityKey: string, fieldKey: string, - link: Link | undefined + link?: Link | undefined ) => { const data = currentData!; // Retrieve the reference counting dict or the optimistic reference locking dict @@ -449,8 +456,8 @@ export const writeLink = ( } // Retrieve the previous link for this field - const prevLinkNode = links !== undefined ? links.get(entityKey) : undefined; - const prevLink = prevLinkNode !== undefined ? prevLinkNode[fieldKey] : null; + const prevLinkNode = links && links.get(entityKey); + const prevLink = prevLinkNode && prevLinkNode[fieldKey]; // Update dependencies updateDependencies(entityKey, fieldKey); @@ -508,8 +515,7 @@ const deleteLayer = (data: InMemoryData, layerKey: number) => { /** Merges an optimistic layer of links and records into the base data */ const squashLayer = (layerKey: number) => { // Hide current dependencies from squashing operations - const prevDependencies = currentDependencies; - currentDependencies = new Set(); + forkDependencies(); const links = currentData!.links.optimistic[layerKey]; if (links) { @@ -527,7 +533,7 @@ const squashLayer = (layerKey: number) => { }); } - currentDependencies = prevDependencies; + unforkDependencies(); deleteLayer(currentData!, layerKey); }; diff --git a/exchanges/graphcache/src/store/store.ts b/exchanges/graphcache/src/store/store.ts index 94866520dc..f335b6b8b1 100644 --- a/exchanges/graphcache/src/store/store.ts +++ b/exchanges/graphcache/src/store/store.ts @@ -58,59 +58,36 @@ export class Store implements Cache { Subscription: (updates && updates.Subscription) || {}, } as UpdatesConfig; + let queryName = 'Query'; + let mutationName = 'Mutation'; + let subscriptionName = 'Subscription'; if (rawSchema) { const schema = (this.schema = buildClientSchema(rawSchema)); - const queryType = schema.getQueryType(); const mutationType = schema.getMutationType(); const subscriptionType = schema.getSubscriptionType(); - - const queryName = queryType ? queryType.name : 'Query'; - const mutationName = mutationType ? mutationType.name : 'Mutation'; - const subscriptionName = subscriptionType - ? subscriptionType.name - : 'Subscription'; - - this.rootFields = { - query: queryName, - mutation: mutationName, - subscription: subscriptionName, - }; - - this.rootNames = { - [queryName]: 'query', - [mutationName]: 'mutation', - [subscriptionName]: 'subscription', - }; - } else { - this.rootFields = { - query: 'Query', - mutation: 'Mutation', - subscription: 'Subscription', - }; - - this.rootNames = { - Query: 'query', - Mutation: 'mutation', - Subscription: 'subscription', - }; + if (queryType) queryName = queryType.name; + if (mutationType) mutationName = mutationType.name; + if (subscriptionType) subscriptionName = subscriptionType.name; } - this.data = InMemoryData.make(this.getRootKey('query')); - } + this.rootFields = { + query: queryName, + mutation: mutationName, + subscription: subscriptionName, + }; - gcScheduled = false; - gc = () => { - InMemoryData.gc(this.data); - this.gcScheduled = false; - }; + this.rootNames = { + [queryName]: 'query', + [mutationName]: 'mutation', + [subscriptionName]: 'subscription', + }; - keyOfField = keyOfField; - - getRootKey(name: RootField) { - return this.rootFields[name]; + this.data = InMemoryData.make(queryName); } + keyOfField = keyOfField; + keyOfEntity(data: Data) { const { __typename: typename, id, _id } = data; if (!typename) { diff --git a/exchanges/graphcache/src/types.ts b/exchanges/graphcache/src/types.ts index 72e93a1650..e924db5697 100644 --- a/exchanges/graphcache/src/types.ts +++ b/exchanges/graphcache/src/types.ts @@ -90,7 +90,7 @@ export interface Cache { /** inspectFields() retrieves all known fields for a given entity */ inspectFields(entity: Data | string | null): FieldInfo[]; - /** invalidateQuery() invalidates all data of a given query */ + /** @deprecated Use invalidate() instead */ invalidateQuery(query: DocumentNode, variables?: Variables): void; /** invalidate() invalidates an entity */ diff --git a/package.json b/package.json index 0a65ab71d0..1ff9b17079 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "rollup-plugin-generate-package-json": "^3.2.0", "rollup-plugin-terser": "^5.3.0", "rollup-plugin-typescript2": "^0.26.0", + "rollup-plugin-visualizer": "^3.3.1", "terser": "^4.6.6", "ts-jest": "^25.2.1", "typescript": "^3.8.3" diff --git a/scripts/rollup/plugins.js b/scripts/rollup/plugins.js index 22db1216af..1e118ed34f 100644 --- a/scripts/rollup/plugins.js +++ b/scripts/rollup/plugins.js @@ -1,3 +1,5 @@ +import * as path from 'path'; + import commonjs from '@rollup/plugin-commonjs'; import resolve from '@rollup/plugin-node-resolve'; import typescript from 'rollup-plugin-typescript2'; @@ -6,6 +8,7 @@ import buble from '@rollup/plugin-buble'; import replace from '@rollup/plugin-replace'; import babel from 'rollup-plugin-babel'; import compiler from '@ampproject/rollup-plugin-closure-compiler'; +import visualizer from 'rollup-plugin-visualizer'; import { terser } from 'rollup-plugin-terser'; import babelPluginTransformPipe from '../babel/transform-pipe'; @@ -92,7 +95,11 @@ export const makePlugins = ({ isProduction } = {}) => [ formatting: 'PRETTY_PRINT', compilation_level: 'SIMPLE_OPTIMIZATIONS' }), - isProduction ? terserMinified : terserPretty + isProduction ? terserMinified : terserPretty, + isProduction && settings.isAnalyze && visualizer({ + filename: path.resolve(settings.cwd, 'node_modules/.cache/analyze.html'), + sourcemap: true, + }), ].filter(Boolean); const terserPretty = terser({ diff --git a/scripts/rollup/settings.js b/scripts/rollup/settings.js index ad0660d592..8fd5c181c0 100644 --- a/scripts/rollup/settings.js +++ b/scripts/rollup/settings.js @@ -48,3 +48,4 @@ export const hasPreact = externalModules.includes('preact'); export const hasSvelte = externalModules.includes('svelte'); export const mayReexport = hasReact || hasPreact || hasSvelte; export const isCI = !!process.env.CIRCLECI; +export const isAnalyze = !!process.env.ANALYZE; diff --git a/yarn.lock b/yarn.lock index ac98500fd1..5938b93334 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4931,6 +4931,11 @@ es6-symbol@^3.1.1, es6-symbol@~3.1.3: d "^1.0.1" ext "^1.1.2" +escape-goat@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675" + integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q== + escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" @@ -8539,6 +8544,11 @@ nan@^2.12.1: version "2.14.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" +nanoid@^2.1.6: + version "2.1.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280" + integrity sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA== + nanomatch@^1.2.9: version "1.2.13" resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" @@ -8991,6 +9001,13 @@ onetime@^5.1.0: dependencies: mimic-fn "^2.1.0" +open@^6.0.0: + version "6.4.0" + resolved "https://registry.yarnpkg.com/open/-/open-6.4.0.tgz#5c13e96d0dc894686164f18965ecfe889ecfc8a9" + integrity sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg== + dependencies: + is-wsl "^1.1.0" + opencollective-postinstall@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/opencollective-postinstall/-/opencollective-postinstall-2.0.2.tgz#5657f1bede69b6e33a45939b061eb53d3c6c3a89" @@ -10309,6 +10326,13 @@ punycode@^2.1.0, punycode@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" +pupa@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.0.1.tgz#dbdc9ff48ffbea4a26a069b6f9f7abb051008726" + integrity sha512-hEJH0s8PXLY/cdXh66tNEQGndDrIKNqNC5xmrysZy3i5C3oEoLna7YAOad+7u125+zH1HNXUmGEkrhb3c2VriA== + dependencies: + escape-goat "^2.0.0" + q@^1.1.2: version "1.5.1" resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" @@ -11251,6 +11275,18 @@ rollup-plugin-typescript2@^0.26.0: rollup-pluginutils "2.8.2" tslib "1.10.0" +rollup-plugin-visualizer@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/rollup-plugin-visualizer/-/rollup-plugin-visualizer-3.3.1.tgz#fac77899559098d28b1b02d90243e683847f6994" + integrity sha512-ElVm75615Qr7fGLnlyYmdejffK8neMkbLMEUHfTYW04RhbiH8brgO14jBKiZBCykOBSnPQ7EvNHGBGF/CT/QRg== + dependencies: + mkdirp "^0.5.1" + nanoid "^2.1.6" + open "^6.0.0" + pupa "^2.0.0" + source-map "^0.7.3" + yargs "^15.0.0" + rollup-pluginutils@2.8.2, rollup-pluginutils@^2.8.1, rollup-pluginutils@^2.8.2: version "2.8.2" resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz#72f2af0748b592364dbd3389e600e5a9444a351e"