Repository: urql-graphql/urql
Pull request #3317 (2023-07-19 13:19:34)

feat(core/graphcache): Add support for client-side-only directive processing
## Summary

When applied, this change alters `formatDocument` to return a `FormattedNode` type mapping. This maps GraphQL AST nodes and specifically:
- adds a `_generated: true` flag on added `__typename` fields so Graphcache can identify them as relevant only to introspection
- adds a `_directives: {...}` dictionary to selection nodes that contains a mapping of directive names to directive nodes for client-side directives
- filters directives to not include client-side directives, i.e. directive starting with `"_"` underscores

This will allow us to implement client-side directives in Graphcache as well as stabilise `formatDocument` to do more advanced processing of GraphQL ASTs.

## Set of changes

- `@urql/core`
  - Move `formatDocument` to `core/src/utils/formatDocument.ts`
  - Rename internal `collectTypesFromResponse` to `collectTypenames` and move it to `core/src/utils/collectTypenames.ts`
  - Add `FormattedNode` type mapping for `formatDocument`
  - Add directive handling to `formatDocument`
  - Add `_generated: true` annotation to `__typename` fields that `formatDocument` adds
  - Remove `hasChanged` checks in `formatDocument`
- `@urql/exchange-graphcache`
  - Remove remapping of "original operations" and instead rely on skipping `_generated: true` in cache reads
  - Use `_directives` map instead of `directives` list for `@include`, `@skip` and `@defer`
  - Update to always require `FormattedNode` AST nodes throughout the codebase


Patch:
diff --git a/.changeset/brave-lamps-punch.md b/.changeset/brave-lamps-punch.md
new file mode 100644
index 00000000..1a0c3671
--- /dev/null
+++ b/.changeset/brave-lamps-punch.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-graphcache': patch
+---
+
+Use new `FormattedNode` / `formatDocument` functionality added to `@urql/core` to slightly speed up directive processing by using the client-side `_directives` dictionary that `formatDocument` adds.
diff --git a/.changeset/rude-waves-check.md b/.changeset/rude-waves-check.md
new file mode 100644
index 00000000..519ea5fe
--- /dev/null
+++ b/.changeset/rude-waves-check.md
@@ -0,0 +1,5 @@
+---
+'@urql/core': minor
+---
+
+Update `formatDocument` to output `FormattedNode` type mapping. The formatter will now annotate added `__typename` fields with `_generated: true`, place selection nodes' directives onto a `_directives` dictionary, and will filter directives to not include `"_"` underscore prefixed directives in the final query. This prepares us for a feature that allows enhanced client-side directives in Graphcache.
diff --git a/exchanges/graphcache/src/ast/node.ts b/exchanges/graphcache/src/ast/node.ts
index 4cf62b46..01fcfdff 100644
--- a/exchanges/graphcache/src/ast/node.ts
+++ b/exchanges/graphcache/src/ast/node.ts
@@ -1,15 +1,23 @@
 import {
   NamedTypeNode,
   NameNode,
+  DirectiveNode,
   SelectionNode,
   SelectionSetNode,
-  InlineFragmentNode,
   FieldNode,
   FragmentDefinitionNode,
-  Kind,
 } from '@0no-co/graphql.web';
 
-export type SelectionSet = ReadonlyArray<SelectionNode>;
+import { FormattedNode } from '@urql/core';
+
+export type SelectionSet = readonly FormattedNode<SelectionNode>[];
+
+const EMPTY_DIRECTIVES: Record<string, DirectiveNode | undefined> = {};
+
+/** Returns the directives dictionary of a given node */
+export const getDirectives = (node: {
+  _directives?: Record<string, DirectiveNode | undefined>;
+}) => node._directives || EMPTY_DIRECTIVES;
 
 /** Returns the name of a given node */
 export const getName = (node: { name: NameNode }): string => node.name.value;
@@ -25,18 +33,13 @@ const emptySelectionSet: SelectionSet = [];
 
 /** Returns the SelectionSet for a given inline or defined fragment node */
 export const getSelectionSet = (node: {
-  selectionSet?: SelectionSetNode;
-}): SelectionSet =>
-  node.selectionSet ? node.selectionSet.selections : emptySelectionSet;
+  selectionSet?: FormattedNode<SelectionSetNode>;
+}): FormattedNode<SelectionSet> =>
+  (node.selectionSet
+    ? node.selectionSet.selections
+    : emptySelectionSet) as FormattedNode<SelectionSet>;
 
 export const getTypeCondition = (node: {
   typeCondition?: NamedTypeNode;
 }): string | null =>
   node.typeCondition ? node.typeCondition.name.value : null;
-
-export const isFieldNode = (node: SelectionNode): node is FieldNode =>
-  node.kind === Kind.FIELD;
-
-export const isInlineFragment = (
-  node: SelectionNode
-): node is InlineFragmentNode => node.kind === Kind.INLINE_FRAGMENT;
diff --git a/exchanges/graphcache/src/ast/traversal.ts b/exchanges/graphcache/src/ast/traversal.ts
index a0b1bbd2..82373899 100644
--- a/exchanges/graphcache/src/ast/traversal.ts
+++ b/exchanges/graphcache/src/ast/traversal.ts
@@ -8,18 +8,21 @@ import {
   Kind,
 } from '@0no-co/graphql.web';
 
-import { getName } from './node';
-
+import { FormattedNode } from '@urql/core';
+import { getName, getDirectives } from './node';
 import { invariant } from '../helpers/help';
 import { Fragments, Variables } from '../types';
 
+function getMainOperation(
+  doc: FormattedNode<DocumentNode>
+): FormattedNode<OperationDefinitionNode>;
+function getMainOperation(doc: DocumentNode): OperationDefinitionNode;
+
 /** Returns the main operation's definition */
-export const getMainOperation = (
-  doc: DocumentNode
-): OperationDefinitionNode => {
+function getMainOperation(doc: DocumentNode): OperationDefinitionNode {
   for (let i = 0; i < doc.definitions.length; i++) {
     if (doc.definitions[i].kind === Kind.OPERATION_DEFINITION) {
-      return doc.definitions[i] as OperationDefinitionNode;
+      return doc.definitions[i] as FormattedNode<OperationDefinitionNode>;
     }
   }
 
@@ -29,10 +32,12 @@ export const getMainOperation = (
       'node for a query, subscription, or mutation.',
     1
   );
-};
+}
+
+export { getMainOperation };
 
 /** Returns a mapping from fragment names to their selections */
