Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

(graphcache) - Handle fields with associated GraphQLError as cache misses and provide errors to updaters (#1356)

* Add Context.path tracking the currently field alias path

* Remove active ctx.path tracking from query in production

It's not needed to track current errors from results there,
so it can alternatively be fully removed.

* Add note to ResolveInfo type on why path isn't exposed via types

* Provide the current field's GraphQLError on an updater's context

* Update docs to add information about the error field

* Overwrite null values as undefined when field has an associated error

When a field has an associated GraphQLError then the `fieldValue` should
be written as `undefined` rather than `null`.

* Replace isFieldMissing with getFieldError

* Isolate path/errorMap on context in context.__internal

This is to further hide the implementation from the user and
to ensure that sub-write methods like `updateQuery` or `writeFragment`
on the Store don't interfere with this logic as they'd build up their
own paths.

* Fix leftover global errorMap in shared.ts

* Add tests for errored fields set to undefined rather than null

* Add changeset

* Move updateContext in write.ts' updater call

* Deduplicate writeOptimistic logic with startWrite

authored by

Phil Pluckthun and committed by
GitHub
6a687091 77fe7725

+261 -100
+5
.changeset/smart-emus-jam.md
··· 1 + --- 2 + '@urql/exchange-graphcache': major 3 + --- 4 + 5 + Add improved error awareness to Graphcache. When Graphcache now receives a `GraphQLError` (via a `CombinedError`) it checks whether the `GraphQLError`'s `path` matches up with `null` values in the `data`. Any `null` values that the write operation now sees in the data will be replaced with a "cache miss" value (i.e. `undefined`) when it has an associated error. This means that errored fields from your GraphQL API will be marked as uncached and won't be cached. Instead the client will now attempt a refetch of the data so that errors aren't preventing future refetches or with schema awareness it will attempt a refetch automatically. Additionally, the `updates` functions will now be able to check whether the current field has any errors associated with it with `info.error`.
+19 -11
docs/api/graphcache.md
··· 119 119 | `cache` | `Cache` | The cache using which data can be read or written. [See `Cache`.](#cache) | 120 120 | `info` | `Info` | Additional metadata and information about the current operation and the current field. [See `Info`.](#info) | 121 121 122 + It's possible to derive more information about the current update using the `info` argument. For 123 + instance this metadata contains the current `fieldName` of the updater which may be used to make an 124 + updater function more reusable, along with `parentKey` and other key fields. It also contains 125 + `variables` and `fragments` which remain the same for the entire write operation, and additionally 126 + it may have the `error` field set to describe whether the current field is `null` because the API 127 + encountered a `GraphQLError`. 128 + 122 129 [Read more about how to set up `updates` on the "Custom Updates" 123 130 page.](../graphcache/custom-updates.md) 124 131 ··· 463 470 information about the current GraphQL document and query, and also some information on the current 464 471 field that a given resolver or updater is called on. 465 472 466 - | Argument | Type | Description | 467 - | ---------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | 468 - | `parent` | `Data` | The field's parent entity's data, as it was written or read up until now, which means it may be incomplete. [Use `cache.resolve`](#resolve) to read from it. | 469 - | `parentTypeName` | `string` | The field's parent entity's typename | 470 - | `parentKey` | `string` | The field's parent entity's cache key (if any) | 471 - | `parentFieldKey` | `string` | The current key's cache key, which is the parent entity's key combined with the current field's key (This is mostly obsolete) | 472 - | `fieldName` | `string` | The current field's name | 473 - | `fragments` | `{ [name: string]: FragmentDefinitionNode }` | A dictionary of fragments from the current GraphQL document | 474 - | `variables` | `object` | The current GraphQL operation's variables (may be an empty object) | 475 - | `partial` | `?boolean` | This may be set to `true` at any point in time (by your custom resolver or by _Graphcache_) to indicate that some data is uncached and missing | 476 - | `optimistic` | `?boolean` | This is only `true` when an optimistic mutation update is running | 473 + | Argument | Type | Description | 474 + | ---------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 475 + | `parent` | `Data` | The field's parent entity's data, as it was written or read up until now, which means it may be incomplete. [Use `cache.resolve`](#resolve) to read from it. | 476 + | `parentTypeName` | `string` | The field's parent entity's typename | 477 + | `parentKey` | `string` | The field's parent entity's cache key (if any) | 478 + | `parentFieldKey` | `string` | The current key's cache key, which is the parent entity's key combined with the current field's key (This is mostly obsolete) | 479 + | `fieldName` | `string` | The current field's name | 480 + | `fragments` | `{ [name: string]: FragmentDefinitionNode }` | A dictionary of fragments from the current GraphQL document | 481 + | `variables` | `object` | The current GraphQL operation's variables (may be an empty object) | 482 + | `error` | `GraphQLError \| undefined` | The current GraphQLError for a given field. This will always be `undefined` for resolvers and optimistic updaters, but may be present for updaters when the API has returned an error for a given field. | 483 + | `partial` | `?boolean` | This may be set to `true` at any point in time (by your custom resolver or by _Graphcache_) to indicate that some data is uncached and missing | 484 + | `optimistic` | `?boolean` | This is only `true` when an optimistic mutation update is running | 477 485 478 486 > **Note:** Using `info` is regarded as a last resort. Please only use information from it if 479 487 > there's no other solution to get to the metadata you need. We don't regard the `Info` API as
+7 -2
exchanges/graphcache/src/cacheExchange.ts
··· 225 225 if (result.data) { 226 226 // Write the result to cache and collect all dependencies that need to be 227 227 // updated 228 - const writeDependencies = write(store, operation, result.data, key) 229 - .dependencies; 228 + const writeDependencies = write( 229 + store, 230 + operation, 231 + result.data, 232 + result.error, 233 + key 234 + ).dependencies; 230 235 collectPendingOperations(pendingOperations, writeDependencies); 231 236 232 237 const queryResult = query(store, operation, result.data, key);
+37 -6
exchanges/graphcache/src/operations/query.ts
··· 129 129 while ((node = iterate())) { 130 130 const fieldAlias = getFieldAlias(node); 131 131 const fieldValue = originalData[fieldAlias]; 132 + // Add the current alias to the walked path before processing the field's value 133 + if (process.env.NODE_ENV !== 'production') 134 + ctx.__internal.path.push(fieldAlias); 135 + // Process the root field's value 132 136 if (node.selectionSet && fieldValue !== null) { 133 137 const fieldData = ensureData(fieldValue); 134 138 data[fieldAlias] = readRootField(ctx, getSelectionSet(node), fieldData); 135 139 } else { 136 140 data[fieldAlias] = fieldValue; 137 141 } 142 + // After processing the field, remove the current alias from the path again 143 + if (process.env.NODE_ENV !== 'production') ctx.__internal.path.pop(); 138 144 } 139 145 140 146 return data; ··· 147 153 ): Data | NullArray<Data> | null => { 148 154 if (Array.isArray(originalData)) { 149 155 const newData = new Array(originalData.length); 150 - for (let i = 0, l = originalData.length; i < l; i++) 156 + for (let i = 0, l = originalData.length; i < l; i++) { 157 + // Add the current index to the walked path before reading the field's value 158 + if (process.env.NODE_ENV !== 'production') ctx.__internal.path.push(i); 159 + // Recursively read the root field's value 151 160 newData[i] = readRootField(ctx, select, originalData[i]); 161 + // After processing the field, remove the current index from the path 162 + if (process.env.NODE_ENV !== 'production') ctx.__internal.path.pop(); 163 + } 164 + 152 165 return newData; 153 166 } else if (originalData === null) { 154 167 return null; ··· 289 302 isFieldAvailableOnType(store.schema, typename, fieldName); 290 303 } 291 304 305 + // We directly assign typenames and skip the field afterwards 306 + if (fieldName === '__typename') { 307 + data[fieldAlias] = typename; 308 + continue; 309 + } 310 + 292 311 // We temporarily store the data field in here, but undefined 293 312 // means that the value is missing from the cache 294 313 let dataFieldValue: void | DataField; 314 + // Add the current alias to the walked path before processing the field's value 315 + if (process.env.NODE_ENV !== 'production') 316 + ctx.__internal.path.push(fieldAlias); 295 317 296 - if (fieldName === '__typename') { 297 - data[fieldAlias] = typename; 298 - continue; 299 - } else if (resultValue !== undefined && node.selectionSet === undefined) { 318 + if (resultValue !== undefined && node.selectionSet === undefined) { 300 319 // The field is a scalar and can be retrieved directly from the result 301 320 dataFieldValue = resultValue; 302 321 } else if ( ··· 377 396 } 378 397 } 379 398 399 + // After processing the field, remove the current alias from the path again 400 + if (process.env.NODE_ENV !== 'production') ctx.__internal.path.pop(); 380 401 // Now that dataFieldValue has been retrieved it'll be set on data 381 402 // If it's uncached (undefined) but nullable we can continue assembling 382 403 // a partial query result ··· 420 441 !store.schema || isListNullable(store.schema, typename, fieldName); 421 442 const data = new Array(result.length); 422 443 for (let i = 0, l = result.length; i < l; i++) { 444 + // Add the current index to the walked path before reading the field's value 445 + if (process.env.NODE_ENV !== 'production') ctx.__internal.path.push(i); 423 446 // Recursively read resolver result 424 447 const childResult = resolveResolverResult( 425 448 ctx, ··· 431 454 prevData != null ? prevData[i] : undefined, 432 455 result[i] 433 456 ); 434 - 457 + // After processing the field, remove the current index from the path 458 + if (process.env.NODE_ENV !== 'production') ctx.__internal.path.pop(); 459 + // Check the result for cache-missed values 435 460 if (childResult === undefined && !_isListNullable) { 436 461 return undefined; 437 462 } else { ··· 478 503 store.schema && isListNullable(store.schema, typename, fieldName); 479 504 const newLink = new Array(link.length); 480 505 for (let i = 0, l = link.length; i < l; i++) { 506 + // Add the current index to the walked path before reading the field's value 507 + if (process.env.NODE_ENV !== 'production') ctx.__internal.path.push(i); 508 + // Recursively read the link 481 509 const childLink = resolveLink( 482 510 ctx, 483 511 link[i], ··· 486 514 select, 487 515 prevData != null ? prevData[i] : undefined 488 516 ); 517 + // After processing the field, remove the current index from the path 518 + if (process.env.NODE_ENV !== 'production') ctx.__internal.path.pop(); 519 + // Check the result for cache-missed values 489 520 if (childLink === undefined && !_isListNullable) { 490 521 return undefined; 491 522 } else {
+53 -14
exchanges/graphcache/src/operations/shared.ts
··· 1 - import { FieldNode, InlineFragmentNode, FragmentDefinitionNode } from 'graphql'; 1 + import { CombinedError } from '@urql/core'; 2 + import { 3 + GraphQLError, 4 + FieldNode, 5 + InlineFragmentNode, 6 + FragmentDefinitionNode, 7 + } from 'graphql'; 2 8 3 9 import { 4 10 isInlineFragment, ··· 24 30 parentFieldKey: string; 25 31 parent: Data; 26 32 fieldName: string; 33 + error: GraphQLError | undefined; 27 34 partial: boolean; 28 35 optimistic: boolean; 36 + __internal: { 37 + path: Array<string | number>; 38 + errorMap: { [path: string]: GraphQLError } | undefined; 39 + }; 29 40 } 30 41 31 42 export const contextRef: { current: Context | null } = { current: null }; 32 43 44 + // Checks whether the current data field is a cache miss because of a GraphQLError 45 + export const getFieldError = (ctx: Context): GraphQLError | undefined => 46 + ctx.__internal.path.length > 0 && ctx.__internal.errorMap 47 + ? ctx.__internal.errorMap[ctx.__internal.path.join('.')] 48 + : undefined; 49 + 33 50 export const makeContext = ( 34 51 store: Store, 35 52 variables: Variables, 36 53 fragments: Fragments, 37 54 typename: string, 38 55 entityKey: string, 39 - optimistic?: boolean 40 - ): Context => ({ 41 - store, 42 - variables, 43 - fragments, 44 - parent: { __typename: typename }, 45 - parentTypeName: typename, 46 - parentKey: entityKey, 47 - parentFieldKey: '', 48 - fieldName: '', 49 - partial: false, 50 - optimistic: !!optimistic, 51 - }); 56 + optimistic?: boolean, 57 + error?: CombinedError | undefined 58 + ): Context => { 59 + const ctx: Context = { 60 + store, 61 + variables, 62 + fragments, 63 + parent: { __typename: typename }, 64 + parentTypeName: typename, 65 + parentKey: entityKey, 66 + parentFieldKey: '', 67 + fieldName: '', 68 + error: undefined, 69 + partial: false, 70 + optimistic: !!optimistic, 71 + __internal: { 72 + path: [], 73 + errorMap: undefined, 74 + }, 75 + }; 76 + 77 + if (error && error.graphQLErrors) { 78 + for (let i = 0; i < error.graphQLErrors.length; i++) { 79 + const graphQLError = error.graphQLErrors[i]; 80 + if (graphQLError.path && graphQLError.path.length) { 81 + if (!ctx.__internal.errorMap) 82 + ctx.__internal.errorMap = Object.create(null); 83 + ctx.__internal.errorMap![graphQLError.path.join('.')] = graphQLError; 84 + } 85 + } 86 + } 87 + 88 + return ctx; 89 + }; 52 90 53 91 export const updateContext = ( 54 92 ctx: Context, ··· 64 102 ctx.parentKey = entityKey; 65 103 ctx.parentFieldKey = fieldKey; 66 104 ctx.fieldName = fieldName; 105 + ctx.error = getFieldError(ctx); 67 106 }; 68 107 69 108 const isFragmentHeuristicallyMatching = (
+74 -1
exchanges/graphcache/src/operations/write.test.ts
··· 1 1 /* eslint-disable @typescript-eslint/no-var-requires */ 2 2 3 - import { gql } from '@urql/core'; 3 + import { gql, CombinedError } from '@urql/core'; 4 4 import { minifyIntrospectionQuery } from '@urql/introspection'; 5 5 6 6 import { write } from './write'; ··· 173 173 InMemoryData.initDataState('read', store.data, null); 174 174 // The field must still be `'test'` 175 175 expect(InMemoryData.readRecord('Query', 'field')).toBe('test'); 176 + }); 177 + 178 + it('should write errored records as undefined rather than null', () => { 179 + const query = gql` 180 + { 181 + missingField 182 + setField 183 + } 184 + `; 185 + 186 + write( 187 + store, 188 + { query }, 189 + { missingField: null, setField: 'test' } as any, 190 + new CombinedError({ 191 + graphQLErrors: [ 192 + { 193 + message: 'Test', 194 + path: ['missingField'], 195 + }, 196 + ], 197 + }) 198 + ); 199 + 200 + InMemoryData.initDataState('read', store.data, null); 201 + 202 + // The setField must still be `'test'` 203 + expect(InMemoryData.readRecord('Query', 'setField')).toBe('test'); 204 + // The missingField must still be `undefined` 205 + expect(InMemoryData.readRecord('Query', 'missingField')).toBe(undefined); 206 + }); 207 + 208 + it('should write errored links as undefined rather than null', () => { 209 + const query = gql` 210 + { 211 + missingTodoItem: todos { 212 + id 213 + text 214 + } 215 + missingTodo: todo { 216 + id 217 + text 218 + } 219 + } 220 + `; 221 + 222 + write( 223 + store, 224 + { query }, 225 + { 226 + missingTodoItem: [null, { __typename: 'Todo', id: 1, text: 'Learn' }], 227 + missingTodo: null, 228 + } as any, 229 + new CombinedError({ 230 + graphQLErrors: [ 231 + { 232 + message: 'Test', 233 + path: ['missingTodoItem', 0], 234 + }, 235 + { 236 + message: 'Test', 237 + path: ['missingTodo'], 238 + }, 239 + ], 240 + }) 241 + ); 242 + 243 + InMemoryData.initDataState('read', store.data, null); 244 + expect(InMemoryData.readLink('Query', 'todos')).toEqual([ 245 + undefined, 246 + 'Todo:1', 247 + ]); 248 + expect(InMemoryData.readLink('Query', 'todo')).toEqual(undefined); 176 249 }); 177 250 });
+63 -65
exchanges/graphcache/src/operations/write.ts
··· 1 1 import { FieldNode, DocumentNode, FragmentDefinitionNode } from 'graphql'; 2 + import { CombinedError } from '@urql/core'; 2 3 3 4 import { 4 5 getFragments, ··· 35 36 } from '../store'; 36 37 37 38 import * as InMemoryData from '../store/data'; 39 + 38 40 import { 39 41 Context, 40 42 makeSelectionIterator, 41 43 ensureData, 42 44 makeContext, 43 45 updateContext, 46 + getFieldError, 44 47 } from './shared'; 45 48 46 49 export interface WriteResult { ··· 53 56 store: Store, 54 57 request: OperationRequest, 55 58 data: Data, 59 + error?: CombinedError | undefined, 56 60 key?: number 57 61 ): WriteResult => { 58 62 initDataState('write', store.data, key || null); 59 - const result = startWrite(store, request, data); 63 + const result = startWrite(store, request, data, error); 60 64 clearDataState(); 61 65 return result; 62 66 }; 63 67 64 - export const startWrite = ( 68 + export const writeOptimistic = ( 65 69 store: Store, 66 70 request: OperationRequest, 67 - data: Data 68 - ) => { 69 - const operation = getMainOperation(request.query); 70 - const result: WriteResult = { data, dependencies: getCurrentDependencies() }; 71 - const operationName = store.rootFields[operation.operation]; 72 - 73 - const ctx = makeContext( 74 - store, 75 - normalizeVariables(operation, request.variables), 76 - getFragments(request.query), 77 - operationName, 78 - operationName 79 - ); 80 - 81 - if (process.env.NODE_ENV !== 'production') { 82 - pushDebugNode(operationName, operation); 83 - } 84 - 85 - writeSelection(ctx, operationName, getSelectionSet(operation), data); 86 - 71 + key: number 72 + ): WriteResult => { 87 73 if (process.env.NODE_ENV !== 'production') { 88 - popDebugNode(); 74 + invariant( 75 + getMainOperation(request.query).operation === 'mutation', 76 + 'writeOptimistic(...) was called with an operation that is not a mutation.\n' + 77 + 'This case is unsupported and should never occur.', 78 + 10 79 + ); 89 80 } 90 81 82 + initDataState('write', store.data, key, true); 83 + const result = startWrite(store, request, {} as Data, undefined, true); 84 + clearDataState(); 91 85 return result; 92 86 }; 93 87 94 - export const writeOptimistic = ( 88 + export const startWrite = ( 95 89 store: Store, 96 90 request: OperationRequest, 97 - key: number 98 - ): WriteResult => { 99 - initDataState('write', store.data, key, true); 100 - 91 + data: Data, 92 + error?: CombinedError | undefined, 93 + isOptimistic?: boolean 94 + ) => { 101 95 const operation = getMainOperation(request.query); 102 - const result: WriteResult = { 103 - data: {} as Data, 104 - dependencies: getCurrentDependencies(), 105 - }; 96 + const result: WriteResult = { data, dependencies: getCurrentDependencies() }; 106 97 const operationName = store.rootFields[operation.operation]; 107 98 108 - invariant( 109 - operationName === store.rootFields['mutation'], 110 - 'writeOptimistic(...) was called with an operation that is not a mutation.\n' + 111 - 'This case is unsupported and should never occur.', 112 - 10 113 - ); 114 - 115 - if (process.env.NODE_ENV !== 'production') { 116 - pushDebugNode(operationName, operation); 117 - } 118 - 119 99 const ctx = makeContext( 120 100 store, 121 101 normalizeVariables(operation, request.variables), 122 102 getFragments(request.query), 123 103 operationName, 124 104 operationName, 125 - true 105 + !!isOptimistic, 106 + error 126 107 ); 127 108 128 - writeSelection(ctx, operationName, getSelectionSet(operation), result.data!); 109 + if (process.env.NODE_ENV !== 'production') { 110 + pushDebugNode(operationName, operation); 111 + } 112 + 113 + writeSelection(ctx, operationName, getSelectionSet(operation), data); 129 114 130 115 if (process.env.NODE_ENV !== 'production') { 131 116 popDebugNode(); 132 117 } 133 118 134 - clearDataState(); 135 119 return result; 136 120 }; 137 121 ··· 174 158 variables || {}, 175 159 fragments, 176 160 typename, 177 - entityKey 161 + entityKey, 162 + undefined 178 163 ); 179 164 180 165 writeSelection(ctx, entityKey, getSelectionSet(fragment), dataToWrite); ··· 246 231 } 247 232 } 248 233 249 - if (fieldName === '__typename') { 250 - continue; 251 - } else if (ctx.optimistic && isRoot) { 234 + // We simply skip all typenames fields and assume they've already been written above 235 + if (fieldName === '__typename') continue; 236 + 237 + // Add the current alias to the walked path before processing the field's value 238 + ctx.__internal.path.push(fieldAlias); 239 + 240 + // Execute optimistic mutation functions on root fields 241 + if (ctx.optimistic && isRoot) { 252 242 const resolver = ctx.store.optimisticMutations[fieldName]; 253 243 254 244 if (!resolver) continue; ··· 278 268 InMemoryData.writeRecord( 279 269 entityKey || typename, 280 270 fieldKey, 281 - fieldValue as EntityField 271 + (fieldValue !== null || !getFieldError(ctx) 272 + ? fieldValue 273 + : undefined) as EntityField 282 274 ); 283 275 } 284 276 285 277 if (isRoot) { 286 - // We have to update the context to reflect up-to-date ResolveInfo 287 - updateContext( 288 - ctx, 289 - data, 290 - typename, 291 - typename, 292 - joinKeys(typename, fieldKey), 293 - fieldName 294 - ); 295 - 296 278 // We run side-effect updates after the default, normalized updates 297 279 // so that the data is already available in-store if necessary 298 280 const updater = ctx.store.updates[typename][fieldName]; 299 281 if (updater) { 282 + // We have to update the context to reflect up-to-date ResolveInfo 283 + updateContext( 284 + ctx, 285 + data, 286 + typename, 287 + typename, 288 + joinKeys(typename, fieldKey), 289 + fieldName 290 + ); 291 + 300 292 data[fieldName] = fieldValue; 301 293 updater(data, fieldArgs || {}, ctx.store, ctx); 302 294 } 303 295 } 296 + 297 + // After processing the field, remove the current alias from the path again 298 + ctx.__internal.path.pop(); 304 299 } 305 300 }; 306 301 ··· 312 307 select: SelectionSet, 313 308 data: null | Data | NullArray<Data>, 314 309 parentFieldKey?: string 315 - ): Link => { 310 + ): Link | undefined => { 316 311 if (Array.isArray(data)) { 317 312 const newData = new Array(data.length); 318 313 for (let i = 0, l = data.length; i < l; i++) { 319 - const item = data[i]; 314 + // Add the current index to the walked path before processing the link 315 + ctx.__internal.path.push(i); 320 316 // Append the current index to the parentFieldKey fallback 321 317 const indexKey = parentFieldKey 322 318 ? joinKeys(parentFieldKey, `${i}`) 323 319 : undefined; 324 320 // Recursively write array data 325 - const links = writeField(ctx, select, item, indexKey); 321 + const links = writeField(ctx, select, data[i], indexKey); 326 322 // Link cannot be expressed as a recursive type 327 323 newData[i] = links as string | null; 324 + // After processing the field, remove the current index from the path 325 + ctx.__internal.path.pop(); 328 326 } 329 327 330 328 return newData; 331 329 } else if (data === null) { 332 - return null; 330 + return getFieldError(ctx) ? undefined : null; 333 331 } 334 332 335 333 const entityKey = ctx.store.keyOfEntity(data);
+3 -1
exchanges/graphcache/src/types.ts
··· 1 1 import { TypedDocumentNode } from '@urql/core'; 2 - import { DocumentNode, FragmentDefinitionNode } from 'graphql'; 2 + import { GraphQLError, DocumentNode, FragmentDefinitionNode } from 'graphql'; 3 3 4 4 // Helper types 5 5 export type NullArray<T> = Array<null | T>; ··· 65 65 fieldName: string; 66 66 fragments: Fragments; 67 67 variables: Variables; 68 + error: GraphQLError | undefined; 68 69 partial?: boolean; 69 70 optimistic?: boolean; 71 + __internal?: unknown; 70 72 } 71 73 72 74 export interface QueryInput<T = Data, V = Variables> {