Repository: urql-graphql/urql
Pull request #3548 (2024-03-26 20:08:44)

feat(graphcache): track abstract-concrete type mapping
## Summary

In doing so we reduce our need for heuristic fragment matching as we can reliably tell our user that we are dealing with a certain type after writing


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

Latest commit: bbcecf1d5c683953bd23f9a330578563ceb82265

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

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

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

</details>

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

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/urql-graphql/urql/new/mapping-abstract-types?filename=.changeset/lemon-panthers-build.md&value=---%0A%22%40urql%2Fexchange-graphcache%22%3A%20patch%0A---%0A%0Afeat(graphcache)%3A%20track%20abstract-concrete%20type%20mapping%0A)



Patch:
diff --git a/.changeset/many-toes-try.md b/.changeset/many-toes-try.md
new file mode 100644
index 00000000..406b4afe
--- /dev/null
+++ b/.changeset/many-toes-try.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-graphcache': minor
+---
+
+Track abstract types being written so that we have a more reliable way of matching abstract fragments
diff --git a/.changeset/ninety-taxis-return.md b/.changeset/ninety-taxis-return.md
new file mode 100644
index 00000000..fd7dec8f
--- /dev/null
+++ b/.changeset/ninety-taxis-return.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-graphcache': patch
+---
+
+Fix `invalidate` not applying when using a string to invalidate an entity
diff --git a/README.md b/README.md
index d6abd19c..898051eb 100644
--- a/README.md
+++ b/README.md
@@ -42,6 +42,7 @@ you can take it from getting started with your first GraphQL project all the way
   <tr>
    <td align="center"><a href="https://bigcommerce.com/"><img src="https://avatars.githubusercontent.com/u/186342?s=200&v=4" width="150" alt="BigCommerce"/><br />BigCommerce</a></td>
    <td align="center"><a href="https://wundergraph.com/"><img src="https://avatars.githubusercontent.com/u/64281914?s=200&v=4" width="150" alt="WunderGraph"/><br />WunderGraph</a></td>
+   <td align="center"><a href="https://the-guild.dev/"><img src="https://avatars.githubusercontent.com/u/42573040?s=200&v=4" width="150" alt="The Guild "/><br />The Guild</a></td>
   </tr>
 </table>
 <table>
diff --git a/docs/graphcache/cache-updates.md b/docs/graphcache/cache-updates.md
index 174bb4cf..514d564e 100644
--- a/docs/graphcache/cache-updates.md
+++ b/docs/graphcache/cache-updates.md
@@ -7,7 +7,7 @@ order: 4
 
 As we've learned [on the page on "Normalized
 Caching"](./normalized-caching.md#normalizing-relational-data), when Graphcache receives an API
-result it will traverse and store all its data to its cache in a normalised structure. Each entity
+result it will traverse and store all its data to its cache in a normalized structure. Each entity
 that is found in a result will be stored under the entity's key.
 
 A query's result is represented as a graph, which can also be understood as a tree structure,
diff --git a/docs/graphcache/errors.md b/docs/graphcache/errors.md
index ed293069..33dddde3 100644
--- a/docs/graphcache/errors.md
+++ b/docs/graphcache/errors.md
@@ -224,7 +224,7 @@ Please make sure that you include enough properties on your data so that `write`
 This error occurs when the cache can't generate a key for an entity. The key
 would then effectively be `null`, and the entity won't be cached by a key.
 
-Conceptually this means that an entity won't be normalised but will indeed
+Conceptually this means that an entity won't be normalized but will indeed
 be cached by the parent's key and field, which is displayed in the first
 part of the warning.
 