-export const getFragments = (doc: DocumentNode): Fragments => {
+export const getFragments = (doc: FormattedNode<DocumentNode>): Fragments => {
   const fragments: Fragments = {};
   for (let i = 0; i < doc.definitions.length; i++) {
     const node = doc.definitions[i];
@@ -46,52 +51,45 @@ export const getFragments = (doc: DocumentNode): Fragments => {
 
 /** Resolves @include and @skip directives to determine whether field is included. */
 export const shouldInclude = (
-  node: SelectionNode,
+  node: FormattedNode<SelectionNode>,
   vars: Variables
 ): boolean => {
-  // Finds any @include or @skip directive that forces the node to be skipped
-  for (let i = 0; node.directives && i < node.directives.length; i++) {
-    const directive = node.directives[i];
-    const name = getName(directive);
-    if (
-      (name === 'include' || name === 'skip') &&
-      directive.arguments &&
-      directive.arguments[0] &&
-      getName(directive.arguments[0]) === 'if'
-    ) {
-      // Return whether this directive forces us to skip
-      // `@include(if: false)` or `@skip(if: true)`
-      const value = valueFromASTUntyped(directive.arguments[0].value, vars);
-      return name === 'include' ? !!value : !value;
+  const directives = getDirectives(node);
+  if (directives.include || directives.skip) {
+    // Finds any @include or @skip directive that forces the node to be skipped
+    for (const name in directives) {
+      const directive = directives[name];
+      if (
+        directive &&
+        (name === 'include' || name === 'skip') &&
+        directive.arguments &&
+        directive.arguments[0] &&
+        getName(directive.arguments[0]) === 'if'
+      ) {
+        // Return whether this directive forces us to skip
+        // `@include(if: false)` or `@skip(if: true)`
+        const value = valueFromASTUntyped(directive.arguments[0].value, vars);
+        return name === 'include' ? !!value : !value;
+      }
     }
   }
-
   return true;
 };
 
 /** Resolves @defer directive to determine whether a fragment is potentially skipped. */
 export const isDeferred = (
-  node: FragmentSpreadNode | InlineFragmentNode,
+  node: FormattedNode<FragmentSpreadNode | InlineFragmentNode>,
   vars: Variables
 ): boolean => {
-  for (let i = 0; node.directives && i < node.directives.length; i++) {
-    const directive = node.directives[i];
-    const name = getName(directive);
-    if (name === 'defer') {
-      for (
-        let j = 0;
-        directive.arguments && j < directive.arguments.length;
-        j++
-      ) {
-        const argument = directive.arguments[i];
-        if (getName(argument) === 'if') {
-          // Return whether `@defer(if: )` is enabled
-          return !!valueFromASTUntyped(argument.value, vars);
-        }
+  const { defer } = getDirectives(node);
+  if (defer) {
+    for (const argument of defer.arguments || []) {
+      if (getName(argument) === 'if') {
+        // Return whether `@defer(if: )` is enabled
+        return !!valueFromASTUntyped(argument.value, vars);
       }
-
-      return true;
     }
+    return true;
   }
 
   return false;
diff --git a/exchanges/graphcache/src/cacheExchange.ts b/exchanges/graphcache/src/cacheExchange.ts
index a6df7162..7d0594e1 100644
--- a/exchanges/graphcache/src/cacheExchange.ts
+++ b/exchanges/graphcache/src/cacheExchange.ts
@@ -4,7 +4,6 @@ import {
   makeOperation,
   Operation,
   OperationResult,
-  OperationContext,
   RequestPolicy,
   CacheOutcome,
 } from '@urql/core';
@@ -145,7 +144,6 @@ export const cacheExchange =
 
     // This registers queries with the data layer to ensure commutativity
     const prepareForwardedOperation = (operation: Operation) => {
-      let context: Partial<OperationContext> | undefined;
       if (operation.kind === 'query') {
         // Pre-reserve the position of the result layer
         reserveLayer(store.data, operation.key);
@@ -158,14 +156,34 @@ export const cacheExchange =
         // Mark operation layer as done
         noopDataState(store.data, operation.key);
         return operation;
-      } else if (
+      }
+
+      const query = formatDocument(operation.query);
+      operation = makeOperation(
+        operation.kind,
+        {
+          key: operation.key,
+          query,
+          variables: operation.variables
+            ? filterVariables(getMainOperation(query), operation.variables)
+            : operation.variables,
+        },
+        { ...operation.context }
+      );
+
+      if (
         operation.kind === 'mutation' &&
         operation.context.requestPolicy !== 'network-only'
       ) {
         operations.set(operation.key, operation);
         // This executes an optimistic update for mutations and registers it if necessary
         initDataState('write', store.data, operation.key, true, false);
-        const { dependencies } = _write(store, operation, undefined, undefined);
+        const { dependencies } = _write(
+          store,
+          operation as any,
+          undefined,
+          undefined
+        );
         clearDataState();
         if (dependencies.size) {
           // Update blocked optimistic dependencies
@@ -180,24 +198,11 @@ export const cacheExchange =
           executePendingOperations(operation, pendingOperations, true);
 
           // Mark operation as optimistic
-          context = { optimistic: true };
+          operation.context.optimistic = true;
         }
       }
 
-      return makeOperation(
-        operation.kind,
-        {
-          key: operation.key,
-          query: formatDocument(operation.query),
-          variables: operation.variables
-            ? filterVariables(
-                getMainOperation(operation.query),
-                operation.variables
-              )
-            : operation.variables,
-        },
-        { ...operation.context, ...context }
-      );
+      return operation;
     };
 
     // This updates the known dependencies for the passed operation
@@ -246,9 +251,7 @@ export const cacheExchange =
       result: OperationResult,
       pendingOperations: Operations
     ): OperationResult => {
-      // Retrieve the original operation to remove changes made by formatDocument
-      const operation =
-        operations.get(result.operation.key) || result.operation;
+      const { operation } = result;
       if (operation.kind === 'mutation') {
         // Collect previous dependencies that have been written for optimistic updates
         const dependencies = optimisticKeysToDependencies.get(operation.key);
diff --git a/exchanges/graphcache/src/offlineExchange.ts b/exchanges/graphcache/src/offlineExchange.ts
index db138e5a..ca205850 100644
--- a/exchanges/graphcache/src/offlineExchange.ts
+++ b/exchanges/graphcache/src/offlineExchange.ts
@@ -13,7 +13,6 @@ import {
 } from '@urql/core';
 
 import { SerializedRequest, CacheExchangeOpts, StorageAdapter } from './types';
-
 import { cacheExchange } from './cacheExchange';
 import { toRequestPolicy } from './helpers/operation';
 
diff --git a/exchanges/graphcache/src/operations/query.ts b/exchanges/graphcache/src/operations/query.ts
index cf4d2b5f..96ca957c 100644
--- a/exchanges/graphcache/src/operations/query.ts
+++ b/exchanges/graphcache/src/operations/query.ts
@@ -1,4 +1,4 @@
-import { CombinedError } from '@urql/core';
+import { formatDocument, FormattedNode, CombinedError } from '@urql/core';
 
 import {
   FieldNode,
@@ -80,14 +80,15 @@ export const _query = (
   input?: Data | null | undefined,
   error?: CombinedError | undefined
 ): QueryResult => {
-  const operation = getMainOperation(request.query);
+  const query = formatDocument(request.query);
+  const operation = getMainOperation(query);
   const rootKey = store.rootFields[operation.operation];
   const rootSelect = getSelectionSet(operation);
 
   const ctx = makeContext(
     store,
     normalizeVariables(operation, request.variables),
-    getFragments(request.query),
+    getFragments(query),
     rootKey,
     rootKey,
     error
@@ -127,7 +128,7 @@ export const _query = (
 const readRoot = (
   ctx: Context,
   entityKey: string,
-  select: SelectionSet,
+  select: FormattedNode<SelectionSet>,
   input: Data
 ): Data => {
   const typename = ctx.store.rootNames[entityKey]
@@ -145,7 +146,7 @@ const readRoot = (
     ctx
   );
 
-  let node: FieldNode | void;
+  let node: FormattedNode<FieldNode> | void;
   let hasChanged = InMemoryData.currentForeignData;
   const output = InMemoryData.makeData(input);
   while ((node = iterate())) {
@@ -179,7 +180,7 @@ const readRoot = (
 
 const readRootField = (
   ctx: Context,
-  select: SelectionSet,
+  select: FormattedNode<SelectionSet>,
   originalData: Link<Data>
 ): Link<Data> => {
   if (Array.isArray(originalData)) {
@@ -213,16 +214,16 @@ const readRootField = (
 
 export const _queryFragment = (
   store: Store,
-  query: DocumentNode,
+  query: FormattedNode<DocumentNode>,
   entity: Partial<Data> | string,
   variables?: Variables,
   fragmentName?: string
 ): Data | null => {
   const fragments = getFragments(query);
 
-  let fragment: FragmentDefinitionNode;
+  let fragment: FormattedNode<FragmentDefinitionNode>;
   if (fragmentName) {
-    fragment = fragments[fragmentName] as FragmentDefinitionNode;
+    fragment = fragments[fragmentName]!;
     if (!fragment) {
       warn(
         'readFragment(...) was called with a fragment name that does not exist.\n' +
@@ -238,7 +239,7 @@ export const _queryFragment = (
     }
   } else {
     const names = Object.keys(fragments);
-    fragment = fragments[names[0]] as FragmentDefinitionNode;
+    fragment = fragments[names[0]]!;
     if (!fragment) {
       warn(
         'readFragment(...) was called with an empty fragment.\n' +
@@ -297,7 +298,7 @@ export const _queryFragment = (
 const readSelection = (
   ctx: Context,
   key: string,
-  select: SelectionSet,
+  select: FormattedNode<SelectionSet>,
   input: Data,
   result?: Data
 ): Data | undefined => {
@@ -352,7 +353,7 @@ const readSelection = (
   let hasPartials = false;
   let hasNext = false;
   let hasChanged = InMemoryData.currentForeignData;
-  let node: FieldNode | void;
+  let node: FormattedNode<FieldNode> | void;
   const output = InMemoryData.makeData(input);
   while ((node = iterate()) !== undefined) {
     // Derive the needed data from our node.
@@ -511,7 +512,7 @@ const resolveResolverResult = (
   typename: string,
   fieldName: string,
   key: string,
-  select: SelectionSet,
+  select: FormattedNode<SelectionSet>,
   prevData: void | null | Data | Data[],
   result: void | DataField,
   isOwnedData: boolean
@@ -583,7 +584,7 @@ const resolveLink = (
   link: Link | Link[],
   typename: string,
   fieldName: string,
-  select: SelectionSet,
+  select: FormattedNode<SelectionSet>,
   prevData: void | null | Data | Data[],
   isOwnedData: boolean
 ): DataField | undefined => {
diff --git a/exchanges/graphcache/src/operations/shared.ts b/exchanges/graphcache/src/operations/shared.ts
index 97681956..57198ef1 100644
--- a/exchanges/graphcache/src/operations/shared.ts
+++ b/exchanges/graphcache/src/operations/shared.ts
@@ -1,6 +1,7 @@
-import { CombinedError, ErrorLike } from '@urql/core';
+import { CombinedError, ErrorLike, FormattedNode } from '@urql/core';
 
 import {
+  Kind,
   FieldNode,
   InlineFragmentNode,
   FragmentDefinitionNode,
@@ -8,12 +9,10 @@ import {
 
 import {
   isDeferred,
-  isInlineFragment,
   getTypeCondition,
   getSelectionSet,
   getName,
   SelectionSet,
-  isFieldNode,
 } from '../ast';
 
 import { warn, pushDebugNode, popDebugNode } from '../helpers/help';
@@ -120,7 +119,7 @@ export const updateContext = (
 };
 
 const isFragmentHeuristicallyMatching = (
-  node: InlineFragmentNode | FragmentDefinitionNode,
+  node: FormattedNode<InlineFragmentNode | FragmentDefinitionNode>,
   typename: void | string,
   entityKey: string,
   vars: Variables
@@ -146,7 +145,7 @@ const isFragmentHeuristicallyMatching = (
   return (
     currentOperation === 'write' ||
     !getSelectionSet(node).some(node => {
-      if (!isFieldNode(node)) return false;
+      if (node.kind !== Kind.FIELD) return false;
       const fieldKey = keyOfField(getName(node), getFieldArguments(node, vars));
       return !hasField(entityKey, fieldKey);
     })
@@ -154,21 +153,21 @@ const isFragmentHeuristicallyMatching = (
 };
 
 interface SelectionIterator {
-  (): FieldNode | undefined;
+  (): FormattedNode<FieldNode> | undefined;
 }
 
 export const makeSelectionIterator = (
   typename: void | string,
   entityKey: string,
   defer: boolean,
-  selectionSet: SelectionSet,
+  selectionSet: FormattedNode<SelectionSet>,
   ctx: Context
 ): SelectionIterator => {
   let child: SelectionIterator | void;
   let index = 0;
 
   return function next() {
-    let node: FieldNode | undefined;
+    let node: FormattedNode<FieldNode> | undefined;
     while (child || index < selectionSet.length) {
       node = undefined;
       deferRef = defer;
@@ -183,11 +182,12 @@ export const makeSelectionIterator = (
         const select = selectionSet[index++];
         if (!shouldInclude(select, ctx.variables)) {
           /*noop*/
-        } else if (!isFieldNode(select)) {
+        } else if (select.kind !== Kind.FIELD) {
           // A fragment is either referred to by FragmentSpread or inline
-          const fragment = !isInlineFragment(select)
-            ? ctx.fragments[getName(select)]
-            : select;
+          const fragment =
+            select.kind !== Kind.INLINE_FRAGMENT
+              ? ctx.fragments[getName(select)]
+              : select;
           if (fragment) {
             const isMatching =
               !fragment.typeCondition ||
@@ -211,7 +211,7 @@ export const makeSelectionIterator = (
               );
             }
           }
-        } else {
+        } else if (currentOperation === 'write' || !select._generated) {
           return select;
         }
       }
diff --git a/exchanges/graphcache/src/operations/write.ts b/exchanges/graphcache/src/operations/write.ts
index f9a07bb5..f5bbfe2a 100644
--- a/exchanges/graphcache/src/operations/write.ts
+++ b/exchanges/graphcache/src/operations/write.ts
@@ -1,4 +1,4 @@
-import { CombinedError } from '@urql/core';
+import { formatDocument, FormattedNode, CombinedError } from '@urql/core';
 
 import {
   FieldNode,
@@ -97,7 +97,8 @@ export const _write = (
     InMemoryData.getCurrentDependencies();
   }
 
-  const operation = getMainOperation(request.query);
+  const query = formatDocument(request.query);
+  const operation = getMainOperation(query);
   const result: WriteResult = {
     data: data || InMemoryData.makeData(),
     dependencies: InMemoryData.currentDependencies!,
@@ -107,7 +108,7 @@ export const _write = (
   const ctx = makeContext(
     store,
     normalizeVariables(operation, request.variables),
-    getFragments(request.query),
+    getFragments(query),
     kind,
     kind,
     error
@@ -128,15 +129,15 @@ export const _write = (
 
 export const _writeFragment = (
   store: Store,
-  query: DocumentNode,
+  query: FormattedNode<DocumentNode>,
   data: Partial<Data>,
   variables?: Variables,
   fragmentName?: string
 ) => {
   const fragments = getFragments(query);
-  let fragment: FragmentDefinitionNode;
+  let fragment: FormattedNode<FragmentDefinitionNode>;
   if (fragmentName) {
-    fragment = fragments[fragmentName] as FragmentDefinitionNode;
+    fragment = fragments[fragmentName]!;
     if (!fragment) {
       warn(
         'writeFragment(...) was called with a fragment name that does not exist.\n' +
@@ -152,7 +153,7 @@ export const _writeFragment = (
     }
   } else {
     const names = Object.keys(fragments);
-    fragment = fragments[names[0]] as FragmentDefinitionNode;
+    fragment = fragments[names[0]]!;
     if (!fragment) {
       warn(
         'writeFragment(...) was called with an empty fragment.\n' +
@@ -200,7 +201,7 @@ export const _writeFragment = (
 const writeSelection = (
   ctx: Context,
   entityKey: undefined | string,
-  select: SelectionSet,
+  select: FormattedNode<SelectionSet>,
   data: Data
 ) => {
   // These fields determine how we write. The `Query` root type is written
@@ -237,7 +238,7 @@ const writeSelection = (
     ctx
   );
 
-  let node: FieldNode | void;
+  let node: FormattedNode<FieldNode> | void;
   while ((node = iterate())) {
     const fieldName = getName(node);
     const fieldArgs = getFieldArguments(node, ctx.variables);
@@ -371,7 +372,7 @@ const KEYLESS_TYPE_RE = /^__|PageInfo|(Connection|Edge)$/;
 
 const writeField = (
   ctx: Context,
-  select: SelectionSet,
+  select: FormattedNode<SelectionSet>,
   data: null | Data | NullArray<Data>,
   parentFieldKey?: string,
   prevLink?: Link
diff --git a/exchanges/graphcache/src/store/store.ts b/exchanges/graphcache/src/store/store.ts
index 33b4258f..ccf9a667 100644
--- a/exchanges/graphcache/src/store/store.ts
+++ b/exchanges/graphcache/src/store/store.ts
@@ -180,7 +180,6 @@ export class Store<
     updater: (data: T | null) => T | null
   ): void {
     const request = createRequest(input.query, input.variables!);
-    request.query = formatDocument(request.query);
     const output = updater(this.readQuery(request));
     if (output !== null) {
       _write(this, request, output as any, undefined);
@@ -189,7 +188,6 @@ export class Store<
 
   readQuery<T = Data, V = Variables>(input: QueryInput<T, V>): T | null {
     const request = createRequest(input.query, input.variables!);
-    request.query = formatDocument(request.query);
     return _query(this, request, undefined, undefined).data as T | null;
   }
 
diff --git a/exchanges/graphcache/src/types.ts b/exchanges/graphcache/src/types.ts
index 81fc5863..9446da19 100644
--- a/exchanges/graphcache/src/types.ts
+++ b/exchanges/graphcache/src/types.ts
@@ -3,10 +3,11 @@ import {
   DocumentInput,
   RequestExtensions,
   TypedDocumentNode,
+  FormattedNode,
   ErrorLike,
 } from '@urql/core';
 
-import { FragmentDefinitionNode } from '@0no-co/graphql.web';
+import { DocumentNode, FragmentDefinitionNode } from '@0no-co/graphql.web';
 import { IntrospectionData } from './ast';
 
 /** Nullable GraphQL list types of `T`.
@@ -26,7 +27,7 @@ export type NullArray<T> = Array<null | T | NullArray<T>>;
  * executing.
  */
 export interface Fragments {
-  [fragmentName: string]: void | FragmentDefinitionNode;
+  [fragmentName: string]: void | FormattedNode<FragmentDefinitionNode>;
 }
 
 /** Non-object JSON values as serialized by a GraphQL API
@@ -184,7 +185,7 @@ export interface KeyInfo {
  * GraphQL operation: its query document and variables.
  */
 export interface OperationRequest {
-  query: Exclude<DocumentInput<any, any>, string>;
+  query: FormattedNode<DocumentNode> | DocumentNode;
   variables?: any;
 }
 
diff --git a/packages/core/src/exchanges/cache.ts b/packages/core/src/exchanges/cache.ts
index 5d8db362..98bef757 100755
--- a/packages/core/src/exchanges/cache.ts
+++ b/packages/core/src/exchanges/cache.ts
@@ -7,7 +7,7 @@ import { Exchange, Operation, OperationResult } from '../types';
 import {
   makeOperation,
   addMetadata,
-  collectTypesFromResponse,
+  collectTypenames,
   formatDocument,
 } from '../utils';
 
@@ -126,7 +126,7 @@ export const cacheExchange: Exchange = ({ forward, client, dispatchDebug }) => {
         // than using subscriptions as “signals” to reexecute queries. However, if they’re
         // just used as signals, it’s intuitive to hook them up using `additionalTypenames`
         if (response.operation.kind !== 'subscription') {
-          typenames = collectTypesFromResponse(response.data).concat(typenames);
+          typenames = collectTypenames(response.data).concat(typenames);
         }
 
         // Invalidates the cache given a mutation's response
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index cd546277..e5ac33a0 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -1,4 +1,10 @@
 import type { GraphQLError, DocumentNode } from './utils/graphql';
+import type {
+  Kind,
+  DirectiveNode,
+  ValueNode,
+  TypeNode,
+} from '@0no-co/graphql.web';
 import { Subscription, Source } from 'wonka';
 import { Client } from './client';
 import { CombinedError } from './utils/error';
@@ -36,6 +42,36 @@ export type TypedDocumentNode<
   __ensureTypesOfVariablesAndResultMatching?: (variables: Variables) => Result;
 };
 
+/** GraphQL nodes with added `_directives` dictionary on nodes with directives.
+ *
+ * @remarks
+ * The {@link formatDocument} utility processes documents to add `__typename`
+ * fields to them. It additionally provides additional directives processing
+ * and outputs this type.
+ *
+ * When applied, every node with non-const directives, will have an additional
+ * `_directives` dictionary added to it, and filter directives starting with
+ * a leading `_` underscore from the directives array.
+ */
+export type FormattedNode<Node> = Node extends readonly (infer Child)[]
+  ? readonly FormattedNode<Child>[]
+  : Node extends ValueNode | TypeNode
+  ? Node
+  : Node extends { kind: Kind }
+  ? {
+      [K in Exclude<keyof Node, 'directives' | 'loc'>]: FormattedNode<Node[K]>;
+    } extends infer Node
+    ? Node extends {
+        kind: Kind.FIELD | Kind.INLINE_FRAGMENT | Kind.FRAGMENT_SPREAD;
+      }
+      ? Node & {
+          _generated?: boolean;
+          _directives?: Record<string, DirectiveNode> | undefined;
+        }
+      : Node
+    : Node
+  : Node;
+
 /** Any GraphQL `DocumentNode` or query string input.
  *
  * @remarks
diff --git a/packages/core/src/utils/collectTypenames.ts b/packages/core/src/utils/collectTypenames.ts
new file mode 100755
index 00000000..0b4e3cbb
--- /dev/null
+++ b/packages/core/src/utils/collectTypenames.ts
@@ -0,0 +1,30 @@
+interface EntityLike {
+  [key: string]: EntityLike | EntityLike[] | any;
+  __typename: string | null | void;
+}
+
+const collectTypes = (obj: EntityLike | EntityLike[], types: Set<string>) => {
+  if (Array.isArray(obj)) {
+    for (const item of obj) collectTypes(item, types);
+  } else if (typeof obj === 'object' && obj !== null) {
+    for (const key in obj) {
+      if (key === '__typename' && typeof obj[key] === 'string') {
+        types.add(obj[key] as string);
+      } else {
+        collectTypes(obj[key], types);
+      }
+    }
+  }
+
+  return types;
+};
+
+/** Finds and returns a list of `__typename` fields found in response data.
+ *
+ * @privateRemarks
+ * This is used by `@urql/core`’s document `cacheExchange` to find typenames
+ * in a given GraphQL response’s data.
+ */
+export const collectTypenames = (response: object): string[] => [
+  ...collectTypes(response as EntityLike, new Set()),
+];
diff --git a/packages/core/src/utils/typenames.ts b/packages/core/src/utils/formatDocument.ts
old mode 100755
new mode 100644
similarity index 58%
rename from packages/core/src/utils/typenames.ts
rename to packages/core/src/utils/formatDocument.ts
index 5f227e27..84425c34
--- a/packages/core/src/utils/typenames.ts
+++ b/packages/core/src/utils/formatDocument.ts
@@ -1,55 +1,45 @@
-import { Kind, SelectionNode, DefinitionNode } from '@0no-co/graphql.web';
+import {
+  Kind,
+  FieldNode,
+  SelectionNode,
+  DefinitionNode,
+  DirectiveNode,
+} from '@0no-co/graphql.web';
 import { KeyedDocumentNode, keyDocument } from './request';
-import { TypedDocumentNode } from '../types';
-
-interface EntityLike {
-  [key: string]: EntityLike | EntityLike[] | any;
-  __typename: string | null | void;
-}
-
-const collectTypes = (obj: EntityLike | EntityLike[], types: Set<string>) => {
-  if (Array.isArray(obj)) {
-    for (const item of obj) collectTypes(item, types);
-  } else if (typeof obj === 'object' && obj !== null) {
-    for (const key in obj) {
-      if (key === '__typename' && typeof obj[key] === 'string') {
-        types.add(obj[key] as string);
-      } else {
-        collectTypes(obj[key], types);
-      }
-    }
-  }
-
-  return types;
-};
-
-/** Finds and returns a list of `__typename` fields found in response data.
- *
- * @privateRemarks
- * This is used by `@urql/core`’s document `cacheExchange` to find typenames
- * in a given GraphQL response’s data.
- */
-export const collectTypesFromResponse = (response: object): string[] => [
-  ...collectTypes(response as EntityLike, new Set()),
-];
+import { FormattedNode, TypedDocumentNode } from '../types';
 
 const formatNode = <
   T extends SelectionNode | DefinitionNode | TypedDocumentNode<any, any>
 >(
   node: T
-): T => {
-  let hasChanged = false;
-
+): FormattedNode<T> => {
   if ('definitions' in node) {
-    const definitions: DefinitionNode[] = [];
+    const definitions: FormattedNode<DefinitionNode>[] = [];
     for (const definition of node.definitions) {
       const newDefinition = formatNode(definition);
-      hasChanged = hasChanged || newDefinition !== definition;
       definitions.push(newDefinition);
     }
-    if (hasChanged) return { ...node, definitions };
-  } else if ('selectionSet' in node) {
-    const selections: SelectionNode[] = [];
+
+    return { ...node, definitions } as FormattedNode<T>;
+  }
+
+  if ('directives' in node && node.directives && node.directives.length) {
+    const directives: DirectiveNode[] = [];
+    const _directives = {};
+    for (const directive of node.directives) {
+      let name = directive.name.value;
+      if (name[0] !== '_') {
+        directives.push(directive);
+      } else {
+        name = name.slice(1);
+      }
+      _directives[name] = directive;
+    }
+    node = { ...node, directives, _directives };
+  }
+
+  if ('selectionSet' in node) {
+    const selections: FormattedNode<SelectionNode>[] = [];
     let hasTypename = node.kind === Kind.OPERATION_DEFINITION;
     if (node.selectionSet) {
       for (const selection of node.selectionSet.selections || []) {
@@ -59,33 +49,36 @@ const formatNode = <
             selection.name.value === '__typename' &&
             !selection.alias);
         const newSelection = formatNode(selection);
-        hasChanged = hasChanged || newSelection !== selection;
         selections.push(newSelection);
       }
+
       if (!hasTypename) {
-        hasChanged = true;
         selections.push({
           kind: Kind.FIELD,
           name: {
             kind: Kind.NAME,
             value: '__typename',
           },
-        });
+          _generated: true,
+        } as FormattedNode<FieldNode>);
       }
-      if (hasChanged)
-        return { ...node, selectionSet: { ...node.selectionSet, selections } };
+
+      return {
+        ...node,
+        selectionSet: { ...node.selectionSet, selections },
+      } as FormattedNode<T>;
     }
   }
 
-  return node;
+  return node as FormattedNode<T>;
 };
 
 const formattedDocs = new Map<number, KeyedDocumentNode>();
 
-/** Adds `__typename` fields to a GraphQL `DocumentNode`.
+/** Formats a GraphQL document to add `__typename` fields and process client-side directives.
  *
  * @param node - a {@link DocumentNode}.
- * @returns a copy of the passed {@link DocumentNode} with added `__typename` introspection fields.
+ * @returns a {@link FormattedDocument}
  *
  * @remarks
  * Cache {@link Exchange | Exchanges} will require typename introspection to
@@ -93,6 +86,10 @@ const formattedDocs = new Map<number, KeyedDocumentNode>();
  * this function is used to add the `__typename` fields to non-root
  * selection sets of a GraphQL document.
  *
+ * Additionally, this utility will process directives, filter out client-side
+ * directives starting with an `_` underscore, and place a `_directives` dictionary
+ * on selection nodes.
+ *
  * This utility also preserves the internally computed key of the
  * document as created by {@link createRequest} to avoid any
  * formatting from being duplicated.
@@ -102,7 +99,7 @@ const formattedDocs = new Map<number, KeyedDocumentNode>();
  */
 export const formatDocument = <T extends TypedDocumentNode<any, any>>(
   node: T
-): T => {
+): FormattedNode<T> => {
   const query = keyDocument(node);
 
   let result = formattedDocs.get(query.__key);
@@ -121,5 +118,5 @@ export const formatDocument = <T extends TypedDocumentNode<any, any>>(
     });
   }
 
-  return result as unknown as T;
+  return result as FormattedNode<T>;
 };
diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts
index 4956d90b..cd291ca1 100644
--- a/packages/core/src/utils/index.ts
+++ b/packages/core/src/utils/index.ts
@@ -1,8 +1,9 @@
 export * from './error';
 export * from './request';
 export * from './result';
-export * from './typenames';
 export * from './variables';
+export * from './collectTypenames';
+export * from './formatDocument';
 export * from './maskTypename';
 export * from './streamUtils';
 export * from './operation';
diff --git a/scripts/rollup/config.mjs b/scripts/rollup/config.mjs
index ddb09e40..59311d57 100644
--- a/scripts/rollup/config.mjs
+++ b/scripts/rollup/config.mjs
@@ -29,8 +29,7 @@ const input = settings.sources.reduce((acc, source) => {
     const rel = relative(source.dir, process.cwd());
     plugins.push({
       async writeBundle() {
-        await fs.mkdir(source.dir, { recursive: true });
-        await fs.writeFile(join(source.dir, 'package.json'), JSON.stringify({
+        const packageJson = JSON.stringify({
           name: source.name,
           private: true,
           version: '0.0.0',
@@ -40,14 +39,17 @@ const input = settings.sources.reduce((acc, source) => {
           source: join(rel, source.source),
           exports: {
             '.': {
+              types: join(rel, source.types),
               import: join(rel, source.module),
               require: join(rel, source.main),
-              types: join(rel, source.types),
               source: join(rel, source.source),
             },
             './package.json': './package.json'
           },
-        }, null, 2));
+        }, null, 2).trim();
+
+        await fs.mkdir(source.dir, { recursive: true });
+        await fs.writeFile(join(source.dir, 'package.json'), packageJson + '\n');
       },
     });
   }


Test patch:
diff --git a/exchanges/graphcache/src/ast/traversal.test.ts b/exchanges/graphcache/src/ast/traversal.test.ts
index 16e45938..e14f7c95 100644
--- a/exchanges/graphcache/src/ast/traversal.test.ts
+++ b/exchanges/graphcache/src/ast/traversal.test.ts
@@ -1,4 +1,4 @@
-import { gql } from '@urql/core';
+import { formatDocument, gql } from '@urql/core';
 import { describe, it, expect } from 'vitest';
 
 import { getSelectionSet } from './node';
@@ -6,33 +6,36 @@ import { getMainOperation, shouldInclude } from './traversal';
 
 describe('getMainOperation', () => {
   it('retrieves the first operation', () => {
-    const doc = gql`
+    const doc = formatDocument(gql`
       query Query {
         field
       }
-    `;
+    `);
+
     const operation = getMainOperation(doc);
     expect(operation).toBe(doc.definitions[0]);
   });
 
   it('throws when no operation is found', () => {
-    const doc = gql`
+    const doc = formatDocument(gql`
       fragment _ on Query {
         field
       }
-    `;
+    `);
+
     expect(() => getMainOperation(doc)).toThrow();
   });
 });
 
 describe('shouldInclude', () => {
   it('should include fields with truthy @include or falsy @skip directives', () => {
-    const doc = gql`
+    const doc = formatDocument(gql`
       {
         fieldA @include(if: true)
         fieldB @skip(if: false)
       }
-    `;
+    `);
+
     const fieldA = getSelectionSet(getMainOperation(doc))[0];
     const fieldB = getSelectionSet(getMainOperation(doc))[1];
     expect(shouldInclude(fieldA, {})).toBe(true);
@@ -40,12 +43,13 @@ describe('shouldInclude', () => {
   });
 
   it('should exclude fields with falsy @include or truthy @skip directives', () => {
-    const doc = gql`
+    const doc = formatDocument(gql`
       {
         fieldA @include(if: false)
         fieldB @skip(if: true)
       }
-    `;
+    `);
+
     const fieldA = getSelectionSet(getMainOperation(doc))[0];
     const fieldB = getSelectionSet(getMainOperation(doc))[1];
     expect(shouldInclude(fieldA, {})).toBe(false);
@@ -53,31 +57,34 @@ describe('shouldInclude', () => {
   });
 
   it('ignore other directives', () => {
-    const doc = gql`
+    const doc = formatDocument(gql`
       {
         field @test(if: false)
       }
-    `;
+    `);
+
     const field = getSelectionSet(getMainOperation(doc))[0];
     expect(shouldInclude(field, {})).toBe(true);
   });
 
   it('ignore unknown arguments on directives', () => {
-    const doc = gql`
+    const doc = formatDocument(gql`
       {
         field @skip(if: true, other: false)
       }
-    `;
+    `);
+
     const field = getSelectionSet(getMainOperation(doc))[0];
     expect(shouldInclude(field, {})).toBe(false);
   });
 
   it('ignore directives with invalid first arguments', () => {
-    const doc = gql`
+    const doc = formatDocument(gql`
       {
         field @skip(other: true)
       }
-    `;
+    `);
+
     const field = getSelectionSet(getMainOperation(doc))[0];
     expect(shouldInclude(field, {})).toBe(true);
   });
diff --git a/exchanges/graphcache/src/ast/variables.test.ts b/exchanges/graphcache/src/ast/variables.test.ts
index cdccd740..b1b42cc9 100644
--- a/exchanges/graphcache/src/ast/variables.test.ts
+++ b/exchanges/graphcache/src/ast/variables.test.ts
@@ -1,4 +1,4 @@
-import { gql } from '@urql/core';
+import { formatDocument, gql } from '@urql/core';
 import { describe, it, expect } from 'vitest';
 import { getMainOperation } from './traversal';
 import { normalizeVariables, filterVariables } from './variables';
@@ -7,11 +7,13 @@ describe('normalizeVariables', () => {
   it('normalizes variables', () => {
     const input = { x: 42 };
     const operation = getMainOperation(
-      gql`
-        query ($x: Int!) {
-          field
-        }
-      `
+      formatDocument(
+        gql`
+          query ($x: Int!) {
+            field
+          }
+        `
+      )
     );
     const normalized = normalizeVariables(operation, input);
     expect(normalized).toEqual({ x: 42 });
@@ -20,11 +22,13 @@ describe('normalizeVariables', () => {
   it('normalizes variables with defaults', () => {
     const input = { x: undefined };
     const operation = getMainOperation(
-      gql`
-        query ($x: Int! = 42) {
-          field
-        }
-      `
+      formatDocument(
+        gql`
+          query ($x: Int! = 42) {
+            field
+          }
+        `
+      )
     );
     const normalized = normalizeVariables(operation, input);
     expect(normalized).toEqual({ x: 42 });
@@ -33,11 +37,13 @@ describe('normalizeVariables', () => {
   it('normalizes variables even with missing fields', () => {
     const input = { x: undefined };
     const operation = getMainOperation(
-      gql`
-        query ($x: Int!) {
-          field
-        }
-      `
+      formatDocument(
+        gql`
+          query ($x: Int!) {
+            field
+          }
+        `
+      )
     );
     const normalized = normalizeVariables(operation, input);
     expect(normalized).toEqual({});
@@ -45,11 +51,13 @@ describe('normalizeVariables', () => {
 
   it('skips normalizing for queries without variables', () => {
     const operation = getMainOperation(
-      gql`
-        query {
-          field
-        }
-      `
+      formatDocument(
+        gql`
+          query {
+            field
+          }
+        `
+      )
     );
     (operation as any).variableDefinitions = undefined;
     const normalized = normalizeVariables(operation, {});
@@ -58,11 +66,13 @@ describe('normalizeVariables', () => {
 
   it('preserves missing variables', () => {
     const operation = getMainOperation(
-      gql`
-        query {
-          field
-        }
-      `
+      formatDocument(
+        gql`
+          query {
+            field
+          }
+        `
+      )
     );
     (operation as any).variableDefinitions = undefined;
     const normalized = normalizeVariables(operation, { test: true });
@@ -73,11 +83,13 @@ describe('normalizeVariables', () => {
 describe('filterVariables', () => {
   it('returns undefined when no variables are defined', () => {
     const operation = getMainOperation(
-      gql`
-        query {
-          field
-        }
-      `
+      formatDocument(
+        gql`
+          query {
+            field
+          }
+        `
+      )
     );
     const vars = filterVariables(operation, { test: true });
     expect(vars).toBe(undefined);
@@ -86,11 +98,13 @@ describe('filterVariables', () => {
   it('filters out missing vars', () => {
     const input = { x: true, y: false };
     const operation = getMainOperation(
-      gql`
-        query ($x: Int!) {
-          field
-        }
-      `
+      formatDocument(
+        gql`
+          query ($x: Int!) {
+            field
+          }
+        `
+      )
     );
     const vars = filterVariables(operation, input);
     expect(vars).toEqual({ x: true });
@@ -99,11 +113,13 @@ describe('filterVariables', () => {
   it('ignores defaults', () => {
     const input = { x: undefined };
     const operation = getMainOperation(
-      gql`
-        query ($x: Int! = 42) {
-          field
-        }
-      `
+      formatDocument(
+        gql`
+          query ($x: Int! = 42) {
+            field
+          }
+        `
+      )
     );
     const vars = filterVariables(operation, input);
     expect(vars).toEqual({ x: undefined });
diff --git a/exchanges/graphcache/src/operations/shared.test.ts b/exchanges/graphcache/src/operations/shared.test.ts
index fdfb7111..77fdaeb5 100644
--- a/exchanges/graphcache/src/operations/shared.test.ts
+++ b/exchanges/graphcache/src/operations/shared.test.ts
@@ -1,14 +1,21 @@
 import { describe, it, expect } from 'vitest';
-import { TypedDocumentNode, gql } from '@urql/core';
+import {
+  TypedDocumentNode,
+  FormattedNode,
+  formatDocument,
+  gql,
+} from '@urql/core';
 import { FieldNode } from '@0no-co/graphql.web';
 
 import { makeSelectionIterator, deferRef } from './shared';
 import { SelectionSet } from '../ast';
 
-const selectionOfDocument = (doc: TypedDocumentNode): SelectionSet => {
-  for (const definition of doc.definitions)
+const selectionOfDocument = (
+  doc: TypedDocumentNode
+): FormattedNode<SelectionSet> => {
+  for (const definition of formatDocument(doc).definitions)
     if (definition.kind === 'OperationDefinition')
-      return definition.selectionSet.selections;
+      return definition.selectionSet.selections as FormattedNode<SelectionSet>;
   return [];
 };
 
diff --git a/packages/core/src/utils/collectTypenames.test.ts b/packages/core/src/utils/collectTypenames.test.ts
new file mode 100755
index 00000000..38367933
--- /dev/null
+++ b/packages/core/src/utils/collectTypenames.test.ts
@@ -0,0 +1,88 @@
+import { describe, it, expect } from 'vitest';
+import { collectTypenames } from './collectTypenames';
+
+describe('collectTypenames', () => {
+  it('returns all typenames included in a response as an array', () => {
+    const typeNames = collectTypenames({
+      todos: [
+        {
+          id: 1,
+          __typename: 'Todo',
+        },
+      ],
+    });
+    expect(typeNames).toEqual(['Todo']);
+  });
+
+  it('does not duplicate typenames', () => {
+    const typeNames = collectTypenames({
+      todos: [
+        {
+          id: 1,
+          __typename: 'Todo',
+        },
+        {
+          id: 3,
+          __typename: 'Todo',
+        },
+      ],
+    });
+    expect(typeNames).toEqual(['Todo']);
+  });
+
+  it('returns multiple different typenames', () => {
+    const typeNames = collectTypenames({
+      todos: [
+        {
+          id: 1,
+          __typename: 'Todo',
+        },
+        {
+          id: 3,
+          __typename: 'Avocado',
+        },
+      ],
+    });
+    expect(typeNames).toEqual(['Todo', 'Avocado']);
+  });
+
+  it('works on nested objects', () => {
+    const typeNames = collectTypenames({
+      todos: [
+        {
+          id: 1,
+          __typename: 'Todo',
+        },
+        {
+          id: 2,
+          subTask: {
+            id: 3,
+            __typename: 'SubTask',
+          },
+        },
+      ],
+    });
+    expect(typeNames).toEqual(['Todo', 'SubTask']);
+  });
+
+  it('traverses nested arrays of objects', () => {
+    const typenames = collectTypenames({
+      todos: [
+        {
+          id: 1,
+          authors: [
+            [
+              {
+                name: 'Phil',
+                __typename: 'Author',
+              },
+            ],
+          ],
+          __typename: 'Todo',
+        },
+      ],
+    });
+
+    expect(typenames).toEqual(['Author', 'Todo']);
+  });
+});
diff --git a/packages/core/src/utils/typenames.test.ts b/packages/core/src/utils/formatDocument.test.ts
old mode 100755
new mode 100644
similarity index 63%
rename from packages/core/src/utils/typenames.test.ts
rename to packages/core/src/utils/formatDocument.test.ts
index f9d6e0eb..e4ce40a8
--- a/packages/core/src/utils/typenames.test.ts
+++ b/packages/core/src/utils/formatDocument.test.ts
@@ -1,14 +1,14 @@
-import { parse, print } from '@0no-co/graphql.web';
+import { Kind, parse, print } from '@0no-co/graphql.web';
 import { describe, it, expect } from 'vitest';
-import { collectTypesFromResponse, formatDocument } from './typenames';
 import { createRequest } from './request';
+import { formatDocument } from './formatDocument';
 
 const formatTypeNames = (query: string) => {
   const typedNode = formatDocument(parse(query));
   return print(typedNode);
 };
 
-describe('formatTypeNames', () => {
+describe('formatDocument', () => {
   it('creates a new instance when adding typenames', () => {
     const doc = parse(`{ id todos { id } }`) as any;
     const newDoc = formatDocument(doc) as any;
@@ -112,90 +112,39 @@ describe('formatTypeNames', () => {
       }"
     `);
   });
-});
 
-describe('collectTypesFromResponse', () => {
-  it('returns all typenames included in a response as an array', () => {
-    const typeNames = collectTypesFromResponse({
-      todos: [
-        {
-          id: 1,
-          __typename: 'Todo',
-        },
-      ],
-    });
-    expect(typeNames).toEqual(['Todo']);
-  });
+  it('processes directives', () => {
+    const document = `
+      {
+        todos @skip {
+          id @_test
+        }
+      }
+   `;
 
-  it('does not duplicate typenames', () => {
-    const typeNames = collectTypesFromResponse({
-      todos: [
-        {
-          id: 1,
-          __typename: 'Todo',
-        },
-        {
-          id: 3,
-          __typename: 'Todo',
-        },
-      ],
-    });
-    expect(typeNames).toEqual(['Todo']);
-  });
+    const node = formatDocument(parse(document));
 
-  it('returns multiple different typenames', () => {
-    const typeNames = collectTypesFromResponse({
-      todos: [
-        {
-          id: 1,
-          __typename: 'Todo',
-        },
-        {
-          id: 3,
-          __typename: 'Avocado',
-        },
-      ],
-    });
-    expect(typeNames).toEqual(['Todo', 'Avocado']);
-  });
-
-  it('works on nested objects', () => {
-    const typeNames = collectTypesFromResponse({
-      todos: [
-        {
-          id: 1,
-          __typename: 'Todo',
-        },
-        {
-          id: 2,
-          subTask: {
-            id: 3,
-            __typename: 'SubTask',
+    expect(node).toHaveProperty(
+      'definitions.0.selectionSet.selections.0.selectionSet.selections.0._directives',
+      {
+        test: {
+          kind: Kind.DIRECTIVE,
+          arguments: [],
+          name: {
+            kind: Kind.NAME,
+            value: '_test',
           },
         },
-      ],
-    });
-    expect(typeNames).toEqual(['Todo', 'SubTask']);
-  });
-
-  it('traverses nested arrays of objects', () => {
-    const typenames = collectTypesFromResponse({
-      todos: [
-        {
-          id: 1,
-          authors: [
-            [
-              {
-                name: 'Phil',
-                __typename: 'Author',
-              },
-            ],
-          ],
-          __typename: 'Todo',
-        },
-      ],
-    });
+      }
+    );
 
-    expect(typenames).toEqual(['Author', 'Todo']);
+    expect(formatTypeNames(document)).toMatchInlineSnapshot(`
+     "{
+       todos @skip {
+         id
+         __typename
+       }
+     }"
+   `);
   });
 });
diff --git a/packages/core/src/utils/request.test.ts b/packages/core/src/utils/request.test.ts
index f8e39cb1..46a056e3 100644
--- a/packages/core/src/utils/request.test.ts
+++ b/packages/core/src/utils/request.test.ts
@@ -3,7 +3,7 @@ import { expect, it, describe } from 'vitest';
 import { parse, print } from '@0no-co/graphql.web';
 import { gql } from '../gql';
 import { createRequest, stringifyDocument } from './request';
-import { formatDocument } from './typenames';
+import { formatDocument } from './formatDocument';
 
 describe('createRequest', () => {
   it('should hash identical queries identically', () => {