Repository: urql-graphql/urql
Pull request #3308 (2023-07-11 01:46:52)

fix(graphcache): Re-enable offlineExchange issuing non-cache request policies
## Summary

This PR, when applied, makes two changes.

Primarily, requesdt policies other than `cache-first` are now allowed in the `offlineExchange` when re-running failed operations. After rehydration or when replaying failed queries, queries will now not be forced to `cache-first` policies, and instead use the highest policy that was issued, preferring network policies (`cache-only` < `cache-first` < `network-only` < `cache-and-network`).

Additionally, instead of checking manually which mutations have made optimistic changes, the `cacheExchange` will now mark optimistic mutations' contexts with `optimistic: true` instead.

## Set of changes

- Add `OperationContext.optimistic` indicating optimistic updates have been made
- Remove `cache-first` forcing in `offlineExchange`
- Pick highest policy that has been seen in `offlineExchange`
- Remove queries from `failedQueue` that have received a pre-hydration network result



Patch:
diff --git a/.changeset/curvy-coins-travel.md b/.changeset/curvy-coins-travel.md
deleted file mode 100644
index 0d947549..00000000
--- a/.changeset/curvy-coins-travel.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@urql/core': patch
----
-
-Re-order `maskTypename` to apply masking earlier in the chain.
diff --git a/.changeset/eleven-snakes-look.md b/.changeset/eleven-snakes-look.md
new file mode 100644
index 00000000..0abdacfa
--- /dev/null
+++ b/.changeset/eleven-snakes-look.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-graphcache': patch
+---
+
+Allow `offlineExchange` to once again issue all request policies, instead of mapping them to `cache-first`. When replaying operations after rehydrating it will now prioritise network policies, and before rehydrating receiving a network result will prevent a network request from being issued again.
diff --git a/.changeset/honest-hairs-wave.md b/.changeset/honest-hairs-wave.md
deleted file mode 100644
index bf4545ec..00000000
--- a/.changeset/honest-hairs-wave.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@urql/core': patch
----
-
-Fix `ssrExchange` not formatting query documents using `formatDocument`. Without this call we'd run the risk of not having `__typename` available on the client-side when rehydrating.
diff --git a/.changeset/rich-crews-wink.md b/.changeset/rich-crews-wink.md
deleted file mode 100644
index 32beeadf..00000000
--- a/.changeset/rich-crews-wink.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@urql/svelte': patch
----
-
-Updated peer dependency range to include support for Svelte `^4.0.0`
diff --git a/.changeset/swift-camels-draw.md b/.changeset/swift-camels-draw.md
deleted file mode 100644
index 331f95ba..00000000
--- a/.changeset/swift-camels-draw.md
+++ /dev/null
@@ -1,8 +0,0 @@
----
-'@urql/core': patch
----
-
-Add deprecation notice for `maskTypename` option.
-Masking typenames in a result is no longer recommended. It’s only
-useful when multiple pre-conditions are applied and inferior to
-mapping to an input object manually.
diff --git a/.changeset/two-ants-relate.md b/.changeset/two-ants-relate.md
new file mode 100644
index 00000000..67ecbdb8
--- /dev/null
+++ b/.changeset/two-ants-relate.md
@@ -0,0 +1,6 @@
+---
+'@urql/exchange-graphcache': patch
+'@urql/core': patch
+---
+
+Add `OperationContext.optimistic` flag as an internal indication on whether a mutation triggered an optimistic update in `@urql/exchange-graphcache`'s `cacheExchange`.
diff --git a/examples/with-apq/package.json b/examples/with-apq/package.json
index 4f2a7e8e..4ff1a535 100644
--- a/examples/with-apq/package.json
+++ b/examples/with-apq/package.json
@@ -6,7 +6,7 @@
     "start": "vite"
   },
   "dependencies": {
-    "@urql/core": "^4.0.10",
+    "@urql/core": "^4.0.11",
     "@urql/exchange-persisted-fetch": "^1.3.0",
     "graphql": "^16.6.0",
     "react": "^18.2.0",
diff --git a/examples/with-defer-stream-directives/package.json b/examples/with-defer-stream-directives/package.json
index 38be63df..7acdb1ce 100644
--- a/examples/with-defer-stream-directives/package.json
+++ b/examples/with-defer-stream-directives/package.json
@@ -17,7 +17,7 @@
   },
   "dependencies": {
     "@graphql-yoga/plugin-defer-stream": "^1.7.1",
-    "@urql/core": "^4.0.10",
+    "@urql/core": "^4.0.11",
     "@urql/exchange-graphcache": "^6.1.4",
     "graphql": "17.0.0-alpha.2",
     "react": "^18.2.0",
diff --git a/examples/with-graphcache-pagination/package.json b/examples/with-graphcache-pagination/package.json
index ac14723c..d80c335c 100644
--- a/examples/with-graphcache-pagination/package.json
+++ b/examples/with-graphcache-pagination/package.json
@@ -6,7 +6,7 @@
     "start": "vite"
   },
   "dependencies": {
-    "@urql/core": "^4.0.10",
+    "@urql/core": "^4.0.11",
     "@urql/exchange-graphcache": "^6.1.4",
     "graphql": "^16.6.0",
     "react": "^18.2.0",
diff --git a/examples/with-graphcache-updates/package.json b/examples/with-graphcache-updates/package.json
index fddd9a02..e92c642e 100644
--- a/examples/with-graphcache-updates/package.json
+++ b/examples/with-graphcache-updates/package.json
@@ -6,8 +6,8 @@
     "start": "vite"
   },
   "dependencies": {
-    "@urql/core": "^4.0.10",
-    "@urql/exchange-auth": "^2.1.4",
+    "@urql/core": "^4.0.11",
+    "@urql/exchange-auth": "^2.1.5",
     "@urql/exchange-graphcache": "^6.1.4",
     "graphql": "^16.6.0",
     "react": "^18.2.0",
diff --git a/examples/with-infinite-pagination/package.json b/examples/with-infinite-pagination/package.json
index fc715ebc..3d2cf74c 100644
--- a/examples/with-infinite-pagination/package.json
+++ b/examples/with-infinite-pagination/package.json
@@ -6,7 +6,7 @@
     "start": "vite"
   },
   "dependencies": {
-    "@urql/core": "^4.0.10",
+    "@urql/core": "^4.0.11",
     "graphql": "^16.6.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
diff --git a/examples/with-multipart/package.json b/examples/with-multipart/package.json
index 7094096b..19fa2fc0 100644
--- a/examples/with-multipart/package.json
+++ b/examples/with-multipart/package.json
@@ -6,7 +6,7 @@
     "start": "vite"
   },
   "dependencies": {
-    "@urql/core": "^4.0.10",
+    "@urql/core": "^4.0.11",
     "graphql": "^16.6.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
diff --git a/examples/with-next/package.json b/examples/with-next/package.json
index 883f59a5..1d98d978 100644
--- a/examples/with-next/package.json
+++ b/examples/with-next/package.json
@@ -3,7 +3,7 @@
   "version": "0.0.0",
   "private": true,
   "dependencies": {
-    "@urql/core": "^4.0.10",
+    "@urql/core": "^4.0.11",
     "graphql": "^16.6.0",
     "next": "13.2.4",
     "next-urql": "^5.0.2",
diff --git a/examples/with-pagination/package.json b/examples/with-pagination/package.json
index fc715ebc..3d2cf74c 100644
--- a/examples/with-pagination/package.json
+++ b/examples/with-pagination/package.json
@@ -6,7 +6,7 @@
     "start": "vite"
   },
   "dependencies": {
-    "@urql/core": "^4.0.10",
+    "@urql/core": "^4.0.11",
     "graphql": "^16.6.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
diff --git a/examples/with-react-native/package.json b/examples/with-react-native/package.json
index b6676e3e..c539a424 100644
--- a/examples/with-react-native/package.json
+++ b/examples/with-react-native/package.json
@@ -8,7 +8,7 @@
     "start": "react-native start"
   },
   "dependencies": {
-    "@urql/core": "^4.0.10",
+    "@urql/core": "^4.0.11",
     "graphql": "^16.6.0",
     "react": "18.2.0",
     "react-native": "0.71.4",
diff --git a/examples/with-react/package.json b/examples/with-react/package.json
index 456419bc..239df040 100644
--- a/examples/with-react/package.json
+++ b/examples/with-react/package.json
@@ -6,7 +6,7 @@
     "start": "vite"
   },
   "dependencies": {
-    "@urql/core": "^4.0.10",
+    "@urql/core": "^4.0.11",
     "graphql": "^16.6.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
diff --git a/examples/with-refresh-auth/package.json b/examples/with-refresh-auth/package.json
index 9d6ff1a1..9b833f04 100644
--- a/examples/with-refresh-auth/package.json
+++ b/examples/with-refresh-auth/package.json
@@ -6,8 +6,8 @@
     "start": "vite"
   },
   "dependencies": {
-    "@urql/core": "^4.0.10",
-    "@urql/exchange-auth": "^2.1.4",
+    "@urql/core": "^4.0.11",
+    "@urql/exchange-auth": "^2.1.5",
     "graphql": "^16.6.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
diff --git a/examples/with-retry/package.json b/examples/with-retry/package.json
index 099b215f..b9c7cb9f 100644
--- a/examples/with-retry/package.json
+++ b/examples/with-retry/package.json
@@ -6,7 +6,7 @@
     "start": "vite"
   },
   "dependencies": {
-    "@urql/core": "^4.0.10",
+    "@urql/core": "^4.0.11",
     "@urql/exchange-retry": "^1.2.0",
     "graphql": "^16.6.0",
     "react": "^18.2.0",
diff --git a/examples/with-subscriptions-via-fetch/package.json b/examples/with-subscriptions-via-fetch/package.json
index ce64bda8..53452dfb 100644
--- a/examples/with-subscriptions-via-fetch/package.json
+++ b/examples/with-subscriptions-via-fetch/package.json
@@ -8,7 +8,7 @@
     "start": "run-p client server"
   },
   "dependencies": {
-    "@urql/core": "^4.0.10",
+    "@urql/core": "^4.0.11",
     "@urql/exchange-graphcache": "^6.1.4",
     "graphql": "^16.6.0",
     "react": "^18.2.0",
diff --git a/examples/with-svelte/package.json b/examples/with-svelte/package.json
index a76bba37..ef0a9556 100644
--- a/examples/with-svelte/package.json
+++ b/examples/with-svelte/package.json
@@ -8,8 +8,8 @@
     "serve": "vite preview"
   },
   "dependencies": {
-    "@urql/core": "^4.0.10",
-    "@urql/svelte": "^4.0.2",
+    "@urql/core": "^4.0.11",
+    "@urql/svelte": "^4.0.3",
     "graphql": "^16.6.0",
     "svelte": "^4.0.5"
   },
diff --git a/examples/with-vue3/package.json b/examples/with-vue3/package.json
index 64bff96b..fd42190a 100644
--- a/examples/with-vue3/package.json
+++ b/examples/with-vue3/package.json
@@ -8,7 +8,7 @@
     "serve": "vite preview"
   },
   "dependencies": {
-    "@urql/core": "^4.0.10",
+    "@urql/core": "^4.0.11",
     "@urql/vue": "^1.1.2",
     "graphql": "^16.6.0",
     "vue": "^3.2.47"
diff --git a/exchanges/auth/CHANGELOG.md b/exchanges/auth/CHANGELOG.md
index 3a4c1244..7e6d2d75 100644
--- a/exchanges/auth/CHANGELOG.md
+++ b/exchanges/auth/CHANGELOG.md
@@ -1,5 +1,12 @@
 # Changelog
 
+## 2.1.5
+
+### Patch Changes
+
+- Handle `refreshAuth` rejections and pass the resulting error on to `OperationResult`s on the authentication queue
+  Submitted by [@kitten](https://github.com/kitten) (See [#3307](https://github.com/urql-graphql/urql/pull/3307))
+
 ## 2.1.4
 
 ### Patch Changes
diff --git a/exchanges/auth/package.json b/exchanges/auth/package.json
index eb9259fa..6d02a364 100644
--- a/exchanges/auth/package.json
+++ b/exchanges/auth/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@urql/exchange-auth",
-  "version": "2.1.4",
+  "version": "2.1.5",
   "description": "An exchange for managing authentication and token refresh in urql",
   "sideEffects": false,
   "homepage": "https://formidable.com/open-source/urql/docs/",
diff --git a/exchanges/auth/src/authExchange.ts b/exchanges/auth/src/authExchange.ts
index 13e575c4..ba94f485 100644
--- a/exchanges/auth/src/authExchange.ts
+++ b/exchanges/auth/src/authExchange.ts
@@ -13,6 +13,7 @@ import {
 import {
   createRequest,
   makeOperation,
+  makeErrorResult,
   Operation,
   OperationContext,
   OperationResult,
@@ -202,17 +203,26 @@ export function authExchange(
   return ({ client, forward }) => {
     const bypassQueue = new Set<OperationInstance | undefined>();
     const retries = makeSubject<Operation>();
+    const errors = makeSubject<OperationResult>();
 
     let retryQueue = new Map<number, Operation>();
 
-    function flushQueue(_config?: AuthConfig | undefined) {
-      if (_config) config = _config;
+    function flushQueue() {
       authPromise = undefined;
       const queue = retryQueue;
       retryQueue = new Map();
       queue.forEach(retries.next);
     }
 
+    function errorQueue(error: Error) {
+      authPromise = undefined;
+      const queue = retryQueue;
+      retryQueue = new Map();
+      queue.forEach(operation => {
+        errors.next(makeErrorResult(operation, error));
+      });
+    }
+
     let authPromise: Promise<void> | void;
     let config: AuthConfig | null = null;
 
@@ -270,7 +280,10 @@ export function authExchange(
             },
           })
         )
-        .then(flushQueue);
+        .then((_config: AuthConfig) => {
+          if (_config) config = _config;
+          flushQueue();
+        });
 
       function refreshAuth(operation: Operation) {
         // add to retry queue to try again later
@@ -281,7 +294,7 @@ export function authExchange(
 
         // check that another operation isn't already doing refresh
         if (config && !authPromise) {
-          authPromise = config.refreshAuth().finally(flushQueue);
+          authPromise = config.refreshAuth().then(flushQueue).catch(errorQueue);
         }
       }
 
@@ -341,26 +354,29 @@ export function authExchange(
 
       const result$ = pipe(opsWithAuth$, forward);
 
-      return pipe(
-        result$,
-        filter(result => {
-          if (
-            !bypassQueue.has(result.operation.context._instance) &&
-            result.error &&
-            didAuthError(result) &&
-            !result.operation.context.authAttempt
-          ) {
-            refreshAuth(result.operation);
-            return false;
-          }
+      return merge([
+        errors.source,
+        pipe(
+          result$,
+          filter(result => {
+            if (
+              !bypassQueue.has(result.operation.context._instance) &&
+              result.error &&
+              didAuthError(result) &&
+              !result.operation.context.authAttempt
+            ) {
+              refreshAuth(result.operation);
+              return false;
+            }
 
-          if (bypassQueue.has(result.operation.context._instance)) {
-            bypassQueue.delete(result.operation.context._instance);
-          }
+            if (bypassQueue.has(result.operation.context._instance)) {
+              bypassQueue.delete(result.operation.context._instance);
+            }
 
-          return true;
-        })
-      );
+            return true;
+          })
+        ),
+      ]);
     };
   };
 }
diff --git a/exchanges/graphcache/src/cacheExchange.ts b/exchanges/graphcache/src/cacheExchange.ts
index 7a049ef9..a6df7162 100644
--- a/exchanges/graphcache/src/cacheExchange.ts
+++ b/exchanges/graphcache/src/cacheExchange.ts
@@ -4,6 +4,7 @@ import {
   makeOperation,
   Operation,
   OperationResult,
+  OperationContext,
   RequestPolicy,
   CacheOutcome,
 } from '@urql/core';
@@ -144,6 +145,7 @@ 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);
@@ -155,6 +157,7 @@ export const cacheExchange =
         reexecutingOperations.delete(operation.key);
         // Mark operation layer as done
         noopDataState(store.data, operation.key);
+        return operation;
       } else if (
         operation.kind === 'mutation' &&
         operation.context.requestPolicy !== 'network-only'
@@ -175,6 +178,9 @@ export const cacheExchange =
           const pendingOperations: Operations = new Set();
           collectPendingOperations(pendingOperations, dependencies);
           executePendingOperations(operation, pendingOperations, true);
+
+          // Mark operation as optimistic
+          context = { optimistic: true };
         }
       }
 
