Repository: urql-graphql/urql (ts)

Issue:
Passing a `Headers` object results in an error
### Describe the bug

urql accepts `HeadersInit` (via `RequestInit`), it appears that a `Headers` object can be passed.
But when I actually do, it causes errors.

```ts
const headers = new Headers();

const client = new Client({
  url: "https://localhost:3000/graphql",
  exchanges: [fetchExchange],
  fetchOptions: { headers },
});

const response = await client.query("{ ping }", {});
```

```
[Network] Headers.append: "append(name, value) { /* omit */ }" is an invalid header value.
```

I think: 🤔

- the type might be incorrect
- the implementation might be incorrect


### Reproduction

https://github.com/konomae/urql-headers-repro

### Urql version

urql 4.2.3
node 20.11

### Validations

- [X] I can confirm that this is a bug report, and not a feature request, RFC, question, or discussion, for which [GitHub Discussions](https://github.com/urql-graphql/urql/discussions) should be used
- [X] Read the [docs](https://formidable.com/open-source/urql/docs/).
- [X] Follow our [Code of Conduct](https://github.com/urql-graphql/urql/blob/main/CODE_OF_CONDUCT.md)

Pull request:
fix(core): support Headers being passed in fetchOptions
Resolves #3504 

## Summary

This aims to solve the problem of supporting `Headers` I opted not to do do a `instanceof` check for this to support node versions that don't have this by default. The downside of doing the `forEach` check is that we also get the `[[string, string]]` `HeadersInit` in there which is a tad harder to support

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/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/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/write.ts b/exchanges/graphcache/src/operations/write.ts
index 66f547dc..ed3b4539 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];
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/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,