Skip to content

Commit

Permalink
Don't reexecute live queries when their cache is invalidated
Browse files Browse the repository at this point in the history
  • Loading branch information
scott-rc committed Jan 13, 2025
1 parent aa596f7 commit 53ece63
Show file tree
Hide file tree
Showing 3 changed files with 253 additions and 14 deletions.
17 changes: 3 additions & 14 deletions packages/api-client-core/src/GadgetConnection.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type { DefinitionNode, DirectiveNode, OperationDefinitionNode } from "@0no-co/graphql.web";
import type { ClientOptions, RequestPolicy } from "@urql/core";
import { Client, cacheExchange, fetchExchange, subscriptionExchange } from "@urql/core";
import { Client, fetchExchange, subscriptionExchange } from "@urql/core";
import type { ExecutionResult } from "graphql";
import type { Sink, Client as SubscriptionClient, ClientOptions as SubscriptionClientOptions } from "graphql-ws";
import { CloseCode, createClient as createSubscriptionClient } from "graphql-ws";
import type { Maybe } from "graphql/jsutils/Maybe.js";
import WebSocket from "isomorphic-ws";
import type { AuthenticationModeOptions, BrowserSessionAuthenticationModeOptions, Exchanges } from "./ClientOptions.js";
import { BrowserSessionStorageType } from "./ClientOptions.js";
import { GadgetTransaction, TransactionRolledBack } from "./GadgetTransaction.js";
import type { BrowserStorage } from "./InMemoryStorage.js";
import { InMemoryStorage } from "./InMemoryStorage.js";
import { cacheExchange } from "./exchanges/cacheExchange.js";
import { operationNameExchange } from "./exchanges/operationNameExchange.js";
import { addUrlParams, urlParamExchange } from "./exchanges/urlParamExchange.js";
import { isLiveQueryOperationDefinitionNode } from "./graphql-live-query-utils/index.js";
import {
GadgetTooManyRequestsError,
GadgetUnexpectedCloseError,
Expand Down Expand Up @@ -628,14 +628,3 @@ function processMaybeRelativeInput(input: RequestInfo | URL, endpoint: string):
function isRelativeUrl(url: string) {
return url.startsWith("/") && !url.startsWith("//");
}

const getLiveDirectiveNode = (input: DefinitionNode): Maybe<DirectiveNode> => {
if (input.kind !== "OperationDefinition" || input.operation !== "query") {
return null;
}
return input.directives?.find((d) => d.name.value === "live");
};

const isLiveQueryOperationDefinitionNode = (input: DefinitionNode): input is OperationDefinitionNode => {
return !!getLiveDirectiveNode(input);
};
237 changes: 237 additions & 0 deletions packages/api-client-core/src/exchanges/cacheExchange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
/**
* This file is identical to the cacheExchange from the @urql/core package, except for places where we've added // GADGET: comments.
* https://github.com/urql-graphql/urql/blob/25d114d25807f0676dbf453732602753279ba0db/packages/core/src/exchanges/cache.ts
*/
/* eslint-disable @typescript-eslint/no-use-before-define */
import {
formatDocument,
makeOperation,
makeResult,
type Client,
type Exchange,
type Operation,
type OperationContext,
type OperationResult,
} from "@urql/core";
import { filter, map, merge, pipe, tap } from "wonka";
import { isLiveQueryOperationDefinitionNode } from "../graphql-live-query-utils/index.js";

type ResultCache = Map<number, OperationResult>;
type OperationCache = Map<string, Set<number>>;

const shouldSkip = ({ kind }: Operation) => kind !== "mutation" && kind !== "query";

/** Adds unique typenames to query (for invalidating cache entries) */
export const mapTypeNames = (operation: Operation): Operation => {
const query = formatDocument(operation.query);
if (query !== operation.query) {
const formattedOperation = makeOperation(operation.kind, operation);
formattedOperation.query = query;
return formattedOperation;
} else {
return operation;
}
};

/** Default document cache exchange.
*
* @remarks
* The default document cache in `urql` avoids sending the same GraphQL request
* multiple times by caching it using the {@link Operation.key}. It will invalidate
* query results automatically whenever it sees a mutation responses with matching
* `__typename`s in their responses.
*
* The document cache will get the introspected `__typename` fields by modifying
* your GraphQL operation documents using the {@link formatDocument} utility.
*
* This automatic invalidation strategy can fail if your query or mutation don’t
* contain matching typenames, for instance, because the query contained an
* empty list.
* You can manually add hints for this exchange by specifying a list of
* {@link OperationContext.additionalTypenames} for queries and mutations that
* should invalidate one another.
*
* @see {@link https://urql.dev/goto/docs/basics/document-caching} for more information on this cache.
*/
export const cacheExchange: Exchange = ({ forward, client, dispatchDebug }) => {
const resultCache: ResultCache = new Map();
const operationCache: OperationCache = new Map();

const isOperationCached = (operation: Operation) =>
operation.kind === "query" &&
operation.context.requestPolicy !== "network-only" &&
(operation.context.requestPolicy === "cache-only" || resultCache.has(operation.key));

return (ops$) => {
const cachedOps$ = pipe(
ops$,
filter((op) => !shouldSkip(op) && isOperationCached(op)),
map((operation) => {
const cachedResult = resultCache.get(operation.key);

dispatchDebug({
operation,
...(cachedResult
? {
type: "cacheHit",
message: "The result was successfully retried from the cache",
}
: {
type: "cacheMiss",
message: "The result could not be retrieved from the cache",
}),
});

let result: OperationResult =
cachedResult ||
makeResult(operation, {
data: null,
});

result = {
...result,
operation: addMetadata(operation, {
cacheOutcome: cachedResult ? "hit" : "miss",
}),
};

if (operation.context.requestPolicy === "cache-and-network") {
result.stale = true;
reexecuteOperation(client, operation);
}

return result;
})
);

const forwardedOps$ = pipe(
merge([
pipe(
ops$,
filter((op) => !shouldSkip(op) && !isOperationCached(op)),
map(mapTypeNames)
),
pipe(
ops$,
filter((op) => shouldSkip(op))
),
]),
map((op) => addMetadata(op, { cacheOutcome: "miss" })),
filter((op) => op.kind !== "query" || op.context.requestPolicy !== "cache-only"),
forward,
tap((response) => {
let { operation } = response;
if (!operation) return;

let typenames = operation.context.additionalTypenames || [];
// NOTE: For now, we only respect `additionalTypenames` from subscriptions to
// avoid unexpected breaking changes
// We'd expect live queries or other update mechanisms to be more suitable rather
// 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 = collectTypenames(response.data).concat(typenames);
}

// Invalidates the cache given a mutation's response
if (response.operation.kind === "mutation" || response.operation.kind === "subscription") {
const pendingOperations = new Set<number>();

dispatchDebug({
type: "cacheInvalidation",
message: `The following typenames have been invalidated: ${typenames}`,
operation,
data: { typenames, response },
});

for (let i = 0; i < typenames.length; i++) {
const typeName = typenames[i];
let operations = operationCache.get(typeName);
if (!operations) operationCache.set(typeName, (operations = new Set()));
for (const key of operations.values()) pendingOperations.add(key);
operations.clear();
}

for (const key of pendingOperations.values()) {
if (resultCache.has(key)) {
operation = (resultCache.get(key) as OperationResult).operation;
// GADGET: added the below line to skip reexecuting live queries when their data is invalidated since they should receive updates from the server
if (!operation.query.definitions.some(isLiveQueryOperationDefinitionNode)) {
resultCache.delete(key);
reexecuteOperation(client, operation);
}
}
}
} else if (operation.kind === "query" && response.data) {
resultCache.set(operation.key, response);
for (let i = 0; i < typenames.length; i++) {
const typeName = typenames[i];
let operations = operationCache.get(typeName);
if (!operations) operationCache.set(typeName, (operations = new Set()));
operations.add(operation.key);
}
}
})
);

return merge([cachedOps$, forwardedOps$]);
};
};

