Repository: FormidableLabs/urql (ts)

Issue:
(graphcache) - Partial results aren't refetched
<!--
  Please describe your bug clearly and concisely.
  Include a screenshot or a reproduction to illustrate
  the bug and how to reproduce it.
  Please also leave a note on what version of urql you're using
  and include a list of exchanges (if any).
-->

Partial results on arrays are not being refetched when required data is missing.

urql version & exchanges:
"urql": "^2.0.4"
 "@urql/exchange-graphcache": "^4.1.4"
Schema integrated.

**Steps to reproduce**

1st query:
```
project {
  id
}
```
returns `[ { id: 1} ]`

2nd query:
```
project {
  id
  title
}
```
Queries are executed sequentially.

**1st Working scenario (when missing optional field)**

If `title` field is `optional` in schema, then 2nd query returns partial results `[ { id: 1, title: null } ]`, results are marked as `stale` and fallbacks to refetching (to fetch the `title`).

**2nd Not working scenario (when missing required field)**

If `title` field is `required` in schema, then 2nd query returns `[ null ]`. Cached result seems correct. But results aren't marked as `stale` and refetching isn't done as in the 1st scenario.

**Expected behavior**
Results should be marked as stale and refetched (2nd scenario).

**Actual behavior**
Results are not stale and not refetched (2nd scenario).

<!--
  Your issue will be addressed much faster if you include a
  reproduction. Here are some templates to help you create one:
  - client: https://codesandbox.io/s/urql-issue-template-client-iui0o
  - server: https://codesandbox.io/s/urql-issue-template-server-0ufyz
-->


Pull request description:
(graphcache) - Add missing partial mark to null cascading into list
Resolve #1867

## Summary

When a given field cascades as `undefined` into a list and the schema shows this list as nullable, then the query wouldn't be marked as partial and subsequently the list would contain `null` without the query being refetched.

## Set of changes

- Add missing `ctx.partial` mark to `resolveLink`
- Add missing `ctx.partial` mark to `resolveResolverResult`


Patch:
diff --git a/.changeset/chilled-pears-eat.md b/.changeset/chilled-pears-eat.md
new file mode 100644
index 00000000..85ac7e0e
--- /dev/null
+++ b/.changeset/chilled-pears-eat.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-graphcache': patch
+---
+
+Fix missing values cascading into lists causing a `null` item without the query being marked as stale and fetched from the API. This would happen in schema awareness when a required field, which isn't cached, cascades into a nullable list.
diff --git a/exchanges/graphcache/src/operations/query.ts b/exchanges/graphcache/src/operations/query.ts
index 4ca76d30..a9d56aee 100644
--- a/exchanges/graphcache/src/operations/query.ts
+++ b/exchanges/graphcache/src/operations/query.ts
@@ -492,7 +492,8 @@ const resolveResolverResult = (
       if (childResult === undefined && !_isListNullable) {
         return undefined;
       } else {
-        data[i] = childResult !== undefined ? childResult : null;
+        ctx.partial = ctx.partial || _isListNullable;
+        data[i] = childResult != null ? childResult : null;
         hasChanged = hasChanged || data[i] !== prevData![i];
       }
     }
@@ -556,6 +557,7 @@ const resolveLink = (
       if (childLink === undefined && !_isListNullable) {
         return undefined;
       } else {
+        ctx.partial = ctx.partial || _isListNullable;
         newLink[i] = childLink || null;
         hasChanged = hasChanged || newLink[i] !== prevData![i];
       }


Test patch:
diff --git a/exchanges/graphcache/src/cacheExchange.test.ts b/exchanges/graphcache/src/cacheExchange.test.ts
index e2c6038a..19696c8f 100644
--- a/exchanges/graphcache/src/cacheExchange.test.ts
+++ b/exchanges/graphcache/src/cacheExchange.test.ts
@@ -1554,6 +1554,122 @@ describe('schema awareness', () => {
       'partial'
     );
   });
+
+  it('reexecutes query and returns data on partial results for nullable lists', () => {
+    jest.useFakeTimers();
+    const client = createClient({ url: 'http://0.0.0.0' });
+    const { source: ops$, next } = makeSubject<Operation>();
+    const reexec = jest
+      .spyOn(client, 'reexecuteOperation')
+      // Empty mock to avoid going in an endless loop, since we would again return
+      // partial data.
+      .mockImplementation(() => undefined);
+
+    const initialQuery = gql`
+      query {
+        todos {
+          id
+          __typename
+        }
+      }
+    `;
+
+    const query = gql`
+      query {
+        todos {
+          id
+          text
+          __typename
+        }
+      }
+    `;
+
+    const initialQueryOperation = client.createRequestOperation('query', {
+      key: 1,
+      query: initialQuery,
+    });
+
+    const queryOperation = client.createRequestOperation('query', {
+      key: 2,
+      query,
+    });
+
+    const queryData = {
+      __typename: 'Query',
+      todos: [
+        {
+          __typename: 'Todo',
+          id: '123',
+        },
+        {
+          __typename: 'Todo',
+          id: '456',
+        },
+      ],
+    };
+
+    const response = jest.fn(
+      (forwardOp: Operation): OperationResult => {
+        if (forwardOp.key === 1) {
+          return { operation: initialQueryOperation, data: queryData };
+        } else if (forwardOp.key === 2) {
+          return { operation: queryOperation, data: queryData };
+        }
+
+        return undefined as any;
+      }
+    );
+
+    const result = jest.fn();
+    const forward: ExchangeIO = ops$ => pipe(ops$, delay(1), map(response));
+
+    pipe(
+      cacheExchange({
+        schema: minifyIntrospectionQuery(
+          // eslint-disable-next-line
+          require('./test-utils/simple_schema.json')
+        ),
+      })({ forward, client, dispatchDebug })(ops$),
+      tap(result),
+      publish
+    );
+
+    next(initialQueryOperation);
+    jest.runAllTimers();
+    expect(response).toHaveBeenCalledTimes(1);
+    expect(reexec).toHaveBeenCalledTimes(0);
+    expect(result.mock.calls[0][0].data).toMatchObject({
+      todos: [
+        {
+          __typename: 'Todo',
+          id: '123',
+        },
+        {
+          __typename: 'Todo',
+          id: '456',
+        },
+      ],
+    });
+
+    expect(result.mock.calls[0][0]).toHaveProperty(
+      'operation.context.meta',
+      undefined
+    );
+
+    next(queryOperation);
+    jest.runAllTimers();
+    expect(result).toHaveBeenCalledTimes(2);
+    expect(reexec).toHaveBeenCalledTimes(1);
+    expect(result.mock.calls[1][0].stale).toBe(true);
+    expect(result.mock.calls[1][0].data).toEqual({
+      todos: [null, null],
+    });
+
+    expect(result.mock.calls[1][0]).toHaveProperty(
+      'operation.context.meta.cacheOutcome',
+      'partial'
+    );
+  });
 });
 
 describe('commutativity', () => {