Repository: urql-graphql/urql
Pull request #3502 (2024-02-04 07:57:06)

feat(graphcache): allow for defining inline-fragment/fragment-definition client controlled nullability directives
## Summary

This allows for defining `@_optional` and `@_required` on both a fragment-definition as well as an inline-fragment. This to facilitate patterns like a `refetchable-fragment` where you can refetch parts of a document.

Example

```js
query {
  todo {
    id
    text
    author { id name }
    ... on Todo @_optional { seenBy(first: 10, after: $after) }
    
  }
}
```

Now we can paginate over `seenBy` without losing the data surrounding it, this is good for composing fragments from i.e. a list with the parent.


changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: fbe5fe9cc82d7869498ee31d9b38ba5190e63733

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name                      | Type  |
| ------------------------- | ----- |
| @urql/exchange-graphcache | Minor |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/urql-graphql/urql/new/optional-coming-from-fragments?filename=.changeset/six-spies-hunt.md&value=---%0A%22%40urql%2Fexchange-graphcache%22%3A%20patch%0A---%0A%0Afeat(graphcache)%3A%20allow%20for%20defining%20inline-fragment%2Ffragment-definition%20client%20controlled%20nullability%20directives%0A)



Patch:
diff --git a/.changeset/fluffy-poets-protect.md b/.changeset/fluffy-poets-protect.md
new file mode 100644
index 00000000..ea874ade
--- /dev/null
+++ b/.changeset/fluffy-poets-protect.md
@@ -0,0 +1,5 @@
+---
+'@urql/core': patch
+---
+
+Correctly support the `Headers` class being used in `fetchOptions`
diff --git a/.changeset/happy-peas-sin.md b/.changeset/happy-peas-sin.md
new file mode 100644
index 00000000..230936f4
--- /dev/null
+++ b/.changeset/happy-peas-sin.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-graphcache': minor
+---
+
+Allow `@_optional` and `@_required` to be placed on fragment definitions and inline fragments
diff --git a/.changeset/small-cheetahs-jam.md b/.changeset/small-cheetahs-jam.md
new file mode 100644
index 00000000..3409df63
--- /dev/null
+++ b/.changeset/small-cheetahs-jam.md
@@ -0,0 +1,5 @@
+---
+'@urql/core': minor
+---
+
+Support [Apollo Federation's format](https://www.apollographql.com/docs/router/executing-operations/subscription-multipart-protocol/) for subscription results in `multipart/mixed` responses (result properties essentially are namespaced on a `payload` key)
diff --git a/.changeset/tall-buttons-fetch.md b/.changeset/tall-buttons-fetch.md
new file mode 100644
index 00000000..2946d9be
--- /dev/null
+++ b/.changeset/tall-buttons-fetch.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-graphcache': minor
+---
+
+Track list of entity keys for a given type name. This enables enumerating and invalidating all entities of a given type within the normalized cache.
diff --git a/.changeset/two-bananas-grow.md b/.changeset/two-bananas-grow.md
new file mode 100644
index 00000000..3cf5b516
--- /dev/null
+++ b/.changeset/two-bananas-grow.md
@@ -0,0 +1,5 @@
+---
+'@urql/core': minor
+---
+
+Add support for sending persisted documents. Any `DocumentNode` with no/empty definitions and a `documentId` property is considered a persisted document. When this is detected a `documentId` parameter rather than a `query` string is sent to the GraphQL API, similar to Automatic Persisted Queries (APQs). However, APQs are only supported via `@urql/exchange-persisted`, while support for `documentId` is now built-in.
diff --git a/.changeset/wicked-kids-joke.md b/.changeset/wicked-kids-joke.md
new file mode 100644
index 00000000..ab01de50
--- /dev/null
+++ b/.changeset/wicked-kids-joke.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-graphcache': patch
+---
+
+Fix `store.resolve()` returning the exact link array that’s used by the cache. This can lead to subtle bugs when a user mutates the result returned by `cache.resolve()`, since this directly mutates what’s in the cache at that layer.
diff --git a/docs/architecture.md b/docs/architecture.md
index c41fa497..b90d6941 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -35,7 +35,7 @@ Specifically, `urql` simplifies three common aspects of using GraphQL:
 - Abstracting _caching_ and state management internally
 - Providing a central point of _extensibility_ and integration with your API
 
-In the following sections we'll talk about the way that `urql` solves these three problems and how the logic abstracted away internally.
+In the following sections we'll talk about the way that `urql` solves these three problems and how the logic is abstracted away internally.
 
 ## Requests and Operations on the Client
 
diff --git a/docs/graphcache/cache-updates.md b/docs/graphcache/cache-updates.md
index 16642f98..174bb4cf 100644
--- a/docs/graphcache/cache-updates.md
+++ b/docs/graphcache/cache-updates.md
@@ -260,8 +260,10 @@ cacheExchange({
         `;
 
         cache.updateQuery({ query: TodoList }, data => {
-          data.todos.push(result.createTodo);
-          return data;
+          return {
+            ...data,
+            todos: [...data.todos, result.createTodo],
+          };
         });
       },
     },
@@ -299,8 +301,7 @@ cacheExchange({
       createTodo(result, _args, cache, _info) {
         const todos = cache.resolve('Query', 'todos');
         if (Array.isArray(todos)) {
-          todos.push(result.createTodo);
-          cache.link('Query', 'todos', todos);
+          cache.link('Query', 'todos', [...todos, result.createTodo]);
         }
       },
     },
@@ -489,12 +490,31 @@ In this example we've attached an updater to a `Mutation.updateTodo` field. We r
 mutation by enumerating all `todos` listing fields using `cache.inspectFields` and targetedly
 invalidate only these fields, which causes all queries using these listing fields to be refetched.
 
+### Invalidating a type
+
+We can also invalidate all the entities of a given type, this could be handy in the case of a
+list update or when you aren't sure what entity is affected.
+
+This can be done by only passing the relevant `__typename` to the `invalidate` function.
+
+```js
+cacheExchange({
+  updates: {
+    Mutation: {
+      deleteTodo(_result, args, cache, _info) {
+        cache.invalidate('Todo');
+      },
+    },
+  },
+});
+```
+
 ## Optimistic updates
 
 If we know what result a mutation may return, why wait for the GraphQL API to fulfill our mutations?
 
-Additionally to the `updates` configuration we may also pass an `optimistic` option to the
-`cacheExchange` which is a factory function using, which we can create a "virtual" result for a
+In addition to the `updates` configuration, we can also pass an `optimistic` option to the
+`cacheExchange`. This option is a factory function that allows us to create a "virtual" result for a
 mutation. This temporary result can be applied immediately to the cache to give our users the
 illusion that mutations were executed immediately, which is a great method to reduce waiting time
 and to make our apps feel snappier.
diff --git a/examples/with-graphcache-updates/src/client.js b/examples/with-graphcache-updates/src/client.js
index ea5cf6ee..152a707f 100644
--- a/examples/with-graphcache-updates/src/client.js
+++ b/examples/with-graphcache-updates/src/client.js
@@ -29,8 +29,13 @@ const cache = cacheExchange({
               variables: { first: lastField.arguments.first },
             },
             data => {
-              data.links.nodes.push(result.createLink.node);
-              return data;
+              return {
+                ...data,
+                links: {
+                  ...data.links,
+                  nodes: [...data.links.nodes, result.createLink.node],
+                },
+              };
             }
           );
         }
diff --git a/exchanges/graphcache/src/ast/traversal.ts b/exchanges/graphcache/src/ast/traversal.ts
index 43889ee3..0c4e7357 100644
--- a/exchanges/graphcache/src/ast/traversal.ts
+++ b/exchanges/graphcache/src/ast/traversal.ts
@@ -93,3 +93,19 @@ export const isDeferred = (
 
   return false;
 };
+
+/** Resolves @_optional and @_required directive to determine whether the fields in a fragment are conaidered optional. */
+export const isOptional = (
+  node: FormattedNode<FragmentSpreadNode | InlineFragmentNode>
+): boolean | undefined => {
+  const { optional, required } = getDirectives(node);
+  if (required) {
+    return false;
+  }
+
+  if (optional) {
+    return true;
+  }
+
+  return undefined;
+};
diff --git a/exchanges/graphcache/src/operations/invalidate.ts b/exchanges/graphcache/src/operations/invalidate.ts
index 22ca5b2c..02497b12 100644
--- a/exchanges/graphcache/src/operations/invalidate.ts
+++ b/exchanges/graphcache/src/operations/invalidate.ts
@@ -24,3 +24,10 @@ export const invalidateEntity = (
     }
   }
 };
+
+export const invalidateType = (typename: string) => {
+  const types = InMemoryData.getEntitiesForType(typename);
+  for (const entity of types) {
+    invalidateEntity(entity);
+  }
+};
diff --git a/exchanges/graphcache/src/operations/query.ts b/exchanges/graphcache/src/operations/query.ts
index 2d031202..c85725e7 100644
--- a/exchanges/graphcache/src/operations/query.ts
+++ b/exchanges/graphcache/src/operations/query.ts
@@ -43,6 +43,7 @@ import {
   updateContext,
   getFieldError,
   deferRef,
+  optionalRef,
 } from './shared';
 
 import {
@@ -145,6 +146,7 @@ const readRoot = (
     entityKey,
     entityKey,
     deferRef,
+    undefined,
     select,
     ctx
   );
@@ -389,6 +391,7 @@ const readSelection = (
     typename,
     entityKey,
     deferRef,
+    undefined,
     select,
     ctx
   );
@@ -529,6 +532,7 @@ const readSelection = (
       !deferRef &&
       dataFieldValue === undefined &&
       (directives.optional ||
+        (optionalRef && !directives.required) ||
         !!getFieldError(ctx) ||
         (store.schema &&
           isFieldNullable(store.schema, typename, fieldName, ctx.store.logger)))
@@ -536,7 +540,10 @@ const readSelection = (
       // The field is uncached or has errored, so it'll be set to null and skipped
       ctx.partial = true;
       dataFieldValue = null;
-    } else if (dataFieldValue === null && directives.required) {
+    } else if (
+      dataFieldValue === null &&
+      (directives.required || optionalRef === false)
+    ) {
       if (
         ctx.store.logger &&
         process.env.NODE_ENV !== 'production' &&
diff --git a/exchanges/graphcache/src/operations/shared.ts b/exchanges/graphcache/src/operations/shared.ts
index 458e599f..7ad1800e 100644
--- a/exchanges/graphcache/src/operations/shared.ts
+++ b/exchanges/graphcache/src/operations/shared.ts
@@ -8,7 +8,13 @@ import type {
 import { Kind } from '@0no-co/graphql.web';
 
 import type { SelectionSet } from '../ast';
-import { isDeferred, getTypeCondition, getSelectionSet, getName } from '../ast';
+import {
+  isDeferred,
+  getTypeCondition,
+  getSelectionSet,
+  getName,
+  isOptional,
+} from '../ast';
 
 import { warn, pushDebugNode, popDebugNode } from '../helpers/help';
 import { hasField, currentOperation, currentOptimistic } from '../store/data';
@@ -49,6 +55,7 @@ export interface Context {
 
 export let contextRef: Context | null = null;
 export let deferRef = false;
+export let optionalRef: boolean | undefined = undefined;
 
 // Checks whether the current data field is a cache miss because of a GraphQLError
 export const getFieldError = (ctx: Context): ErrorLike | undefined =>
@@ -160,6 +167,7 @@ export const makeSelectionIterator = (
   typename: void | string,
   entityKey: string,
   defer: boolean,
+  optional: boolean | undefined,
   selectionSet: FormattedNode<SelectionSet>,
   ctx: Context
 ): SelectionIterator => {
@@ -171,6 +179,7 @@ export const makeSelectionIterator = (
     while (child || index < selectionSet.length) {
       node = undefined;
       deferRef = defer;
+      optionalRef = optional;
       if (child) {
         if ((node = child())) {
           return node;
@@ -203,10 +212,13 @@ export const makeSelectionIterator = (
             if (isMatching) {
               if (process.env.NODE_ENV !== 'production')
                 pushDebugNode(typename, fragment);
+
+              const isFragmentOptional = isOptional(select);
               child = makeSelectionIterator(
                 typename,
                 entityKey,
                 defer || isDeferred(select, ctx.variables),
+                isFragmentOptional,
                 getSelectionSet(fragment),
                 ctx
               );
diff --git a/exchanges/graphcache/src/operations/write.ts b/exchanges/graphcache/src/operations/write.ts
index 66f547dc..a2410178 100644
--- a/exchanges/graphcache/src/operations/write.ts
+++ b/exchanges/graphcache/src/operations/write.ts
@@ -232,6 +232,7 @@ const writeSelection = (
     return;
   } else if (!isRoot && entityKey) {
     InMemoryData.writeRecord(entityKey, '__typename', typename);
+    InMemoryData.writeType(typename, entityKey);
   }
 
   const updates = ctx.store.updates[typename];
@@ -239,6 +240,7 @@ const writeSelection = (
     typename,
     entityKey || typename,
     deferRef,
+    undefined,
     select,
     ctx
   );
diff --git a/exchanges/graphcache/src/store/data.ts b/exchanges/graphcache/src/store/data.ts
index 174610a1..99881ff8 100644
--- a/exchanges/graphcache/src/store/data.ts
+++ b/exchanges/graphcache/src/store/data.ts
@@ -47,6 +47,8 @@ export interface InMemoryData {
   records: NodeMap<EntityField>;
   /** A map of entity links which are connections from one entity to another (key-value entries per entity) */
   links: NodeMap<Link>;
+  /** A map of typename to a list of entity-keys belonging to said type */
+  types: Map<string, Set<string>>;
   /** A set of Query operation keys that are in-flight and deferred/streamed */
   deferredKeys: Set<number>;
   /** A set of Query operation keys that are in-flight and awaiting a result */
@@ -234,10 +236,12 @@ export const getCurrentDependencies = (): Dependencies => {
   return currentDependencies;
 };
 
+const DEFAULT_EMPTY_SET = new Set<string>();
 export const make = (queryRootKey: string): InMemoryData => ({
   hydrating: false,
   defer: false,
   gc: new Set(),
+  types: new Map(),
   persist: new Set(),
   queryRootKey,
   refCount: new Map(),
@@ -409,9 +413,17 @@ export const gc = () => {
     const rc = currentData!.refCount.get(entityKey) || 0;
     if (rc > 0) continue;
 
+    const record = currentData!.records.base.get(entityKey);
     // Delete the reference count, and delete the entity from the GC batch
     currentData!.refCount.delete(entityKey);
     currentData!.records.base.delete(entityKey);
+
+    const typename = (record && record.__typename) as string | undefined;
+    if (typename) {
+      const type = currentData!.types.get(typename);
+      if (type) type.delete(entityKey);
+    }
+
     const linkNode = currentData!.links.base.get(entityKey);
     if (linkNode) {
       currentData!.links.base.delete(entityKey);
@@ -452,6 +464,20 @@ export const readLink = (
   return getNode(currentData!.links, entityKey, fieldKey);
 };
 
+export const getEntitiesForType = (typename: string): Set<string> =>
+  currentData!.types.get(typename) || DEFAULT_EMPTY_SET;
+
+export const writeType = (typename: string, entityKey: string) => {
+  const existingTypes = currentData!.types.get(typename);
+  if (!existingTypes) {
+    const typeSet = new Set<string>();
+    typeSet.add(entityKey);
+    currentData!.types.set(typename, typeSet);
+  } else {
+    existingTypes.add(entityKey);
+  }
+};
+
 /** Writes an entity's field (a "record") to data */
 export const writeRecord = (
   entityKey: string,
diff --git a/exchanges/graphcache/src/store/store.ts b/exchanges/graphcache/src/store/store.ts
index c7d26b75..92c5e050 100644
--- a/exchanges/graphcache/src/store/store.ts
+++ b/exchanges/graphcache/src/store/store.ts
@@ -24,7 +24,7 @@ import { invariant } from '../helpers/help';
 import { contextRef, ensureLink } from '../operations/shared';
 import { _query, _queryFragment } from '../operations/query';
 import { _write, _writeFragment } from '../operations/write';
-import { invalidateEntity } from '../operations/invalidate';
+import { invalidateEntity, invalidateType } from '../operations/invalidate';
 import { keyOfField } from './keys';
 import * as InMemoryData from './data';
 
@@ -153,15 +153,15 @@ export class Store<
     field: string,
     args?: FieldArgs
   ): DataField | undefined {
-    let fieldValue: DataField | undefined = null;
     const entityKey = this.keyOfEntity(entity);
     if (entityKey) {
       const fieldKey = keyOfField(field, args);
-      fieldValue = InMemoryData.readRecord(entityKey, fieldKey);
-      if (fieldValue === undefined)
-        fieldValue = InMemoryData.readLink(entityKey, fieldKey);
+      const fieldValue = InMemoryData.readRecord(entityKey, fieldKey);
+      if (fieldValue !== undefined) return fieldValue;
+      let fieldLink = InMemoryData.readLink(entityKey, fieldKey);
+      if (fieldLink !== undefined) fieldLink = ensureLink(this, fieldLink);
+      return fieldLink;
     }
-    return fieldValue;
   }
 
   resolveFieldByKey(entity: Entity, field: string, args?: FieldArgs) {
@@ -170,18 +170,24 @@ export class Store<
 
   invalidate(entity: Entity, field?: string, args?: FieldArgs) {
     const entityKey = this.keyOfEntity(entity);
+    const shouldInvalidateType =
+      entity && typeof entity === 'string' && !field && !args;
 
-    invariant(
-      entityKey,
-      "Can't generate a key for invalidate(...).\n" +
-        'You have to pass an id or _id field or create a custom `keys` field for `' +
-        (typeof entity === 'object'
-          ? (entity as Data).__typename
-          : entity + '`.'),
-      19
-    );
+    if (shouldInvalidateType) {
+      invalidateType(entity);
+    } else {
+      invariant(
+        entityKey,
+        "Can't generate a key for invalidate(...).\n" +
+          'You have to pass an id or _id field or create a custom `keys` field for `' +
+          (typeof entity === 'object'
+            ? (entity as Data).__typename
+            : entity + '`.'),
+        19
+      );
 
-    invalidateEntity(entityKey, field, args);
+      invalidateEntity(entityKey, field, args);
+    }
   }
 
   inspectFields(entity: Entity): FieldInfo[] {
diff --git a/packages/core/src/internal/fetchOptions.ts b/packages/core/src/internal/fetchOptions.ts
index ef928946..1f04b069 100644
--- a/packages/core/src/internal/fetchOptions.ts
+++ b/packages/core/src/internal/fetchOptions.ts
@@ -10,6 +10,7 @@ import type { AnyVariables, GraphQLRequest, Operation } from '../types';
 /** Abstract definition of the JSON data sent during GraphQL HTTP POST requests. */
 export interface FetchBody {
   query?: string;
+  documentId?: string;
   operationName: string | undefined;
   variables: undefined | Record<string, any>;
   extensions: undefined | Record<string, any>;
@@ -24,16 +25,31 @@ export function makeFetchBody<
   Data = any,
   Variables extends AnyVariables = AnyVariables,
 >(request: Omit<GraphQLRequest<Data, Variables>, 'key'>): FetchBody {
-  const isAPQ =
-    request.extensions &&
-    request.extensions.persistedQuery &&
-    !request.extensions.persistedQuery.miss;
-  return {
-    query: isAPQ ? undefined : stringifyDocument(request.query),
+  const body: FetchBody = {
+    query: undefined,
+    documentId: undefined,
     operationName: getOperationName(request.query),
     variables: request.variables || undefined,
     extensions: request.extensions,
   };
+
+  if (
+    'documentId' in request.query &&
+    request.query.documentId &&
+    // NOTE: We have to check that the document will definitely be sent
+    // as a persisted document to avoid breaking changes
+    (!request.query.definitions || !request.query.definitions.length)
+  ) {
+    body.documentId = request.query.documentId;
+  } else if (
+    !request.extensions ||
+    !request.extensions.persistedQuery ||
+    !!request.extensions.persistedQuery.miss
+  ) {
+    body.query = stringifyDocument(request.query);
+  }
+
+  return body;
 }
 
 /** Creates a URL that will be called for a GraphQL HTTP request.
@@ -102,6 +118,9 @@ const serializeBody = (
   }
 };
 
+const isHeaders = (headers: HeadersInit): headers is Headers =>
+  'has' in headers && !Object.keys(headers).length;
+
 /** Creates a `RequestInit` object for a given `Operation`.
  *
  * @param operation - An {@link Operation} for which to make the request.
@@ -129,9 +148,32 @@ export const makeFetchOptions = (
     (typeof operation.context.fetchOptions === 'function'
       ? operation.context.fetchOptions()
       : operation.context.fetchOptions) || {};
-  if (extraOptions.headers)
-    for (const key in extraOptions.headers)
-      headers[key.toLowerCase()] = extraOptions.headers[key];
+  if (extraOptions.headers) {
+    if (isHeaders(extraOptions.headers)) {
+      extraOptions.headers.forEach((value, key) => {
+        headers[key] = value;
+      });
+    } else if (Array.isArray(extraOptions.headers)) {
+      (extraOptions.headers as Array<[string, string]>).forEach(
+        (value, key) => {
+          if (Array.isArray(value)) {
+            if (headers[value[0]]) {
+              headers[value[0]] = `${headers[value[0]]},${value[1]}`;
+            } else {
+              headers[value[0]] = value[1];
+            }
+          } else {
+            headers[key] = value;
+          }
+        }
+      );
+    } else {
+      for (const key in extraOptions.headers) {
+        headers[key.toLowerCase()] = extraOptions.headers[key];
+      }
+    }
+  }
+
   const serializedBody = serializeBody(operation, body);
   if (typeof serializedBody === 'string' && !headers['content-type'])
     headers['content-type'] = 'application/json';
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 33ab727f..0082d641 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -9,6 +9,11 @@ import type { Subscription, Source } from 'wonka';
 import type { Client } from './client';
 import type { CombinedError } from './utils/error';
 
+/** A GraphQL persisted document will contain `documentId` that replaces its definitions */
+export interface PersistedDocument extends DocumentNode {
+  documentId?: string;
+}
+
 /** A GraphQL `DocumentNode` with attached generics for its result data and variables.
  *
  * @remarks
@@ -223,6 +228,7 @@ export interface ExecutionResult {
    * @see {@link https://github.com/graphql/graphql-wg/blob/main/rfcs/DeferStream.md#payload-format} for the DeferStream spec
    */
   hasNext?: boolean;
+  payload?: Omit<ExecutionResult, 'payload'>;
 }
 
 /** A source of {@link OperationResult | OperationResults}, convertable to a promise, subscribable, or Wonka Source.
@@ -340,7 +346,7 @@ export interface GraphQLRequest<
    * In `urql`, we expect a document to only contain a single operation that is executed rather than
    * multiple ones by convention.
    */
-  query: DocumentNode | TypedDocumentNode<Data, Variables>;
+  query: DocumentNode | PersistedDocument | TypedDocumentNode<Data, Variables>;
   /** Variables used to execute the `query` document.
    *
    * @remarks
diff --git a/packages/core/src/utils/result.ts b/packages/core/src/utils/result.ts
index 386c9b93..001e0057 100644
--- a/packages/core/src/utils/result.ts
+++ b/packages/core/src/utils/result.ts
@@ -92,8 +92,12 @@ export const mergeResultPatch = (
   pending?: ExecutionResult['pending']
 ): OperationResult => {
   let errors = prevResult.error ? prevResult.error.graphQLErrors : [];
-  let hasExtensions = !!prevResult.extensions || !!nextResult.extensions;
-  const extensions = { ...prevResult.extensions, ...nextResult.extensions };
+  let hasExtensions =
+    !!prevResult.extensions || !!(nextResult.payload || nextResult).extensions;
+  const extensions = {
+    ...prevResult.extensions,
+    ...(nextResult.payload || nextResult).extensions,
+  };
 
   let incremental = nextResult.incremental;
 
@@ -146,8 +150,11 @@ export const mergeResultPatch = (
       }
     }
   } else {
-    withData.data = nextResult.data || prevResult.data;
-    errors = (nextResult.errors as any[]) || errors;
+    withData.data = (nextResult.payload || nextResult).data || prevResult.data;
+    errors =
+      (nextResult.errors as any[]) ||
+      (nextResult.payload && nextResult.payload.errors) ||
+      errors;
   }
 
   return {


Test patch:
diff --git a/exchanges/graphcache/src/cacheExchange.test.ts b/exchanges/graphcache/src/cacheExchange.test.ts
index f4b89baf..cc115732 100644
--- a/exchanges/graphcache/src/cacheExchange.test.ts
+++ b/exchanges/graphcache/src/cacheExchange.test.ts
@@ -827,6 +827,352 @@ describe('directives', () => {
     });
   });
 
+  it('Does not return partial data for nested selections', () => {
+    const client = createClient({
+      url: 'http://0.0.0.0',
+      exchanges: [],
+    });
+    const { source: ops$, next } = makeSubject<Operation>();
+
+    const query = gql`
+      {
+        todo {
+          ... on Todo @_optional {
+            id
+            text
+            author {
+              id
+              name
+            }
+          }
+        }
+      }
+    `;
+
+    const operation = client.createRequestOperation('query', {
+      key: 1,
+      query,
+      variables: undefined,
+    });
+
+    const queryResult: OperationResult = {
+      ...queryResponse,
+      operation,
+      data: {
+        __typename: 'Query',
+        todo: {
+          id: '1',
+          text: 'learn urql',
+          __typename: 'Todo',
+          author: {
+            __typename: 'Author',
+          },
+        },
+      },
+    };
+
+    const reexecuteOperation = vi
+      .spyOn(client, 'reexecuteOperation')
+      .mockImplementation(next);
+
+    const response = vi.fn((forwardOp: Operation): OperationResult => {
+      if (forwardOp.key === 1) return queryResult;
+      return undefined as any;
+    });
+
+    const result = vi.fn();
+    const forward: ExchangeIO = ops$ => pipe(ops$, map(response), share);
+
+    pipe(
+      cacheExchange({})({ forward, client, dispatchDebug })(ops$),
+      tap(result),
+      publish
+    );
+
+    next(operation);
+
+    expect(response).toHaveBeenCalledTimes(1);
+    expect(result).toHaveBeenCalledTimes(1);
+    expect(reexecuteOperation).toHaveBeenCalledTimes(0);
+    expect(result.mock.calls[0][0].data).toEqual(null);
+  });
+
+  it('returns partial results when an inline-fragment is marked as optional', () => {
+    const client = createClient({
+      url: 'http://0.0.0.0',
+      exchanges: [],
+    });
+    const { source: ops$, next } = makeSubject<Operation>();
+
+    const query = gql`
+      {
+        todos {
+          id
+          text
+          ... on Todo @_optional {
+            completed
+          }
+        }
+      }
+    `;
+
+    const operation = client.createRequestOperation('query', {
+      key: 1,
+      query,
+      variables: undefined,
+    });
+
+    const queryResult: OperationResult = {
+      ...queryResponse,
+      operation,
+      data: {
+        __typename: 'Query',
+        todos: [
+          {
+            id: '1',
+            text: 'learn urql',
+            __typename: 'Todo',
+          },
+        ],
+      },
+    };
+
+    const reexecuteOperation = vi
+      .spyOn(client, 'reexecuteOperation')
+      .mockImplementation(next);
+
+    const response = vi.fn((forwardOp: Operation): OperationResult => {
+      if (forwardOp.key === 1) return queryResult;
+      return undefined as any;
+    });
+
+    const result = vi.fn();
+    const forward: ExchangeIO = ops$ => pipe(ops$, map(response), share);
+
+    pipe(
+      cacheExchange({})({ forward, client, dispatchDebug })(ops$),
+      tap(result),
+      publish
+    );
+
+    next(operation);
+
+    expect(response).toHaveBeenCalledTimes(1);
+    expect(result).toHaveBeenCalledTimes(1);
+    expect(reexecuteOperation).toHaveBeenCalledTimes(0);
+    expect(result.mock.calls[0][0].data).toEqual({
+      todos: [
+        {
+          completed: null,
+          id: '1',
+          text: 'learn urql',
+        },
+      ],
+    });
+  });
+
+  it('does not return partial results when an inline-fragment is marked as optional with a required child fragment', () => {
+    const client = createClient({
+      url: 'http://0.0.0.0',
+      exchanges: [],
+    });
+    const { source: ops$, next } = makeSubject<Operation>();
+
+    const query = gql`
+      {
+        todos {
+          id
+          ... on Todo @_optional {
+            text
+            ... on Todo @_required {
+              completed
+            }
+          }
+        }
+      }
+    `;
+
+    const operation = client.createRequestOperation('query', {
+      key: 1,
+      query,
+      variables: undefined,
+    });
+
+    const queryResult: OperationResult = {
+      ...queryResponse,
+      operation,
+      data: {
+        __typename: 'Query',
+        todos: [
+          {
+            id: '1',
+            text: 'learn urql',
+            __typename: 'Todo',
+          },
+        ],
+      },
+    };
+
+    const reexecuteOperation = vi
+      .spyOn(client, 'reexecuteOperation')
+      .mockImplementation(next);
+
+    const response = vi.fn((forwardOp: Operation): OperationResult => {
+      if (forwardOp.key === 1) return queryResult;
+      return undefined as any;
+    });
+
+    const result = vi.fn();
+    const forward: ExchangeIO = ops$ => pipe(ops$, map(response), share);
+
+    pipe(
+      cacheExchange({})({ forward, client, dispatchDebug })(ops$),
+      tap(result),
+      publish
+    );
+
+    next(operation);
+
+    expect(response).toHaveBeenCalledTimes(1);
+    expect(result).toHaveBeenCalledTimes(1);
+    expect(reexecuteOperation).toHaveBeenCalledTimes(0);
+    expect(result.mock.calls[0][0].data).toEqual(null);
+  });
+
+  it('does not return partial results when an inline-fragment is marked as optional with a required field', () => {
+    const client = createClient({
+      url: 'http://0.0.0.0',
+      exchanges: [],
+    });
+    const { source: ops$, next } = makeSubject<Operation>();
+
+    const query = gql`
+      {
+        todos {
+          id
+          ... on Todo @_optional {
+            text
+            completed @_required
+          }
+        }
+      }
+    `;
+
+    const operation = client.createRequestOperation('query', {
+      key: 1,
+      query,
+      variables: undefined,
+    });
+
+    const queryResult: OperationResult = {
+      ...queryResponse,
+      operation,
+      data: {
+        __typename: 'Query',
+        todos: [
+          {
+            id: '1',
+            text: 'learn urql',
+            __typename: 'Todo',
+          },
+        ],
+      },
+    };
+
+    const reexecuteOperation = vi
+      .spyOn(client, 'reexecuteOperation')
+      .mockImplementation(next);
+
+    const response = vi.fn((forwardOp: Operation): OperationResult => {
+      if (forwardOp.key === 1) return queryResult;
+      return undefined as any;
+    });
+
+    const result = vi.fn();
+    const forward: ExchangeIO = ops$ => pipe(ops$, map(response), share);
+
+    pipe(
+      cacheExchange({})({ forward, client, dispatchDebug })(ops$),
+      tap(result),
+      publish
+    );
+
+    next(operation);
+
+    expect(response).toHaveBeenCalledTimes(1);
+    expect(result).toHaveBeenCalledTimes(1);
+    expect(reexecuteOperation).toHaveBeenCalledTimes(0);
+    expect(result.mock.calls[0][0].data).toEqual(null);
+  });
+
+  it('returns partial results when a fragment-definition is marked as optional', () => {
+    const client = createClient({
+      url: 'http://0.0.0.0',
+      exchanges: [],
+    });
+    const { source: ops$, next } = makeSubject<Operation>();
+
+    const query = gql`
+      {
+        todos {
+          id
+          text
+          ...Fields
+        }
+      }
+
+      fragment Fields on Todo @_optional {
+        completed
+      }
+    `;
+
+    const operation = client.createRequestOperation('query', {
+      key: 1,
+      query,
+      variables: undefined,
+    });
+
+    const queryResult: OperationResult = {
+      ...queryResponse,
+      operation,
+      data: {
+        __typename: 'Query',
+        todos: [
+          {
+            id: '1',
+            text: 'learn urql',
+            __typename: 'Todo',
+          },
+        ],
+      },
+    };
+
+    const reexecuteOperation = vi
+      .spyOn(client, 'reexecuteOperation')
+      .mockImplementation(next);
+
+    const response = vi.fn((forwardOp: Operation): OperationResult => {
+      if (forwardOp.key === 1) return queryResult;
+      return undefined as any;
+    });
+
+    const result = vi.fn();
+    const forward: ExchangeIO = ops$ => pipe(ops$, map(response), share);
+
+    pipe(
+      cacheExchange({})({ forward, client, dispatchDebug })(ops$),
+      tap(result),
+      publish
+    );
+
+    next(operation);
+
+    expect(response).toHaveBeenCalledTimes(1);
+    expect(result).toHaveBeenCalledTimes(1);
+    expect(reexecuteOperation).toHaveBeenCalledTimes(0);
+    expect(result.mock.calls[0][0].data).toEqual(null);
+  });
+
   it('does not return missing required fields', () => {
     const client = createClient({
       url: 'http://0.0.0.0',
diff --git a/exchanges/graphcache/src/operations/shared.test.ts b/exchanges/graphcache/src/operations/shared.test.ts
index 18b8059a..a9603201 100644
--- a/exchanges/graphcache/src/operations/shared.test.ts
+++ b/exchanges/graphcache/src/operations/shared.test.ts
@@ -34,6 +34,7 @@ describe('makeSelectionIterator', () => {
       'Query',
       'Query',
       false,
+      undefined,
       selection,
       ctx
     );
@@ -93,6 +94,7 @@ describe('makeSelectionIterator', () => {
       'Query',
       'Query',
       false,
+      undefined,
       selection,
       ctx
     );
@@ -123,6 +125,7 @@ describe('makeSelectionIterator', () => {
       'Query',
       'Query',
       false,
+      undefined,
       selection,
       ctx
     );
@@ -208,6 +211,7 @@ describe('makeSelectionIterator', () => {
       'Query',
       'Query',
       false,
+      undefined,
       selection,
       ctx
     );
@@ -243,6 +247,7 @@ describe('makeSelectionIterator', () => {
       'Query',
       'Query',
       true,
+      undefined,
       selection,
       ctx
     );
diff --git a/exchanges/graphcache/src/store/data.test.ts b/exchanges/graphcache/src/store/data.test.ts
index 03f5441e..a7dcad93 100644
--- a/exchanges/graphcache/src/store/data.test.ts
+++ b/exchanges/graphcache/src/store/data.test.ts
@@ -16,16 +16,21 @@ describe('garbage collection', () => {
     InMemoryData.writeRecord('Todo:2', '__typename', 'Todo');
     InMemoryData.writeRecord('Query', '__typename', 'Query');
     InMemoryData.writeLink('Query', 'todo', 'Todo:1');
+    InMemoryData.writeType('Todo', 'Todo:1');
 
     InMemoryData.gc();
 
     expect(InMemoryData.readLink('Query', 'todo')).toBe('Todo:1');
+    expect(InMemoryData.getEntitiesForType('Todo')).toEqual(
+      new Set(['Todo:1'])
+    );
 
     InMemoryData.writeLink('Query', 'todo', undefined);
     InMemoryData.gc();
 
     expect(InMemoryData.readLink('Query', 'todo')).toBe(undefined);
     expect(InMemoryData.readRecord('Todo:1', 'id')).toBe(undefined);
+    expect(InMemoryData.getEntitiesForType('Todo')).toEqual(new Set());
 
     expect(InMemoryData.getCurrentDependencies()).toEqual(
       new Set(['Todo:1', 'Todo:2', 'Query.todo'])
@@ -39,12 +44,16 @@ describe('garbage collection', () => {
     InMemoryData.writeLink('Query', 'todo', 'Todo:1');
     InMemoryData.writeLink('Query', 'todo', undefined);
     InMemoryData.writeLink('Query', 'newTodo', 'Todo:1');
+    InMemoryData.writeType('Todo', 'Todo:1');
 
     InMemoryData.gc();
 
     expect(InMemoryData.readLink('Query', 'newTodo')).toBe('Todo:1');
     expect(InMemoryData.readLink('Query', 'todo')).toBe(undefined);
     expect(InMemoryData.readRecord('Todo:1', 'id')).toBe('1');
+    expect(InMemoryData.getEntitiesForType('Todo')).toEqual(
+      new Set(['Todo:1'])
+    );
 
     expect(InMemoryData.getCurrentDependencies()).toEqual(
       new Set(['Todo:1', 'Query.todo', 'Query.newTodo'])
@@ -101,12 +110,22 @@ describe('garbage collection', () => {
     InMemoryData.writeRecord('Todo:1', '__typename', 'Todo');
     InMemoryData.writeRecord('Todo:1', 'id', '1');
     InMemoryData.writeLink('Query', 'todo', 'Todo:1');
+    InMemoryData.writeType('Todo', 'Todo:1');
+    InMemoryData.writeType('Author', 'Author:1');
 
     InMemoryData.writeLink('Query', 'todo', undefined);
+    expect(InMemoryData.getEntitiesForType('Todo')).toEqual(
+      new Set(['Todo:1'])
+    );
+    expect(InMemoryData.getEntitiesForType('Author')).toEqual(
+      new Set(['Author:1'])
+    );
     InMemoryData.gc();
 
     expect(InMemoryData.readRecord('Todo:1', 'id')).toBe(undefined);
     expect(InMemoryData.readRecord('Author:1', 'id')).toBe(undefined);
+    expect(InMemoryData.getEntitiesForType('Todo')).toEqual(new Set());
+    expect(InMemoryData.getEntitiesForType('Author')).toEqual(new Set());
 
     expect(InMemoryData.getCurrentDependencies()).toEqual(
       new Set(['Author:1', 'Todo:1', 'Query.todo'])
diff --git a/exchanges/graphcache/src/store/store.test.ts b/exchanges/graphcache/src/store/store.test.ts
index 40222efe..f73793ae 100644
--- a/exchanges/graphcache/src/store/store.test.ts
+++ b/exchanges/graphcache/src/store/store.test.ts
@@ -844,6 +844,15 @@ describe('Store with OptimisticMutationConfig', () => {
       expect(data).toBe(null);
     });
   });
+
+  describe('Invalidating a type', () => {
+    it('removes an entity from a list.', () => {
+      InMemoryData.initDataState('write', store.data, null);
+      store.invalidate('Todo');
+      const { data } = query(store, { query: Todos });
+      expect(data).toBe(null);
+    });
+  });
 });
 
 describe('Store with storage', () => {
diff --git a/packages/core/src/internal/fetchOptions.test.ts b/packages/core/src/internal/fetchOptions.test.ts
index e0142bf6..4237695f 100644
--- a/packages/core/src/internal/fetchOptions.test.ts
+++ b/packages/core/src/internal/fetchOptions.test.ts
@@ -1,6 +1,7 @@
 // @vitest-environment jsdom
 
 import { expect, describe, it } from 'vitest';
+import { Kind } from '@0no-co/graphql.web';
 import { makeOperation } from '../utils/operation';
 import { queryOperation, mutationOperation } from '../test-utils';
 import { makeFetchBody, makeFetchURL, makeFetchOptions } from './fetchOptions';
@@ -10,6 +11,7 @@ describe('makeFetchBody', () => {
     const body = makeFetchBody(queryOperation);
     expect(body).toMatchInlineSnapshot(`
       {
+        "documentId": undefined,
         "extensions": undefined,
         "operationName": "getUser",
         "query": "query getUser($name: String) {
@@ -42,6 +44,22 @@ describe('makeFetchBody', () => {
     apqOperation.extensions.persistedQuery!.miss = true;
     expect(makeFetchBody(apqOperation).query).not.toBe(undefined);
   });
+
+  it('omits the query property when query is a persisted document', () => {
+    // A persisted documents is one that carries a `documentId` property and
+    // has no definitions
+    const persistedOperation = makeOperation(queryOperation.kind, {
+      ...queryOperation,
+      query: {
+        kind: Kind.DOCUMENT,
+        definitions: [],
+        documentId: 'TestDocumentId',
+      },
+    });
+
+    expect(makeFetchBody(persistedOperation).query).toBe(undefined);
+    expect(makeFetchBody(persistedOperation).documentId).toBe('TestDocumentId');
+  });
 });
 
 describe('makeFetchURL', () => {
@@ -107,6 +125,52 @@ describe('makeFetchOptions', () => {
     `);
   });
 
+  it('handles the Headers object', () => {
+    const headers = new Headers();
+    headers.append('x-test', 'true');
+    const operation = makeOperation(queryOperation.kind, queryOperation, {
+      ...queryOperation.context,
+      fetchOptions: {
+        headers,
+      },
+    });
+    const body = makeFetchBody(operation);
+
+    expect(makeFetchOptions(operation, body)).toMatchInlineSnapshot(`
+      {
+        "body": "{\\"operationName\\":\\"getUser\\",\\"query\\":\\"query getUser($name: String) {\\\\n  user(name: $name) {\\\\n    id\\\\n    firstName\\\\n    lastName\\\\n  }\\\\n}\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}",
+        "headers": {
+          "accept": "application/graphql-response+json, application/graphql+json, application/json, text/event-stream, multipart/mixed",
+          "content-type": "application/json",
+          "x-test": "true",
+        },
+        "method": "POST",
+      }
+    `);
+  });
+
+  it('handles an Array headers init', () => {
+    const operation = makeOperation(queryOperation.kind, queryOperation, {
+      ...queryOperation.context,
+      fetchOptions: {
+        headers: [['x-test', 'true']],
+      },
+    });
+    const body = makeFetchBody(operation);
+
+    expect(makeFetchOptions(operation, body)).toMatchInlineSnapshot(`
+      {
+        "body": "{\\"operationName\\":\\"getUser\\",\\"query\\":\\"query getUser($name: String) {\\\\n  user(name: $name) {\\\\n    id\\\\n    firstName\\\\n    lastName\\\\n  }\\\\n}\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}",
+        "headers": {
+          "accept": "application/graphql-response+json, application/graphql+json, application/json, text/event-stream, multipart/mixed",
+          "content-type": "application/json",
+          "x-test": "true",
+        },
+        "method": "POST",
+      }
+    `);
+  });
+
   it('creates a GET request when preferred for query operations', () => {
     const operation = makeOperation(queryOperation.kind, queryOperation, {
       ...queryOperation.context,
diff --git a/packages/core/src/utils/result.test.ts b/packages/core/src/utils/result.test.ts
index 4d53d2cb..bee7225f 100644
--- a/packages/core/src/utils/result.test.ts
+++ b/packages/core/src/utils/result.test.ts
@@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest';
 import { OperationResult } from '../types';
 import { queryOperation, subscriptionOperation } from '../test-utils';
 import { makeResult, mergeResultPatch } from './result';
+import { GraphQLError } from '@0no-co/graphql.web';
+import { CombinedError } from './error';
 
 describe('makeResult', () => {
   it('adds extensions and errors correctly', () => {
@@ -180,6 +182,62 @@ describe('mergeResultPatch (defer/stream pre June-2023)', () => {
     expect(merged.hasNext).toBe(true);
   });
 
+  it('should work with the payload property', () => {
+    const prevResult: OperationResult = {
+      operation: subscriptionOperation,
+      data: {
+        __typename: 'Subscription',
+        event: 1,
+      },
+      stale: false,
+      hasNext: true,
+    };
+
+    const merged = mergeResultPatch(prevResult, {
+      payload: {
+        data: {
+          __typename: 'Subscription',
+          event: 2,
+        },
+      },
+    });
+
+    expect(merged.data).not.toBe(prevResult.data);
+    expect(merged.data.event).toBe(2);
+    expect(merged.hasNext).toBe(true);
+  });
+
+  it('should work with the payload property and errors', () => {
+    const prevResult: OperationResult = {
+      operation: subscriptionOperation,
+      data: {
+        __typename: 'Subscription',
+        event: 1,
+      },
+      stale: false,
+      hasNext: true,
+    };
+
+    const merged = mergeResultPatch(prevResult, {
+      payload: {
+        data: {
+          __typename: 'Subscription',
+          event: 2,
+        },
+      },
+      errors: [new GraphQLError('Something went horribly wrong')],
+    });
+
+    expect(merged.data).not.toBe(prevResult.data);
+    expect(merged.data.event).toBe(2);
+    expect(merged.error).toEqual(
+      new CombinedError({
+        graphQLErrors: [new GraphQLError('Something went horribly wrong')],
+      })
+    );
+    expect(merged.hasNext).toBe(true);
+  });
+
   it('should ignore invalid patches', () => {
     const prevResult: OperationResult = {
       operation: queryOperation,