/** Reexecutes an `Operation` with the `network-only` request policy.
* @internal
*/
export const reexecuteOperation = (client: Client, operation: Operation) => {
return client.reexecuteOperation(
makeOperation(operation.kind, operation, {
requestPolicy: "network-only",
})
);
};

// GADGET: the below functions are copied from https://github.com/urql-graphql/urql/blob/25d114d25807f0676dbf453732602753279ba0db/packages/core/src/utils/collectTypenames.ts

interface EntityLike {
[key: string]: EntityLike | EntityLike[] | any;
__typename: string | null | void;
}

const collectTypes = (obj: EntityLike | EntityLike[], types: Set<string>) => {
if (Array.isArray(obj)) {
for (let i = 0, l = obj.length; i < l; i++) {
collectTypes(obj[i], 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.
*/
const collectTypenames = (response: object): string[] => [...collectTypes(response as EntityLike, new Set())];

// GADGET: the below function is copied from https://github.com/urql-graphql/urql/blob/25d114d25807f0676dbf453732602753279ba0db/packages/core/src/utils/operation.ts

/** Adds additional metadata to an `Operation`'s `context.meta` property while copying it.
* @see {@link OperationDebugMeta} for more information on the {@link OperationContext.meta} property.
*/
const addMetadata = (operation: Operation, meta: OperationContext["meta"]) => {
return makeOperation(operation.kind, operation, {
meta: {
...operation.context.meta,
...meta,
},
});
};
13 changes: 13 additions & 0 deletions packages/api-client-core/src/graphql-live-query-utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { DefinitionNode, DirectiveNode, OperationDefinitionNode } from "@0no-co/graphql.web";
import type { Delta } from "@n1ru4l/json-patch-plus";
import { patch } from "@n1ru4l/json-patch-plus";
import type { Maybe } from "graphql/jsutils/Maybe.js";
import { createApplyLiveQueryPatch } from "./createApplyLiveQueryPatch.js";

export type ApplyPatchFunction<PatchPayload = unknown> = (
Expand All @@ -15,3 +17,14 @@ export const applyJSONDiffPatch: ApplyPatchFunction<Delta> = (left, delta): Reco

export const applyLiveQueryJSONDiffPatch = createApplyLiveQueryPatch(applyJSONDiffPatch);
export { applyAsyncIterableIteratorToSink, makeAsyncIterableIteratorFromSink } from "@n1ru4l/push-pull-async-iterable-iterator";

export const getLiveDirectiveNode = (input: DefinitionNode): Maybe<DirectiveNode> => {
if (input.kind !== "OperationDefinition" || input.operation !== "query") {
return null;
}
return input.directives?.find((d) => d.name.value === "live");
};

export const isLiveQueryOperationDefinitionNode = (input: DefinitionNode): input is OperationDefinitionNode => {
return !!getLiveDirectiveNode(input);
};

0 comments on commit 53ece63

Please sign in to comment.