@@ -190,7 +196,7 @@ export const cacheExchange =
               )
             : operation.variables,
         },
-        operation.context
+        { ...operation.context, ...context }
       );
     };
 
diff --git a/exchanges/graphcache/src/offlineExchange.ts b/exchanges/graphcache/src/offlineExchange.ts
index 05e07de7..db138e5a 100644
--- a/exchanges/graphcache/src/offlineExchange.ts
+++ b/exchanges/graphcache/src/offlineExchange.ts
@@ -1,5 +1,4 @@
 import { pipe, share, merge, makeSubject, filter, onPush } from 'wonka';
-import { SelectionNode } from '@0no-co/graphql.web';
 
 import {
   Operation,
@@ -7,57 +6,23 @@ import {
   Exchange,
   ExchangeIO,
   CombinedError,
+  RequestPolicy,
   stringifyDocument,
   createRequest,
   makeOperation,
 } from '@urql/core';
 
-import {
-  getMainOperation,
-  getFragments,
-  isInlineFragment,
-  isFieldNode,
-  shouldInclude,
-  getSelectionSet,
-  getName,
-} from './ast';
-
-import {
-  SerializedRequest,
-  OptimisticMutationConfig,
-  Variables,
-  CacheExchangeOpts,
-  StorageAdapter,
-} from './types';
+import { SerializedRequest, CacheExchangeOpts, StorageAdapter } from './types';
 
 import { cacheExchange } from './cacheExchange';
 import { toRequestPolicy } from './helpers/operation';
 
-/** Determines whether a given query contains an optimistic mutation field */
-const isOptimisticMutation = <T extends OptimisticMutationConfig>(
-  config: T,
-  operation: Operation
-) => {
-  const vars: Variables = operation.variables || {};
-  const fragments = getFragments(operation.query);
-  const selections = [...getSelectionSet(getMainOperation(operation.query))];
-
-  let field: void | SelectionNode;
-  while ((field = selections.pop())) {
-    if (!shouldInclude(field, vars)) {
-      continue;
-    } else if (!isFieldNode(field)) {
-      const fragmentNode = !isInlineFragment(field)
-        ? fragments[getName(field)]
-        : field;
-      if (fragmentNode) selections.push(...getSelectionSet(fragmentNode));
-    } else if (config[getName(field)]) {
-      return true;
-    }
-  }
-
-  return false;
-};
+const policyLevel = {
+  'cache-only': 0,
+  'cache-first': 1,
+  'network-only': 2,
+  'cache-and-network': 3,
+} as const;
 
 /** Input parameters for the {@link offlineExchange}.
  * @remarks
@@ -126,7 +91,6 @@ export const offlineExchange =
     ) {
       const { forward: outerForward, client, dispatchDebug } = input;
       const { source: reboundOps$, next } = makeSubject<Operation>();
-      const optimisticMutations = opts.optimistic || {};
       const failedQueue: Operation[] = [];
       let hasRehydrated = false;
       let isFlushingQueue = false;
@@ -148,23 +112,35 @@ export const offlineExchange =
         }
       };
 
+      const filterQueue = (key: number) => {
+        for (let i = failedQueue.length - 1; i >= 0; i--)
+          if (failedQueue[i].key === key) failedQueue.splice(i, 1);
+      };
+
       const flushQueue = () => {
         if (!isFlushingQueue) {
-          isFlushingQueue = true;
-
           const sent = new Set<number>();
+          isFlushingQueue = true;
           for (let i = 0; i < failedQueue.length; i++) {
             const operation = failedQueue[i];
             if (operation.kind === 'mutation' || !sent.has(operation.key)) {
-              if (operation.kind !== 'subscription')
-                next(makeOperation('teardown', operation));
               sent.add(operation.key);
-              next(toRequestPolicy(operation, 'cache-first'));
+              if (operation.kind !== 'subscription') {
+                next(makeOperation('teardown', operation));
+                let overridePolicy: RequestPolicy = 'cache-first';
+                for (let i = 0; i < failedQueue.length; i++) {
+                  const { requestPolicy } = failedQueue[i].context;
+                  if (policyLevel[requestPolicy] > policyLevel[overridePolicy])
+                    overridePolicy = requestPolicy;
+                }
+                next(toRequestPolicy(operation, overridePolicy));
+              } else {
+                next(toRequestPolicy(operation, 'cache-first'));
+              }
             }
           }
-
-          failedQueue.length = 0;
           isFlushingQueue = false;
+          failedQueue.length = 0;
           updateMetadata();
         }
       };
@@ -176,8 +152,8 @@ export const offlineExchange =
             if (
               hasRehydrated &&
               res.operation.kind === 'mutation' &&
-              isOfflineError(res.error, res) &&
-              isOptimisticMutation(optimisticMutations, res.operation)
+              res.operation.context.optimistic &&
+              isOfflineError(res.error, res)
             ) {
               failedQueue.push(res.operation);
               updateMetadata();
@@ -231,9 +207,7 @@ export const offlineExchange =
               if (operation.kind === 'query' && !hasRehydrated) {
                 failedQueue.push(operation);
               } else if (operation.kind === 'teardown') {
-                for (let i = failedQueue.length - 1; i >= 0; i--)
-                  if (failedQueue[i].key === operation.key)
-                    failedQueue.splice(i, 1);
+                filterQueue(operation.key);
               }
             })
           ),
@@ -242,13 +216,14 @@ export const offlineExchange =
         return pipe(
           cacheResults$(opsAndRebound$),
           filter(res => {
-            if (
-              res.operation.kind === 'query' &&
-              isOfflineError(res.error, res)
-            ) {
-              next(toRequestPolicy(res.operation, 'cache-only'));
-              failedQueue.push(res.operation);
-              return false;
+            if (res.operation.kind === 'query') {
+              if (isOfflineError(res.error, res)) {
+                next(toRequestPolicy(res.operation, 'cache-only'));
+                failedQueue.push(res.operation);
+                return false;
+              } else if (!hasRehydrated) {
+                filterQueue(res.operation.key);
+              }
             }
             return true;
           })
diff --git a/exchanges/persisted/CHANGELOG.md b/exchanges/persisted/CHANGELOG.md
index 7cce8d26..0479cdd9 100644
--- a/exchanges/persisted/CHANGELOG.md
+++ b/exchanges/persisted/CHANGELOG.md
@@ -1,5 +1,12 @@
 # @urql/exchange-persisted-fetch
 
+## 4.0.1
+
+### Patch Changes
+
+- ⚠️ Fix `persistedExchange` ignoring teardowns in its initial operation mapping. Since the hash function is promisified, which defers any persisted operation, it needs to respect teardowns
+  Submitted by [@kitten](https://github.com/kitten) (See [#3312](https://github.com/urql-graphql/urql/pull/3312))
+
 ## 4.0.0
 
 ### Major Changes
diff --git a/exchanges/persisted/package.json b/exchanges/persisted/package.json
index 55a2f5d8..2587793d 100644
--- a/exchanges/persisted/package.json
+++ b/exchanges/persisted/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@urql/exchange-persisted",
-  "version": "4.0.0",
+  "version": "4.0.1",
   "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 013a81b5..a2628d88 100644
--- a/exchanges/persisted/src/persistedExchange.ts
+++ b/exchanges/persisted/src/persistedExchange.ts
@@ -5,6 +5,7 @@ import {
   filter,
   merge,
   mergeMap,
+  takeUntil,
   pipe,
 } from 'wonka';
 
@@ -135,6 +136,36 @@ export const persistedExchange =
       ((enableForMutation && operation.kind === 'mutation') ||
         operation.kind === 'query');
 
+    const getPersistedOperation = async (operation: Operation) => {
+      const persistedOperation = makeOperation(operation.kind, operation, {
+        ...operation.context,
+        persistAttempt: true,
+      });
+
+      const sha256Hash = await hashFn(
+        stringifyDocument(operation.query),
+        operation.query
+      );
+      if (sha256Hash) {
+        persistedOperation.extensions = {
+          ...persistedOperation.extensions,
+          persistedQuery: {
+            version: 1,
+            sha256Hash,
+          },
+        };
+        if (
+          persistedOperation.kind === 'query' &&
+          preferGetForPersistedQueries
+        ) {
+          persistedOperation.context.preferGetMethod =
+            preferGetForPersistedQueries;
+        }
+      }
+
+      return persistedOperation;
+    };
+
     return operations$ => {
       const retries = makeSubject<Operation>();
 
@@ -146,36 +177,18 @@ export const persistedExchange =
       const persistedOps$ = pipe(
         operations$,
         filter(operationFilter),
-        map(async operation => {
-          const persistedOperation = makeOperation(operation.kind, operation, {
-            ...operation.context,
-            persistAttempt: true,
-          });
-
-          const sha256Hash = await hashFn(
-            stringifyDocument(operation.query),
-            operation.query
+        mergeMap(operation => {
+          const persistedOperation$ = getPersistedOperation(operation);
+          return pipe(
+            fromPromise(persistedOperation$),
+            takeUntil(
+              pipe(
+                operations$,
+                filter(op => op.kind === 'teardown' && op.key === operation.key)
+              )
+            )
           );
-          if (sha256Hash) {
-            persistedOperation.extensions = {
-              ...persistedOperation.extensions,
-              persistedQuery: {
-                version: 1,
-                sha256Hash,
-              },
-            };
-            if (
-              persistedOperation.kind === 'query' &&
-              preferGetForPersistedQueries
-            ) {
-              persistedOperation.context.preferGetMethod =
-                preferGetForPersistedQueries;
-            }
-          }
-
-          return persistedOperation;
-        }),
-        mergeMap(fromPromise)
+        })
       );
 
       return pipe(
diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md
index d944c25a..cbe556bc 100644
--- a/packages/core/CHANGELOG.md
+++ b/packages/core/CHANGELOG.md
@@ -1,5 +1,19 @@
 # @urql/core
 
+## 4.0.11
+
+### Patch Changes
+
+- Re-order `maskTypename` to apply masking earlier in the chain
+  Submitted by [@kitten](https://github.com/kitten) (See [#3298](https://github.com/urql-graphql/urql/pull/3298))
+- ⚠️ Fix `ssrExchange` not formatting query documents using `formatDocument`. Without this call we'd run the risk of not having `__typename` available on the client-side when rehydrating
+  Submitted by [@kitten](https://github.com/kitten) (See [#3288](https://github.com/urql-graphql/urql/pull/3288))
+- Add deprecation notice for `maskTypename` option.
+  Masking typenames in a result is no longer recommended. It’s only
+  useful when multiple pre-conditions are applied and inferior to
+  mapping to an input object manually
+  Submitted by [@kitten](https://github.com/kitten) (See [#3299](https://github.com/urql-graphql/urql/pull/3299))
+
 ## 4.0.10
 
 ### Patch Changes
diff --git a/packages/core/package.json b/packages/core/package.json
index 79be78f0..46edaf21 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@urql/core",
-  "version": "4.0.10",
+  "version": "4.0.11",
   "description": "The shared core for the highly customizable and versatile GraphQL client",
   "sideEffects": false,
   "homepage": "https://formidable.com/open-source/urql/docs/",
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 1614a922..cd546277 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -528,6 +528,13 @@ export interface OperationContext {
    * @see {@link https://beta.reactjs.org/blog/2022/03/29/react-v18#new-suspense-features} for more information on React Suspense.
    */
   suspense?: boolean;
+  /** A metdata flag indicating whether this operation triggered optimistic updates.
+   *
+   * @remarks
+   * This configuration flag is reserved for `@urql/exchange-graphcache` and is flipped
+   * when an operation triggerd optimistic updates.
+   */
+  optimistic?: boolean;
   [key: string]: any;
 }
 