diff --git a/examples/with-apq/package.json b/examples/with-apq/package.json
index 15698aa5..b22673c3 100644
--- a/examples/with-apq/package.json
+++ b/examples/with-apq/package.json
@@ -7,7 +7,7 @@
   },
   "dependencies": {
     "@urql/core": "^4.3.0",
-    "@urql/exchange-persisted": "^4.1.1",
+    "@urql/exchange-persisted": "^4.2.0",
     "graphql": "^16.6.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
diff --git a/exchanges/graphcache/src/operations/shared.ts b/exchanges/graphcache/src/operations/shared.ts
index 2bb14782..bf158c11 100644
--- a/exchanges/graphcache/src/operations/shared.ts
+++ b/exchanges/graphcache/src/operations/shared.ts
@@ -17,7 +17,13 @@ import {
 } from '../ast';
 
 import { warn, pushDebugNode, popDebugNode } from '../helpers/help';
-import { hasField, currentOperation, currentOptimistic } from '../store/data';
+import {
+  hasField,
+  currentOperation,
+  currentOptimistic,
+  writeConcreteType,
+  getConcreteTypes,
+} from '../store/data';
 import { keyOfField } from '../store/keys';
 import type { Store } from '../store/store';
 
@@ -219,17 +225,30 @@ export function makeSelectionIterator(
               !fragment.typeCondition ||
               (ctx.store.schema
                 ? isInterfaceOfType(ctx.store.schema, fragment, typename)
-                : isFragmentHeuristicallyMatching(
+                : (currentOperation === 'read' &&
+                    isFragmentMatching(
+                      fragment.typeCondition.name.value,
+                      typename
+                    )) ||
+                  isFragmentHeuristicallyMatching(
                     fragment,
                     typename,
                     entityKey,
                     ctx.variables,
                     ctx.store.logger
                   ));
+
             if (isMatching) {
               if (process.env.NODE_ENV !== 'production')
                 pushDebugNode(typename, fragment);
               const isFragmentOptional = isOptional(select);
+              if (
+                fragment.typeCondition &&
+                typename !== fragment.typeCondition.name.value
+              ) {
+                writeConcreteType(fragment.typeCondition.name.value, typename!);
+              }
+
               child = makeSelectionIterator(
                 typename,
                 entityKey,
@@ -250,6 +269,13 @@ export function makeSelectionIterator(
   };
 }
 
+const isFragmentMatching = (typeCondition: string, typename: string | void) => {
+  if (!typename) return false;
+  if (typeCondition === typename) return true;
+  const types = getConcreteTypes(typeCondition);
+  return types.size && types.has(typename);
+};
+
 export const ensureData = (x: DataField): Data | NullArray<Data> | null =>
   x == null ? null : (x as Data | NullArray<Data>);
 
diff --git a/exchanges/graphcache/src/store/data.ts b/exchanges/graphcache/src/store/data.ts
index 7073b789..ee695bc1 100644
--- a/exchanges/graphcache/src/store/data.ts
+++ b/exchanges/graphcache/src/store/data.ts
@@ -59,6 +59,8 @@ export interface InMemoryData {
   optimisticOrder: number[];
   /** This may be a persistence adapter that will receive changes in a batch */
   storage: StorageAdapter | null;
+  /** A map of all the types we have encountered that did not map directly to a concrete type */
+  abstractToConcreteMap: Map<string, Set<string>>;
 }
 
 let currentOwnership: null | WeakSet<any> = null;
@@ -249,6 +251,7 @@ export const make = (queryRootKey: string): InMemoryData => ({
     optimistic: new Map(),
     base: new Map(),
   },
+  abstractToConcreteMap: new Map(),
   records: {
     optimistic: new Map(),
     base: new Map(),
@@ -482,6 +485,23 @@ export const writeType = (typename: string, entityKey: string) => {
   }
 };
 
+export const getConcreteTypes = (typename: string): Set<string> =>
+  currentData!.abstractToConcreteMap.get(typename) || DEFAULT_EMPTY_SET;
+
+export const writeConcreteType = (
+  abstractType: string,
+  concreteType: string
+) => {
+  const existingTypes = currentData!.abstractToConcreteMap.get(abstractType);
+  if (!existingTypes) {
+    const typeSet = new Set<string>();
+    typeSet.add(concreteType);
+    currentData!.abstractToConcreteMap.set(abstractType, typeSet);
+  } else {
+    existingTypes.add(concreteType);
+  }
+};
+
 /** 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 ca6304f4..a6538f79 100644
--- a/exchanges/graphcache/src/store/store.ts
+++ b/exchanges/graphcache/src/store/store.ts
@@ -167,7 +167,11 @@ export class Store<
   invalidate(entity: Entity, field?: string, args?: FieldArgs) {
     const entityKey = this.keyOfEntity(entity);
     const shouldInvalidateType =
-      entity && typeof entity === 'string' && !field && !args;
+      entity &&
+      typeof entity === 'string' &&
+      !field &&
+      !args &&
+      !this.resolve(entity, '__typename');
 
     if (shouldInvalidateType) {
       invalidateType(entity);
diff --git a/exchanges/persisted/CHANGELOG.md b/exchanges/persisted/CHANGELOG.md
index afacc7ae..84f3d761 100644
--- a/exchanges/persisted/CHANGELOG.md
+++ b/exchanges/persisted/CHANGELOG.md
@@ -1,5 +1,12 @@
 # @urql/exchange-persisted-fetch
 
+## 4.2.0
+
+### Minor Changes
+
+- Add option to enable persisted-operations for subscriptions
+  Submitted by [@JoviDeCroock](https://github.com/JoviDeCroock) (See [#3549](https://github.com/urql-graphql/urql/pull/3549))
+
 ## 4.1.1
 
 ### Patch Changes
diff --git a/exchanges/persisted/package.json b/exchanges/persisted/package.json
index cfba8a50..90d8c731 100644
--- a/exchanges/persisted/package.json
+++ b/exchanges/persisted/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@urql/exchange-persisted",
-  "version": "4.1.1",
+  "version": "4.2.0",
   "description": "An exchange that allows for persisted queries support when fetching queries",
   "sideEffects": false,
   "homepage": "https://formidable.com/open-source/urql/docs/",
diff --git a/exchanges/persisted/src/persistedExchange.ts b/exchanges/persisted/src/persistedExchange.ts
index 5473dd75..63a707ff 100644
--- a/exchanges/persisted/src/persistedExchange.ts
+++ b/exchanges/persisted/src/persistedExchange.ts
@@ -89,6 +89,16 @@ export interface PersistedExchangeOptions {
    * their GraphQL APIs.
    */
   enableForMutation?: boolean;
+  /** Enables persisted queries to be used for subscriptions.
+   *
+   * @remarks
+   * When enabled, the `persistedExchange` will also use the persisted queries
+   * logic for subscription operations.
+   *
+   * This is disabled by default, but often used on APIs that obfuscate
+   * their GraphQL APIs.
+   */
+  enableForSubscriptions?: boolean;
 }
 
 /** Exchange factory that adds support for Persisted Queries.
@@ -131,12 +141,14 @@ export const persistedExchange =
     const enforcePersistedQueries = !!options.enforcePersistedQueries;
     const hashFn = options.generateHash || hash;
     const enableForMutation = !!options.enableForMutation;
+    const enableForSubscriptions = !!options.enableForSubscriptions;
     let supportsPersistedQueries = true;
 
     const operationFilter = (operation: Operation) =>
       supportsPersistedQueries &&
       !operation.context.persistAttempt &&
       ((enableForMutation && operation.kind === 'mutation') ||
+        (enableForSubscriptions && operation.kind === 'subscription') ||
         operation.kind === 'query');
 
     const getPersistedOperation = async (operation: Operation) => {


Test patch:
diff --git a/exchanges/graphcache/src/store/store.test.ts b/exchanges/graphcache/src/store/store.test.ts
index ba6dd043..82dfd8f9 100644
--- a/exchanges/graphcache/src/store/store.test.ts
+++ b/exchanges/graphcache/src/store/store.test.ts
@@ -856,12 +856,19 @@ describe('Store with OptimisticMutationConfig', () => {
   });
 
   describe('Invalidating an entity', () => {
-    it('removes an entity from a list.', () => {
+    it('removes an entity from a list by object-key.', () => {
       InMemoryData.initDataState('write', store.data, null);
       store.invalidate(todosData.todos[1]);
       const { data } = query(store, { query: Todos });
       expect(data).toBe(null);
     });
+
+    it('removes an entity from a list by string-key.', () => {
+      InMemoryData.initDataState('write', store.data, null);
+      store.invalidate(store.keyOfEntity(todosData.todos[1]));
+      const { data } = query(store, { query: Todos });
+      expect(data).toBe(null);
+    });
   });
 
   describe('Invalidating a type', () => {