Repository: urql-graphql/urql (ts)

Issue:
RangeError: Maximum call stack size exceeded when using persistedExchange in front of ssrExchange
### Describe the bug

We have following setup of exchanges


```
persistedExchange(),
ssrExchange,
fetchExchange,
```

Which leads to 

```
file:/node_modules/wonka/dist/wonka.mjs:246
    i(start((e => {
             ^

RangeError: Maximum call stack size exceeded
    at file:///node_modules/wonka/dist/wonka.mjs:246:14
    at file:///node_modules/wonka/dist/wonka.mjs:442:9
    at Array.<anonymous> (file://node_modules/wonka/dist/wonka.mjs:179:9)
    at file://node_modules/wonka/dist/wonka.mjs:426:17
    at Array.<anonymous> (file://node_modules/wonka/dist/wonka.mjs:235:15)
    at file://node_modules/wonka/dist/wonka.mjs:426:17
    at next (file:///node_modules/wonka/dist/wonka.mjs:974:11)
    at Object.next (file://node_modules/wonka/dist/wonka.mjs:1004:9)
    at file:///node_modules/@urql/exchange-persisted/dist/urql-exchange-persisted.mjs:90:13
    at Array.<anonymous> (file:///node_modules/wonka/dist/wonka.mjs:192:14)

```

if non-persisted query is executed.

if I exchange ssr in front of persisted Query like that

```
ssrExchange,
persistedExchange(),
fetchExchange,
```

it works - but it also re-executes queries after hydration on client-side.



### Reproduction

https://github.com/Warxcell/urql-ssr-persisted-exchange-demo

### Urql version

"@urql/core@>=4.1.0", "@urql/core@^4.0.0":
  version "4.2.0"
"@urql/vue@1.1.2":
  version "1.1.2"
"@urql/exchange-persisted@^4.1.0":
  version "4.1.0"

### Validations

- [X] I can confirm that this is a bug report, and not a feature request, RFC, question, or discussion, for which [GitHub Discussions](https://github.com/urql-graphql/urql/discussions) should be used
- [X] Read the [docs](https://formidable.com/open-source/urql/docs/).
- [X] Follow our [Code of Conduct](https://github.com/urql-graphql/urql/blob/main/CODE_OF_CONDUCT.md)

Pull request:
fix(persisted): Add dev warning for persisted-miss results for an already persisted-miss errored operation (repeated retries)
Closes #3441

## Summary

When a `persistedExchange()` sees a persisted-miss error, it will retry the operation while disabling persisted serialization using the `miss: true` flag. However, we had no guard to check whether `miss: true` was already set.

This meant that if an API/exchange erroneously keeps replying with persisted-miss errors then `persistedExchange()` wouldn't stop retrying the operation.
When an `ssrExchange()` on the server-side caches this error, this is made worse, since `ssrExchange`'s results are synchronous and this hence causes an infinite loop.

A development-time warning has been added that explicitly tells people that this is what's going on and how to resolve it.

## Set of changes

- Add failing test for repeated persisted-miss errors
- Handle & Ignore repeated persisted-miss errors and log development warning


Patch:
diff --git a/.changeset/late-plants-poke.md b/.changeset/late-plants-poke.md
new file mode 100644
index 00000000..d0c90a67
--- /dev/null
+++ b/.changeset/late-plants-poke.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-persisted': patch
+---
+
+Warn about cached persisted-miss results in development, when a `persistedExchange()` sees a persisted-miss error for a result that's already seen a persisted-miss error (i.e. two misses). This shouldn't happen unless something is caching persisted errors and we should warn about this appropriately.
diff --git a/exchanges/persisted/src/persistedExchange.ts b/exchanges/persisted/src/persistedExchange.ts
index bfa17be7..5473dd75 100644
--- a/exchanges/persisted/src/persistedExchange.ts
+++ b/exchanges/persisted/src/persistedExchange.ts
@@ -216,6 +216,18 @@ export const persistedExchange =
               retries.next(followupOperation);
               return null;
             } else if (result.error && isPersistedMiss(result.error)) {
+              if (result.operation.extensions.persistedQuery.miss) {
+                if (process.env.NODE_ENV !== 'production') {
+                  console.warn(
+                    'persistedExchange()’s results include two misses for the same operation.\n' +
+                      'This is not expected as it means a persisted error has been delivered for a non-persisted query!\n' +
+                      'Another exchange with a cache may be delivering an outdated result. For example, a server-side ssrExchange() may be caching an errored result.\n' +
+                      'Try moving the persistedExchange() in past these exchanges, for example in front of your fetchExchange.'
+                  );
+                }
+
+                return result;
+              }
               // Update operation with unsupported attempt
               const followupOperation = makeOperation(
                 result.operation.kind,


Test patch:
diff --git a/exchanges/persisted/src/persistedExchange.test.ts b/exchanges/persisted/src/persistedExchange.test.ts
index 15632568..09b4a777 100644
--- a/exchanges/persisted/src/persistedExchange.test.ts
+++ b/exchanges/persisted/src/persistedExchange.test.ts
@@ -119,6 +119,40 @@ it('retries query persisted query resulted in unsupported', async () => {
   expect(operations[2].extensions).toEqual(undefined);
 });
 
+it('fails gracefully when an invalid result with `PersistedQueryNotFound` is always delivered', async () => {
+  const { result, operations, exchangeArgs } = makeExchangeArgs();
+
+  result.mockImplementation(operation => ({
+    ...queryResponse,
+    operation,
+    error: new CombinedError({
+      graphQLErrors: [{ message: 'PersistedQueryNotFound' }],
+    }),
+  }));
+
+  const res = await pipe(
+    fromValue(queryOperation),
+    persistedExchange()(exchangeArgs),
+    take(1),
+    toPromise
+  );
+
+  expect(res.operation.context.persistAttempt).toBe(true);
+  expect(operations.length).toBe(2);
+
+  expect(operations[1].extensions).toEqual({
+    persistedQuery: {
+      version: 1,
+      sha256Hash: expect.any(String),
+      miss: true,
+    },
+  });
+
+  expect(console.warn).toHaveBeenLastCalledWith(
+    expect.stringMatching(/two misses/i)
+  );
+});
+
 it('skips operation when generateHash returns a nullish value', async () => {
   const { result, operations, exchangeArgs } = makeExchangeArgs();