diff --git a/packages/svelte-urql/CHANGELOG.md b/packages/svelte-urql/CHANGELOG.md
index 215fd880..bb169566 100644
--- a/packages/svelte-urql/CHANGELOG.md
+++ b/packages/svelte-urql/CHANGELOG.md
@@ -1,5 +1,12 @@
 # @urql/svelte
 
+## 4.0.3
+
+### Patch Changes
+
+- Updated peer dependency range to include support for Svelte `^4.0.0`
+  Submitted by [@ategen3rt](https://github.com/ategen3rt) (See [#3302](https://github.com/urql-graphql/urql/pull/3302))
+
 ## 4.0.2
 
 ### Patch Changes
diff --git a/packages/svelte-urql/package.json b/packages/svelte-urql/package.json
index e472b5b5..d9bd44d4 100644
--- a/packages/svelte-urql/package.json
+++ b/packages/svelte-urql/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@urql/svelte",
-  "version": "4.0.2",
+  "version": "4.0.3",
   "description": "A highly customizable and versatile GraphQL client for Svelte",
   "sideEffects": false,
   "homepage": "https://formidable.com/open-source/urql/docs/",


Test patch:
diff --git a/exchanges/auth/src/authExchange.test.ts b/exchanges/auth/src/authExchange.test.ts
index b9045a87..e4c8a818 100644
--- a/exchanges/auth/src/authExchange.test.ts
+++ b/exchanges/auth/src/authExchange.test.ts
@@ -442,3 +442,37 @@ it('does not infinitely retry authentication when an operation did error', async
     'final-token'
   );
 });
