···11+---
22+'@urql/exchange-graphcache': patch
33+---
44+55+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.
+5
.changeset/rude-waves-check.md
···11+---
22+'@urql/core': minor
33+---
44+55+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.
+16-13
exchanges/graphcache/src/ast/node.ts
···11import {
22 NamedTypeNode,
33 NameNode,
44+ DirectiveNode,
45 SelectionNode,
56 SelectionSetNode,
66- InlineFragmentNode,
77 FieldNode,
88 FragmentDefinitionNode,
99- Kind,
109} from '@0no-co/graphql.web';
11101212-export type SelectionSet = ReadonlyArray<SelectionNode>;
1111+import { FormattedNode } from '@urql/core';
1212+1313+export type SelectionSet = readonly FormattedNode<SelectionNode>[];
1414+1515+const EMPTY_DIRECTIVES: Record<string, DirectiveNode | undefined> = {};
1616+1717+/** Returns the directives dictionary of a given node */
1818+export const getDirectives = (node: {
1919+ _directives?: Record<string, DirectiveNode | undefined>;
2020+}) => node._directives || EMPTY_DIRECTIVES;
13211422/** Returns the name of a given node */
1523export const getName = (node: { name: NameNode }): string => node.name.value;
···25332634/** Returns the SelectionSet for a given inline or defined fragment node */
2735export const getSelectionSet = (node: {
2828- selectionSet?: SelectionSetNode;
2929-}): SelectionSet =>
3030- node.selectionSet ? node.selectionSet.selections : emptySelectionSet;
3636+ selectionSet?: FormattedNode<SelectionSetNode>;
3737+}): FormattedNode<SelectionSet> =>
3838+ (node.selectionSet
3939+ ? node.selectionSet.selections
4040+ : emptySelectionSet) as FormattedNode<SelectionSet>;
31413242export const getTypeCondition = (node: {
3343 typeCondition?: NamedTypeNode;
3444}): string | null =>
3545 node.typeCondition ? node.typeCondition.name.value : null;
3636-3737-export const isFieldNode = (node: SelectionNode): node is FieldNode =>
3838- node.kind === Kind.FIELD;
3939-4040-export const isInlineFragment = (
4141- node: SelectionNode
4242-): node is InlineFragmentNode => node.kind === Kind.INLINE_FRAGMENT;
···11-import { CombinedError } from '@urql/core';
11+import { formatDocument, FormattedNode, CombinedError } from '@urql/core';
2233import {
44 FieldNode,
···9797 InMemoryData.getCurrentDependencies();
9898 }
9999100100- const operation = getMainOperation(request.query);
100100+ const query = formatDocument(request.query);
101101+ const operation = getMainOperation(query);
101102 const result: WriteResult = {
102103 data: data || InMemoryData.makeData(),
103104 dependencies: InMemoryData.currentDependencies!,
···107108 const ctx = makeContext(
108109 store,
109110 normalizeVariables(operation, request.variables),
110110- getFragments(request.query),
111111+ getFragments(query),
111112 kind,
112113 kind,
113114 error
···128129129130export const _writeFragment = (
130131 store: Store,
131131- query: DocumentNode,
132132+ query: FormattedNode<DocumentNode>,
132133 data: Partial<Data>,
133134 variables?: Variables,
134135 fragmentName?: string
135136) => {
136137 const fragments = getFragments(query);
137137- let fragment: FragmentDefinitionNode;
138138+ let fragment: FormattedNode<FragmentDefinitionNode>;
138139 if (fragmentName) {
139139- fragment = fragments[fragmentName] as FragmentDefinitionNode;
140140+ fragment = fragments[fragmentName]!;
140141 if (!fragment) {
141142 warn(
142143 'writeFragment(...) was called with a fragment name that does not exist.\n' +
···152153 }
153154 } else {
154155 const names = Object.keys(fragments);
155155- fragment = fragments[names[0]] as FragmentDefinitionNode;
156156+ fragment = fragments[names[0]]!;
156157 if (!fragment) {
157158 warn(
158159 'writeFragment(...) was called with an empty fragment.\n' +
···200201const writeSelection = (
201202 ctx: Context,
202203 entityKey: undefined | string,
203203- select: SelectionSet,
204204+ select: FormattedNode<SelectionSet>,
204205 data: Data
205206) => {
206207 // These fields determine how we write. The `Query` root type is written
···237238 ctx
238239 );
239240240240- let node: FieldNode | void;
241241+ let node: FormattedNode<FieldNode> | void;
241242 while ((node = iterate())) {
242243 const fieldName = getName(node);
243244 const fieldArgs = getFieldArguments(node, ctx.variables);
···371372372373const writeField = (
373374 ctx: Context,
374374- select: SelectionSet,
375375+ select: FormattedNode<SelectionSet>,
375376 data: null | Data | NullArray<Data>,
376377 parentFieldKey?: string,
377378 prevLink?: Link
-2
exchanges/graphcache/src/store/store.ts
···180180 updater: (data: T | null) => T | null
181181 ): void {
182182 const request = createRequest(input.query, input.variables!);
183183- request.query = formatDocument(request.query);
184183 const output = updater(this.readQuery(request));
185184 if (output !== null) {
186185 _write(this, request, output as any, undefined);
···189188190189 readQuery<T = Data, V = Variables>(input: QueryInput<T, V>): T | null {
191190 const request = createRequest(input.query, input.variables!);
192192- request.query = formatDocument(request.query);
193191 return _query(this, request, undefined, undefined).data as T | null;
194192 }
195193
+4-3
exchanges/graphcache/src/types.ts
···33 DocumentInput,
44 RequestExtensions,
55 TypedDocumentNode,
66+ FormattedNode,
67 ErrorLike,
78} from '@urql/core';
8999-import { FragmentDefinitionNode } from '@0no-co/graphql.web';
1010+import { DocumentNode, FragmentDefinitionNode } from '@0no-co/graphql.web';
1011import { IntrospectionData } from './ast';
11121213/** Nullable GraphQL list types of `T`.
···2627 * executing.
2728 */
2829export interface Fragments {
2929- [fragmentName: string]: void | FragmentDefinitionNode;
3030+ [fragmentName: string]: void | FormattedNode<FragmentDefinitionNode>;
3031}
31323233/** Non-object JSON values as serialized by a GraphQL API
···184185 * GraphQL operation: its query document and variables.
185186 */
186187export interface OperationRequest {
187187- query: Exclude<DocumentInput<any, any>, string>;
188188+ query: FormattedNode<DocumentNode> | DocumentNode;
188189 variables?: any;
189190}
190191
+2-2
packages/core/src/exchanges/cache.ts
···77import {
88 makeOperation,
99 addMetadata,
1010- collectTypesFromResponse,
1010+ collectTypenames,
1111 formatDocument,
1212} from '../utils';
1313···126126 // than using subscriptions as “signals” to reexecute queries. However, if they’re
127127 // just used as signals, it’s intuitive to hook them up using `additionalTypenames`
128128 if (response.operation.kind !== 'subscription') {
129129- typenames = collectTypesFromResponse(response.data).concat(typenames);
129129+ typenames = collectTypenames(response.data).concat(typenames);
130130 }
131131132132 // Invalidates the cache given a mutation's response
+36
packages/core/src/types.ts
···11import type { GraphQLError, DocumentNode } from './utils/graphql';
22+import type {
33+ Kind,
44+ DirectiveNode,
55+ ValueNode,
66+ TypeNode,
77+} from '@0no-co/graphql.web';
28import { Subscription, Source } from 'wonka';
39import { Client } from './client';
410import { CombinedError } from './utils/error';
···3541 */
3642 __ensureTypesOfVariablesAndResultMatching?: (variables: Variables) => Result;
3743};
4444+4545+/** GraphQL nodes with added `_directives` dictionary on nodes with directives.
4646+ *
4747+ * @remarks
4848+ * The {@link formatDocument} utility processes documents to add `__typename`
4949+ * fields to them. It additionally provides additional directives processing
5050+ * and outputs this type.
5151+ *
5252+ * When applied, every node with non-const directives, will have an additional
5353+ * `_directives` dictionary added to it, and filter directives starting with
5454+ * a leading `_` underscore from the directives array.
5555+ */
5656+export type FormattedNode<Node> = Node extends readonly (infer Child)[]
5757+ ? readonly FormattedNode<Child>[]
5858+ : Node extends ValueNode | TypeNode
5959+ ? Node
6060+ : Node extends { kind: Kind }
6161+ ? {
6262+ [K in Exclude<keyof Node, 'directives' | 'loc'>]: FormattedNode<Node[K]>;
6363+ } extends infer Node
6464+ ? Node extends {
6565+ kind: Kind.FIELD | Kind.INLINE_FRAGMENT | Kind.FRAGMENT_SPREAD;
6666+ }
6767+ ? Node & {
6868+ _generated?: boolean;
6969+ _directives?: Record<string, DirectiveNode> | undefined;
7070+ }
7171+ : Node
7272+ : Node
7373+ : Node;
38743975/** Any GraphQL `DocumentNode` or query string input.
4076 *
···11+interface EntityLike {
22+ [key: string]: EntityLike | EntityLike[] | any;
33+ __typename: string | null | void;
44+}
55+66+const collectTypes = (obj: EntityLike | EntityLike[], types: Set<string>) => {
77+ if (Array.isArray(obj)) {
88+ for (const item of obj) collectTypes(item, types);
99+ } else if (typeof obj === 'object' && obj !== null) {
1010+ for (const key in obj) {
1111+ if (key === '__typename' && typeof obj[key] === 'string') {
1212+ types.add(obj[key] as string);
1313+ } else {
1414+ collectTypes(obj[key], types);
1515+ }
1616+ }
1717+ }
1818+1919+ return types;
2020+};
2121+2222+/** Finds and returns a list of `__typename` fields found in response data.
2323+ *
2424+ * @privateRemarks
2525+ * This is used by `@urql/core`’s document `cacheExchange` to find typenames
2626+ * in a given GraphQL response’s data.
2727+ */
2828+export const collectTypenames = (response: object): string[] => [
2929+ ...collectTypes(response as EntityLike, new Set()),
3030+];
+150
packages/core/src/utils/formatDocument.test.ts
···11+import { Kind, parse, print } from '@0no-co/graphql.web';
22+import { describe, it, expect } from 'vitest';
33+import { createRequest } from './request';
44+import { formatDocument } from './formatDocument';
55+66+const formatTypeNames = (query: string) => {
77+ const typedNode = formatDocument(parse(query));
88+ return print(typedNode);
99+};
1010+1111+describe('formatDocument', () => {
1212+ it('creates a new instance when adding typenames', () => {
1313+ const doc = parse(`{ id todos { id } }`) as any;
1414+ const newDoc = formatDocument(doc) as any;
1515+ expect(doc).not.toBe(newDoc);
1616+ expect(doc.definitions).not.toBe(newDoc.definitions);
1717+ expect(doc.definitions[0]).not.toBe(newDoc.definitions[0]);
1818+ expect(doc.definitions[0].selectionSet).not.toBe(
1919+ newDoc.definitions[0].selectionSet
2020+ );
2121+ expect(doc.definitions[0].selectionSet.selections).not.toBe(
2222+ newDoc.definitions[0].selectionSet.selections
2323+ );
2424+ // Here we're equal again:
2525+ expect(doc.definitions[0].selectionSet.selections[0]).toBe(
2626+ newDoc.definitions[0].selectionSet.selections[0]
2727+ );
2828+ // Not equal again:
2929+ expect(doc.definitions[0].selectionSet.selections[1]).not.toBe(
3030+ newDoc.definitions[0].selectionSet.selections[1]
3131+ );
3232+ expect(doc.definitions[0].selectionSet.selections[1].selectionSet).not.toBe(
3333+ newDoc.definitions[0].selectionSet.selections[1].selectionSet
3434+ );
3535+ // Equal again:
3636+ expect(
3737+ doc.definitions[0].selectionSet.selections[1].selectionSet.selections[0]
3838+ ).toBe(
3939+ newDoc.definitions[0].selectionSet.selections[1].selectionSet
4040+ .selections[0]
4141+ );
4242+ });
4343+4444+ it('preserves the hashed key of the resulting query', () => {
4545+ const doc = parse(`{ id todos { id } }`) as any;
4646+ const expectedKey = createRequest(doc, undefined).key;
4747+ const formattedDoc = formatDocument(doc);
4848+ expect(formattedDoc).not.toBe(doc);
4949+ const actualKey = createRequest(formattedDoc, undefined).key;
5050+ expect(expectedKey).toBe(actualKey);
5151+ });
5252+5353+ it('does not preserve the referential integrity with a cloned object', () => {
5454+ const doc = parse(`{ id todos { id } }`);
5555+ const formattedDoc = formatDocument(doc);
5656+ expect(formattedDoc).not.toBe(doc);
5757+ const query = { ...formattedDoc };
5858+ const reformattedDoc = formatDocument(query);
5959+ expect(reformattedDoc).not.toBe(doc);
6060+ });
6161+6262+ it('preserves custom properties', () => {
6363+ const doc = parse(`{ todos { id } }`) as any;
6464+ doc.documentId = '123';
6565+ expect((formatDocument(doc) as any).documentId).toBe(doc.documentId);
6666+ });
6767+6868+ it('adds typenames to a query string', () => {
6969+ expect(formatTypeNames(`{ todos { id } }`)).toMatchInlineSnapshot(`
7070+ "{
7171+ todos {
7272+ id
7373+ __typename
7474+ }
7575+ }"
7676+ `);
7777+ });
7878+7979+ it('does not duplicate typenames', () => {
8080+ expect(
8181+ formatTypeNames(`{
8282+ todos {
8383+ id
8484+ __typename
8585+ }
8686+ }`)
8787+ ).toMatchInlineSnapshot(`
8888+ "{
8989+ todos {
9090+ id
9191+ __typename
9292+ }
9393+ }"
9494+ `);
9595+ });
9696+9797+ it('does add typenames when it is aliased', () => {
9898+ expect(
9999+ formatTypeNames(`{
100100+ todos {
101101+ id
102102+ typename: __typename
103103+ }
104104+ }`)
105105+ ).toMatchInlineSnapshot(`
106106+ "{
107107+ todos {
108108+ id
109109+ typename: __typename
110110+ __typename
111111+ }
112112+ }"
113113+ `);
114114+ });
115115+116116+ it('processes directives', () => {
117117+ const document = `
118118+ {
119119+ todos @skip {
120120+ id @_test
121121+ }
122122+ }
123123+ `;
124124+125125+ const node = formatDocument(parse(document));
126126+127127+ expect(node).toHaveProperty(
128128+ 'definitions.0.selectionSet.selections.0.selectionSet.selections.0._directives',
129129+ {
130130+ test: {
131131+ kind: Kind.DIRECTIVE,
132132+ arguments: [],
133133+ name: {
134134+ kind: Kind.NAME,
135135+ value: '_test',
136136+ },
137137+ },
138138+ }
139139+ );
140140+141141+ expect(formatTypeNames(document)).toMatchInlineSnapshot(`
142142+ "{
143143+ todos @skip {
144144+ id
145145+ __typename
146146+ }
147147+ }"
148148+ `);
149149+ });
150150+});
+122
packages/core/src/utils/formatDocument.ts
···11+import {
22+ Kind,
33+ FieldNode,
44+ SelectionNode,
55+ DefinitionNode,
66+ DirectiveNode,
77+} from '@0no-co/graphql.web';
88+import { KeyedDocumentNode, keyDocument } from './request';
99+import { FormattedNode, TypedDocumentNode } from '../types';
1010+1111+const formatNode = <
1212+ T extends SelectionNode | DefinitionNode | TypedDocumentNode<any, any>
1313+>(
1414+ node: T
1515+): FormattedNode<T> => {
1616+ if ('definitions' in node) {
1717+ const definitions: FormattedNode<DefinitionNode>[] = [];
1818+ for (const definition of node.definitions) {
1919+ const newDefinition = formatNode(definition);
2020+ definitions.push(newDefinition);
2121+ }
2222+2323+ return { ...node, definitions } as FormattedNode<T>;
2424+ }
2525+2626+ if ('directives' in node && node.directives && node.directives.length) {
2727+ const directives: DirectiveNode[] = [];
2828+ const _directives = {};
2929+ for (const directive of node.directives) {
3030+ let name = directive.name.value;
3131+ if (name[0] !== '_') {
3232+ directives.push(directive);
3333+ } else {
3434+ name = name.slice(1);
3535+ }
3636+ _directives[name] = directive;
3737+ }
3838+ node = { ...node, directives, _directives };
3939+ }
4040+4141+ if ('selectionSet' in node) {
4242+ const selections: FormattedNode<SelectionNode>[] = [];
4343+ let hasTypename = node.kind === Kind.OPERATION_DEFINITION;
4444+ if (node.selectionSet) {
4545+ for (const selection of node.selectionSet.selections || []) {
4646+ hasTypename =
4747+ hasTypename ||
4848+ (selection.kind === Kind.FIELD &&
4949+ selection.name.value === '__typename' &&
5050+ !selection.alias);
5151+ const newSelection = formatNode(selection);
5252+ selections.push(newSelection);
5353+ }
5454+5555+ if (!hasTypename) {
5656+ selections.push({
5757+ kind: Kind.FIELD,
5858+ name: {
5959+ kind: Kind.NAME,
6060+ value: '__typename',
6161+ },
6262+ _generated: true,
6363+ } as FormattedNode<FieldNode>);
6464+ }
6565+6666+ return {
6767+ ...node,
6868+ selectionSet: { ...node.selectionSet, selections },
6969+ } as FormattedNode<T>;
7070+ }
7171+ }
7272+7373+ return node as FormattedNode<T>;
7474+};
7575+7676+const formattedDocs = new Map<number, KeyedDocumentNode>();
7777+7878+/** Formats a GraphQL document to add `__typename` fields and process client-side directives.
7979+ *
8080+ * @param node - a {@link DocumentNode}.
8181+ * @returns a {@link FormattedDocument}
8282+ *
8383+ * @remarks
8484+ * Cache {@link Exchange | Exchanges} will require typename introspection to
8585+ * recognize types in a GraphQL response. To retrieve these typenames,
8686+ * this function is used to add the `__typename` fields to non-root
8787+ * selection sets of a GraphQL document.
8888+ *
8989+ * Additionally, this utility will process directives, filter out client-side
9090+ * directives starting with an `_` underscore, and place a `_directives` dictionary
9191+ * on selection nodes.
9292+ *
9393+ * This utility also preserves the internally computed key of the
9494+ * document as created by {@link createRequest} to avoid any
9595+ * formatting from being duplicated.
9696+ *
9797+ * @see {@link https://spec.graphql.org/October2021/#sec-Type-Name-Introspection} for more information
9898+ * on typename introspection via the `__typename` field.
9999+ */
100100+export const formatDocument = <T extends TypedDocumentNode<any, any>>(
101101+ node: T
102102+): FormattedNode<T> => {
103103+ const query = keyDocument(node);
104104+105105+ let result = formattedDocs.get(query.__key);
106106+ if (!result) {
107107+ formattedDocs.set(
108108+ query.__key,
109109+ (result = formatNode(query) as KeyedDocumentNode)
110110+ );
111111+ // Ensure that the hash of the resulting document won't suddenly change
112112+ // we are marking __key as non-enumerable so when external exchanges use visit
113113+ // to manipulate a document we won't restore the previous query due to the __key
114114+ // property.
115115+ Object.defineProperty(result, '__key', {
116116+ value: query.__key,
117117+ enumerable: false,
118118+ });
119119+ }
120120+121121+ return result as FormattedNode<T>;
122122+};
+2-1
packages/core/src/utils/index.ts
···11export * from './error';
22export * from './request';
33export * from './result';
44-export * from './typenames';
54export * from './variables';
55+export * from './collectTypenames';
66+export * from './formatDocument';
67export * from './maskTypename';
78export * from './streamUtils';
89export * from './operation';
+1-1
packages/core/src/utils/request.test.ts
···33import { parse, print } from '@0no-co/graphql.web';
44import { gql } from '../gql';
55import { createRequest, stringifyDocument } from './request';
66-import { formatDocument } from './typenames';
66+import { formatDocument } from './formatDocument';
7788describe('createRequest', () => {
99 it('should hash identical queries identically', () => {