+
+it('passes on failing refreshAuth() errors to results', async () => {
+  const { exchangeArgs, result } = makeExchangeArgs();
+
+  const didAuthError = vi.fn().mockReturnValue(true);
+  const willAuthError = vi.fn().mockReturnValue(true);
+
+  const res = await pipe(
+    fromValue(queryOperation),
+    authExchange(async utils => {
+      const token = 'initial-token';
+      return {
+        addAuthToOperation(operation) {
+          return utils.appendHeaders(operation, {
+            Authorization: token,
+          });
+        },
+        didAuthError,
+        willAuthError,
+        async refreshAuth() {
+          throw new Error('test');
+        },
+      };
+    })(exchangeArgs),
+    take(1),
+    toPromise
+  );
+
+  expect(result).toHaveBeenCalledTimes(0);
+  expect(didAuthError).toHaveBeenCalledTimes(0);
+  expect(willAuthError).toHaveBeenCalledTimes(1);
+
+  expect(res.error).toMatchInlineSnapshot('[CombinedError: [Network] test]');
+});
diff --git a/exchanges/graphcache/src/test-utils/examples-1.test.ts b/exchanges/graphcache/src/test-utils/examples-1.test.ts
index 7609e547..c191f722 100644
--- a/exchanges/graphcache/src/test-utils/examples-1.test.ts
+++ b/exchanges/graphcache/src/test-utils/examples-1.test.ts
@@ -534,7 +534,12 @@ it('correctly resolves optimistic updates on Relay schemas', () => {
   `;
 
   write(store, { query: getRoot }, queryData);
-  writeOptimistic(store, { query: updateItem, variables: { id: '2' } }, 1);
+  const { dependencies } = writeOptimistic(
+    store,
+    { query: updateItem, variables: { id: '2' } },
+    1
+  );
+  expect(dependencies.size).not.toBe(0);
   InMemoryData.noopDataState(store.data, 1);
   const queryRes = query(store, { query: getRoot });
 
@@ -542,6 +547,30 @@ it('correctly resolves optimistic updates on Relay schemas', () => {
   expect(queryRes.data).not.toBe(null);
 });
 
+it('skips non-optimistic mutation fields on writes', () => {
+  const store = new Store();
+
+  const updateItem = gql`
+    mutation UpdateItem($id: ID!) {
+      updateItem(id: $id) {
+        __typename
+        item {
+          __typename
+          id
+          name
+        }
+      }
+    }
+  `;
+
+  const { dependencies } = writeOptimistic(
+    store,
+    { query: updateItem, variables: { id: '2' } },
+    1
+  );
+  expect(dependencies.size).toBe(0);
+});
+
 it('allows cumulative optimistic updates', () => {
   let counter = 1;