Skip to content

fix(incremental): prevent lost or delayed successful shared results - #4868

Merged
yaacovCR merged 1 commit into
graphql:17.x.xfrom
yaacovCR:incremental-fix1
Oct 16, 2026
Merged

yaacovCR merged 1 commit into
graphql:17.x.xfrom
yaacovCR:incremental-fix1

Conversation

@yaacovCR

@yaacovCR yaacovCR commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to merged PR #4859.

Thanks to Duckki Oe (@duckki) for pointing out these issues and providing an initial fix.

That PR documented these bugs with skipped regression tests. This PR implements the successful-result fix and enables its regression tests. Additional integration tests cover delayed delivery and later owners.

Previously, we could skip a nested deferred fragment whose shared fields had succeeded before its parent finished. That was incorrect when the shared data had not yet been delivered. If the other fragment failed on a separate field, the data was lost; if its separate fields succeeded later, the data was delayed.

This PR announces the nested fragment and delivers its successful shared data when its parent completes. The data, including any shared stream, can therefore reach the client without waiting for the other fragment's separate fields.

@defer: another fragment fails on a separate field

{
  hero {
    ... @defer(label: "R") {
      nonNullName
      id
    }
    ... @defer(label: "P") {
      name
      ... @defer(label: "C") {
        id
      }
    }
  }
}

R and C share id, which returns 1. R fails because its own nonNullName returns null. After that error is sent, P's name resolves to "Luke".

Both versions start with:

{
  "data": {"hero": {}},
  "pending": [
    {"id": "0", "path": ["hero"], "label": "R"},
    {"id": "1", "path": ["hero"], "label": "P"}
  ],
  "hasNext": true
}

Then R fails:

{
  "hasNext": true,
  "completed": [
    {
      "id": "0",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {"line": 4, "column": 7}
          ],
          "path": ["hero", "nonNullName"]
        }
      ]
    }
  ]
}

Before this fix:

Previously, finishing P ended the response without announcing C or sending id, even though id had succeeded.

Response 3:

{
  "hasNext": false,
  "incremental": [
    {"id": "1", "data": {"name": "Luke"}}
  ],
  "completed": [
    {"id": "1"}
  ]
}

After this fix:

This commit announces C and delivers id when P finishes, so R's separate failure no longer loses C's data.

Response 3:

{
  "hasNext": false,
  "pending": [
    {"id": "2", "path": ["hero"], "label": "C"}
  ],
  "incremental": [
    {"id": "1", "data": {"name": "Luke"}},
    {"id": "2", "data": {"id": "1"}}
  ],
  "completed": [
    {"id": "1"},
    {"id": "2"}
  ]
}

@defer: another fragment's separate field is slower

{
  hero {
    ... @defer(label: "R") {
      nonNullName
      id
    }
    ... @defer(label: "P") {
      name
      ... @defer(label: "C") {
        id
      }
    }
  }
}

Here all three fields succeed. id returns 1 immediately. After the initial response, P's name resolves to "Luke". R's nonNullName stays pending until after P's response, then also resolves to "Luke".

Both versions start with:

{
  "data": {"hero": {}},
  "pending": [
    {"id": "0", "path": ["hero"], "label": "R"},
    {"id": "1", "path": ["hero"], "label": "P"}
  ],
  "hasNext": true
}

Before this fix:

Previously, P completed without announcing C. The shared id wasn't sent until R's separate nonNullName finished, unnecessarily delaying data that C was ready to deliver.

Response 2:

{
  "hasNext": true,
  "incremental": [
    {"id": "1", "data": {"name": "Luke"}}
  ],
  "completed": [
    {"id": "1"}
  ]
}

Response 3:

{
  "hasNext": false,
  "incremental": [
    {"id": "0", "data": {"nonNullName": "Luke"}},
    {"id": "0", "data": {"id": "1"}}
  ],
  "completed": [
    {"id": "0"}
  ]
}

After this fix:

This commit delivers id through C as soon as P finishes. R sends its separate field later.

Response 2:

{
  "hasNext": true,
  "pending": [
    {"id": "2", "path": ["hero"], "label": "C"}
  ],
  "incremental": [
    {"id": "1", "data": {"name": "Luke"}},
    {"id": "2", "data": {"id": "1"}}
  ],
  "completed": [
    {"id": "1"},
    {"id": "2"}
  ]
}

Response 3:

{
  "hasNext": false,
  "incremental": [
    {"id": "0", "data": {"nonNullName": "Luke"}}
  ],
  "completed": [
    {"id": "0"}
  ]
}

@stream: a separate field fails before the list is delivered

{
  nestedObject {
    ... @defer(label: "R") {
      nonNullScalarField
      ...Friends
    }
    ... @defer(label: "P") {
      scalarField
      ... @defer(label: "C") {
        ...Friends
      }
    }
  }
}
fragment Friends on NestedObject {
  nestedFriendList @stream(initialCount: 0) {
    name
  }
}

The shared nestedFriendList returns Luke, Han, and Leia immediately. R fails because its own nonNullScalarField returns null. After that error is sent, P's scalarField resolves to "parent".

Both versions start with:

{
  "data": {"nestedObject": {}},
  "pending": [
    {"id": "0", "path": ["nestedObject"], "label": "R"},
    {"id": "1", "path": ["nestedObject"], "label": "P"}
  ],
  "hasNext": true
}

Then R fails:

{
  "hasNext": true,
  "completed": [
    {
      "id": "0",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field NestedObject.nonNullScalarField.",
          "locations": [
            {"line": 4, "column": 7}
          ],
          "path": ["nestedObject", "nonNullScalarField"]
        }
      ]
    }
  ]
}

Before this fix:

Previously, P completed and the response ended. C and the stream were never announced, so neither the empty list nor its items were sent.

Response 3:

{
  "hasNext": false,
  "incremental": [
    {"id": "1", "data": {"scalarField": "parent"}}
  ],
  "completed": [
    {"id": "1"}
  ]
}

After this fix:

This commit announces C and the stream with P's result. C sends the empty list, then the stream sends all three friends, so R's separate failure no longer loses the list.

Response 3:

{
  "hasNext": true,
  "pending": [
    {"id": "2", "path": ["nestedObject"], "label": "C"},
    {"id": "3", "path": ["nestedObject", "nestedFriendList"]}
  ],
  "incremental": [
    {"id": "1", "data": {"scalarField": "parent"}},
    {"id": "2", "data": {"nestedFriendList": []}}
  ],
  "completed": [
    {"id": "1"},
    {"id": "2"}
  ]
}

Response 4:

{
  "hasNext": false,
  "incremental": [
    {"id": "3", "items": [{"name": "Luke"}, {"name": "Han"}, {"name": "Leia"}]}
  ],
  "completed": [
    {"id": "3"}
  ]
}

Co-authored-by: Duckki Oe duckki.dev@gmail.com

@yaacovCR yaacovCR added the PR: bug fix 🐞 requires increase of "patch" version number label Oct 16, 2026
@vercel

vercel Bot commented Oct 16, 2026

Copy link
Copy Markdown

@yaacovCR is attempting to deploy a commit to the The GraphQL Foundation Team on Vercel.

A member of the Team first needs to authorize it.

Follow-up to merged PR [graphql#4859](graphql#4859).
Thanks to Duckki Oe (@duckki) for pointing out these issues and providing
an initial fix.

That PR documented these bugs with skipped regression tests. This commit
implements the successful-result fix and enables its regression tests.
Additional integration tests cover delayed delivery and later owners.

Previously, we could skip a nested deferred fragment whose shared fields
had succeeded before its parent finished. That was incorrect when the
shared data had not yet been delivered. If the other fragment failed on a
separate field, the data was lost; if its separate fields succeeded later,
the data was delayed.

This commit announces the nested fragment and delivers its successful
shared data when its parent completes. The data, including any shared
stream, can therefore reach the client without waiting for the other
fragment's separate fields.

## @defer: another fragment fails on a separate field

```graphql
{
  hero {
    ... @defer(label: "R") {
      nonNullName
      id
    }
    ... @defer(label: "P") {
      name
      ... @defer(label: "C") {
        id
      }
    }
  }
}
```

R and C share `id`, which returns `1`. R fails because its own
`nonNullName` returns `null`. After that error is sent, P's `name`
resolves to `"Luke"`.

Both versions start with:

```json
{
  "data": {"hero": {}},
  "pending": [
    {"id": "0", "path": ["hero"], "label": "R"},
    {"id": "1", "path": ["hero"], "label": "P"}
  ],
  "hasNext": true
}
```

Then R fails:

```json
{
  "hasNext": true,
  "completed": [
    {
      "id": "0",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {"line": 4, "column": 7}
          ],
          "path": ["hero", "nonNullName"]
        }
      ]
    }
  ]
}
```

**Before this fix:**

Previously, finishing P ended the response without announcing C or
sending `id`, even though `id` had succeeded.

Response 3:

```json
{
  "hasNext": false,
  "incremental": [
    {"id": "1", "data": {"name": "Luke"}}
  ],
  "completed": [
    {"id": "1"}
  ]
}
```

**After this fix:**

This commit announces C and delivers `id` when P finishes, so R's
separate failure no longer loses C's data.

Response 3:

```json
{
  "hasNext": false,
  "pending": [
    {"id": "2", "path": ["hero"], "label": "C"}
  ],
  "incremental": [
    {"id": "1", "data": {"name": "Luke"}},
    {"id": "2", "data": {"id": "1"}}
  ],
  "completed": [
    {"id": "1"},
    {"id": "2"}
  ]
}
```

## @defer: another fragment's separate field is slower

```graphql
{
  hero {
    ... @defer(label: "R") {
      nonNullName
      id
    }
    ... @defer(label: "P") {
      name
      ... @defer(label: "C") {
        id
      }
    }
  }
}
```

Here all three fields succeed. `id` returns `1` immediately. After the
initial response, P's `name` resolves to `"Luke"`. R's `nonNullName` stays
pending until after P's response, then also resolves to `"Luke"`.

Both versions start with:

```json
{
  "data": {"hero": {}},
  "pending": [
    {"id": "0", "path": ["hero"], "label": "R"},
    {"id": "1", "path": ["hero"], "label": "P"}
  ],
  "hasNext": true
}
```

**Before this fix:**

Previously, P completed without announcing C. The shared `id` wasn't
sent until R's separate `nonNullName` finished, unnecessarily delaying
data that C was ready to deliver.

Response 2:

```json
{
  "hasNext": true,
  "incremental": [
    {"id": "1", "data": {"name": "Luke"}}
  ],
  "completed": [
    {"id": "1"}
  ]
}
```

Response 3:

```json
{
  "hasNext": false,
  "incremental": [
    {"id": "0", "data": {"nonNullName": "Luke"}},
    {"id": "0", "data": {"id": "1"}}
  ],
  "completed": [
    {"id": "0"}
  ]
}
```

**After this fix:**

This commit delivers `id` through C as soon as P finishes. R sends
its separate field later.

Response 2:

```json
{
  "hasNext": true,
  "pending": [
    {"id": "2", "path": ["hero"], "label": "C"}
  ],
  "incremental": [
    {"id": "1", "data": {"name": "Luke"}},
    {"id": "2", "data": {"id": "1"}}
  ],
  "completed": [
    {"id": "1"},
    {"id": "2"}
  ]
}
```

Response 3:

```json
{
  "hasNext": false,
  "incremental": [
    {"id": "0", "data": {"nonNullName": "Luke"}}
  ],
  "completed": [
    {"id": "0"}
  ]
}
```

## @stream: a separate field fails before the list is delivered

```graphql
{
  nestedObject {
    ... @defer(label: "R") {
      nonNullScalarField
      ...Friends
    }
    ... @defer(label: "P") {
      scalarField
      ... @defer(label: "C") {
        ...Friends
      }
    }
  }
}
fragment Friends on NestedObject {
  nestedFriendList @stream(initialCount: 0) {
    name
  }
}
```

The shared `nestedFriendList` returns Luke, Han, and Leia immediately.
R fails because its own `nonNullScalarField` returns `null`. After that
error is sent, P's `scalarField` resolves to `"parent"`.

Both versions start with:

```json
{
  "data": {"nestedObject": {}},
  "pending": [
    {"id": "0", "path": ["nestedObject"], "label": "R"},
    {"id": "1", "path": ["nestedObject"], "label": "P"}
  ],
  "hasNext": true
}
```

Then R fails:

```json
{
  "hasNext": true,
  "completed": [
    {
      "id": "0",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field NestedObject.nonNullScalarField.",
          "locations": [
            {"line": 4, "column": 7}
          ],
          "path": ["nestedObject", "nonNullScalarField"]
        }
      ]
    }
  ]
}
```

**Before this fix:**

Previously, P completed and the response ended. C and the stream were
never announced, so neither the empty list nor its items were sent.

Response 3:

```json
{
  "hasNext": false,
  "incremental": [
    {"id": "1", "data": {"scalarField": "parent"}}
  ],
  "completed": [
    {"id": "1"}
  ]
}
```

**After this fix:**

This commit announces C and the stream with P's result. C sends the
empty list, then the stream sends all three friends, so R's separate
failure no longer loses the list.

Response 3:

```json
{
  "hasNext": true,
  "pending": [
    {"id": "2", "path": ["nestedObject"], "label": "C"},
    {"id": "3", "path": ["nestedObject", "nestedFriendList"]}
  ],
  "incremental": [
    {"id": "1", "data": {"scalarField": "parent"}},
    {"id": "2", "data": {"nestedFriendList": []}}
  ],
  "completed": [
    {"id": "1"},
    {"id": "2"}
  ]
}
```

Response 4:

```json
{
  "hasNext": false,
  "incremental": [
    {"id": "3", "items": [{"name": "Luke"}, {"name": "Han"}, {"name": "Leia"}]}
  ],
  "completed": [
    {"id": "3"}
  ]
}
```

Co-authored-by: Duckki Oe <duckki.dev@gmail.com>
@yaacovCR
yaacovCR merged commit 36d96f2 into graphql:17.x.x Oct 16, 2026
22 of 23 checks passed
@yaacovCR
yaacovCR deleted the incremental-fix1 branch October 16, 2026 07:04
yaacovCR added a commit to yaacovCR/graphql-js that referenced this pull request Oct 16, 2026
Follow-up to merged PR [graphql#4868](graphql#4868).
Thanks to Duckki Oe (@duckki) for identifying these issues and providing
an initial fix in [graphql#4859](graphql#4859).

PR graphql#4868 fixed successful shared results. A nested fragment can now
deliver its ready data, including a shared stream, as soon as its parent finishes.
Another fragment's separate failure or slower fields no longer cause that
data to be lost or delayed.

Previously, even after that fix, a shared non-null field failure could
complete a nested deferred fragment before it was announced, although
its parent's separate fields succeeded. This was incorrect: the response
referred to an ID that the client had never received in `pending`.

This commit waits until the nested fragment is announced before sending
its error completion. The client therefore receives its `pending` entry
before its completion, including when both appear in the same response.
If several shared fields have already failed, the fragment completes once
with one shared error. Its parent's successful data is still delivered.

Late outcomes and descendants belonging only to failed fragments are
discarded. Shared work needed by a surviving fragment remains available.

This enables the remaining regression tests introduced in graphql#4859 and adds
integration coverage for error ordering, late outcomes, and shared streams
whose remaining fragment fails.

## @defer: the shared field fails while the parent succeeds

```graphql
{
  hero {
    ... @defer(label: "R") {
      nonNullName
    }
    ... @defer(label: "P") {
      name
      ... @defer(label: "C") {
        nonNullName
      }
    }
  }
}
```

`nonNullName` returns `null`, failing R and C. P's separate `name`
returns `"Luke"` successfully. Both results are available immediately.

Both versions start with:

```json
{
  "data": {"hero": {}},
  "pending": [
    {"id": "0", "path": ["hero"], "label": "R"},
    {"id": "1", "path": ["hero"], "label": "P"}
  ],
  "hasNext": true
}
```

**Before this fix:**

Previously, C completed as ID `"2"` even though only IDs `"0"` and `"1"`
had been announced.

Response 2:

```json
{
  "hasNext": false,
  "incremental": [
    {"id": "1", "data": {"name": "Luke"}}
  ],
  "completed": [
    {
      "id": "0",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {"line": 4, "column": 7},
            {"line": 9, "column": 9}
          ],
          "path": ["hero", "nonNullName"]
        }
      ]
    },
    {
      "id": "2",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {"line": 4, "column": 7},
            {"line": 9, "column": 9}
          ],
          "path": ["hero", "nonNullName"]
        }
      ]
    },
    {"id": "1"}
  ]
}
```

**After this fix:**

This commit announces C as ID `"2"` before sending its error completion,
so the client can identify the failed fragment. P still returns its
successful `name`.

Response 2:

```json
{
  "hasNext": false,
  "pending": [
    {"id": "2", "path": ["hero"], "label": "C"}
  ],
  "incremental": [
    {"id": "1", "data": {"name": "Luke"}}
  ],
  "completed": [
    {
      "id": "0",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {"line": 4, "column": 7},
            {"line": 9, "column": 9}
          ],
          "path": ["hero", "nonNullName"]
        }
      ]
    },
    {"id": "1"},
    {
      "id": "2",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {"line": 4, "column": 7},
            {"line": 9, "column": 9}
          ],
          "path": ["hero", "nonNullName"]
        }
      ]
    }
  ]
}
```

## @defer: two shared fields have already failed

```graphql
{
  hero {
    ... @defer(label: "R1") {
      first: nonNullName
    }
    ... @defer(label: "R2") {
      second: nonNullName
    }
    ... @defer(label: "P") {
      name
      ... @defer(label: "C") {
        first: nonNullName
        second: nonNullName
      }
    }
  }
}
```

`name` returns `"Luke"`. Both `first` and `second` return `null`. C
shares `first` with R1 and `second` with R2; both have failed by the
time P completes.

Both versions start with:

```json
{
  "data": {"hero": {}},
  "pending": [
    {"id": "0", "path": ["hero"], "label": "R1"},
    {"id": "1", "path": ["hero"], "label": "R2"},
    {"id": "2", "path": ["hero"], "label": "P"}
  ],
  "hasNext": true
}
```

**Before this fix:**

Previously, C completed as ID `"3"` without a `pending` entry.

Response 2:

```json
{
  "hasNext": false,
  "incremental": [
    {"id": "2", "data": {"name": "Luke"}}
  ],
  "completed": [
    {
      "id": "0",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {"line": 4, "column": 7},
            {"line": 12, "column": 9}
          ],
          "path": ["hero", "first"]
        }
      ]
    },
    {
      "id": "3",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {"line": 4, "column": 7},
            {"line": 12, "column": 9}
          ],
          "path": ["hero", "first"]
        }
      ]
    },
    {
      "id": "1",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {"line": 7, "column": 7},
            {"line": 13, "column": 9}
          ],
          "path": ["hero", "second"]
        }
      ]
    },
    {"id": "2"}
  ]
}
```

**After this fix:**

This commit announces C before completing it once with a shared error.
In this example, its completion reports the `first` error. R1 and R2 each
report their own error, and P delivers `name`.

Response 2:

```json
{
  "hasNext": false,
  "pending": [
    {"id": "3", "path": ["hero"], "label": "C"}
  ],
  "incremental": [
    {"id": "2", "data": {"name": "Luke"}}
  ],
  "completed": [
    {
      "id": "0",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {"line": 4, "column": 7},
            {"line": 12, "column": 9}
          ],
          "path": ["hero", "first"]
        }
      ]
    },
    {
      "id": "1",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {"line": 7, "column": 7},
            {"line": 13, "column": 9}
          ],
          "path": ["hero", "second"]
        }
      ]
    },
    {"id": "2"},
    {
      "id": "3",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {"line": 4, "column": 7},
            {"line": 12, "column": 9}
          ],
          "path": ["hero", "first"]
        }
      ]
    }
  ]
}
```

Co-authored-by: Duckki Oe <duckki.dev@gmail.com>
yaacovCR added a commit that referenced this pull request Oct 17, 2026
…#4869)

Follow-up to merged PR
[#4868](#4868). Thanks to
Duckki Oe (@duckki) for identifying these issues and providing an
initial fix in [#4859](#4859).

PR #4868 fixed successful shared results. A nested fragment can now
deliver its ready data, including a shared stream, as soon as its parent
finishes. Another fragment's separate failure or slower fields no longer
cause that data to be lost or delayed.

Previously, even after that fix, a shared non-null field failure could
complete a nested deferred fragment before it was announced, although
its parent's separate fields succeeded. This was incorrect: the response
referred to an ID that the client had never received in `pending`.

This PR waits until the nested fragment is announced before sending its
error completion. The client therefore receives its `pending` entry
before its completion, including when both appear in the same response.
If several shared fields have already failed, the fragment completes
once with one shared error. Its parent's successful data is still
delivered.

Late outcomes and descendants belonging only to failed fragments are
discarded. Shared work needed by a surviving fragment remains available.

This enables the remaining regression tests introduced in #4859 and adds
integration coverage for error ordering, late outcomes, and shared
streams whose remaining fragment fails.

## @defer: the shared field fails while the parent succeeds

```graphql
{
  hero {
    ... @defer(label: "R") {
      nonNullName
    }
    ... @defer(label: "P") {
      name
      ... @defer(label: "C") {
        nonNullName
      }
    }
  }
}
```

`nonNullName` returns `null`, failing R and C. P's separate `name`
returns `"Luke"` successfully. Both results are available immediately.

Both versions start with:

```json
{
  "data": {"hero": {}},
  "pending": [
    {"id": "0", "path": ["hero"], "label": "R"},
    {"id": "1", "path": ["hero"], "label": "P"}
  ],
  "hasNext": true
}
```

**Before this fix:**

Previously, C completed as ID `"2"` even though only IDs `"0"` and `"1"`
had been announced.

Response 2:

```json
{
  "hasNext": false,
  "incremental": [
    {"id": "1", "data": {"name": "Luke"}}
  ],
  "completed": [
    {
      "id": "0",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {"line": 4, "column": 7},
            {"line": 9, "column": 9}
          ],
          "path": ["hero", "nonNullName"]
        }
      ]
    },
    {
      "id": "2",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {"line": 4, "column": 7},
            {"line": 9, "column": 9}
          ],
          "path": ["hero", "nonNullName"]
        }
      ]
    },
    {"id": "1"}
  ]
}
```

**After this fix:**

This commit announces C as ID `"2"` before sending its error completion,
so the client can identify the failed fragment. P still returns its
successful `name`.

Response 2:

```json
{
  "hasNext": false,
  "pending": [
    {"id": "2", "path": ["hero"], "label": "C"}
  ],
  "incremental": [
    {"id": "1", "data": {"name": "Luke"}}
  ],
  "completed": [
    {
      "id": "0",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {"line": 4, "column": 7},
            {"line": 9, "column": 9}
          ],
          "path": ["hero", "nonNullName"]
        }
      ]
    },
    {"id": "1"},
    {
      "id": "2",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {"line": 4, "column": 7},
            {"line": 9, "column": 9}
          ],
          "path": ["hero", "nonNullName"]
        }
      ]
    }
  ]
}
```

## @defer: two shared fields have already failed

```graphql
{
  hero {
    ... @defer(label: "R1") {
      first: nonNullName
    }
    ... @defer(label: "R2") {
      second: nonNullName
    }
    ... @defer(label: "P") {
      name
      ... @defer(label: "C") {
        first: nonNullName
        second: nonNullName
      }
    }
  }
}
```

`name` returns `"Luke"`. Both `first` and `second` return `null`. C
shares `first` with R1 and `second` with R2; both have failed by the
time P completes.

Both versions start with:

```json
{
  "data": {"hero": {}},
  "pending": [
    {"id": "0", "path": ["hero"], "label": "R1"},
    {"id": "1", "path": ["hero"], "label": "R2"},
    {"id": "2", "path": ["hero"], "label": "P"}
  ],
  "hasNext": true
}
```

**Before this fix:**

Previously, C completed as ID `"3"` without a `pending` entry.

Response 2:

```json
{
  "hasNext": false,
  "incremental": [
    {"id": "2", "data": {"name": "Luke"}}
  ],
  "completed": [
    {
      "id": "0",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {"line": 4, "column": 7},
            {"line": 12, "column": 9}
          ],
          "path": ["hero", "first"]
        }
      ]
    },
    {
      "id": "3",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {"line": 4, "column": 7},
            {"line": 12, "column": 9}
          ],
          "path": ["hero", "first"]
        }
      ]
    },
    {
      "id": "1",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {"line": 7, "column": 7},
            {"line": 13, "column": 9}
          ],
          "path": ["hero", "second"]
        }
      ]
    },
    {"id": "2"}
  ]
}
```

**After this fix:**

This PR announces C before completing it once with a shared error. In
this example, its completion reports the `first` error. R1 and R2 each
report their own error, and P delivers `name`.

Response 2:

```json
{
  "hasNext": false,
  "pending": [
    {"id": "3", "path": ["hero"], "label": "C"}
  ],
  "incremental": [
    {"id": "2", "data": {"name": "Luke"}}
  ],
  "completed": [
    {
      "id": "0",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {"line": 4, "column": 7},
            {"line": 12, "column": 9}
          ],
          "path": ["hero", "first"]
        }
      ]
    },
    {
      "id": "1",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {"line": 7, "column": 7},
            {"line": 13, "column": 9}
          ],
          "path": ["hero", "second"]
        }
      ]
    },
    {"id": "2"},
    {
      "id": "3",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {"line": 4, "column": 7},
            {"line": 12, "column": 9}
          ],
          "path": ["hero", "first"]
        }
      ]
    }
  ]
}
```

Co-authored-by: Duckki Oe <duckki.dev@gmail.com>
yaacovCR added a commit to yaacovCR/graphql-js that referenced this pull request Oct 18, 2026
This proposal extracts the multiple-error reporting idea from Duckki Oe's
original implementation in [graphql#4859](graphql#4859).

This is an optional follow-up to [graphql#4868](graphql#4868)
and [graphql#4869](graphql#4869). Those changes already
prevent lost or delayed successful results and completions for unannounced
fragments, in line with graphql#4859.

This PR extracts an additional element from graphql#4859, i.e. how many errored tasks
to report for a delivery group with multiple shared tasks that have errored
prior to the start of a deferred group.

Currently, when several shared tasks have failed before a deferred group starts,
the group completes with the first replayed error. This proposal includes every
failure already cached by its retained, started tasks in that one completion.
It exposes more information we already have, without waiting for other work.

For example:

```graphql
{
  hero {
    ... @defer(label: "R1") {
      first: nonNullName
    }
    ... @defer(label: "R2") {
      second: nonNullName
    }
    ... @defer(label: "P") {
      name
      ... @defer(label: "C") {
        first: nonNullName
        second: nonNullName
      }
    }
  }
}
```

`first` and `second` both return `null`, failing R1 and R2 while P's `name`
is still pending. R1 and R2 each report their own error. When `name` resolves
to `"Luke"`, both versions announce C and complete P successfully.

The final response on `17.x.x` is:

```json
{
  "hasNext": false,
  "pending": [
    {
      "id": "3",
      "path": [
        "hero"
      ],
      "label": "C"
    }
  ],
  "incremental": [
    {
      "id": "2",
      "data": {
        "name": "Luke"
      }
    }
  ],
  "completed": [
    {
      "id": "2"
    },
    {
      "id": "3",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {
              "line": 4,
              "column": 7
            },
            {
              "line": 12,
              "column": 9
            }
          ],
          "path": [
            "hero",
            "first"
          ]
        }
      ]
    }
  ]
}
```

With this proposal, the final response is:

```json
{
  "hasNext": false,
  "pending": [
    {
      "id": "3",
      "path": [
        "hero"
      ],
      "label": "C"
    }
  ],
  "incremental": [
    {
      "id": "2",
      "data": {
        "name": "Luke"
      }
    }
  ],
  "completed": [
    {
      "id": "2"
    },
    {
      "id": "3",
      "errors": [
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {
              "line": 4,
              "column": 7
            },
            {
              "line": 12,
              "column": 9
            }
          ],
          "path": [
            "hero",
            "first"
          ]
        },
        {
          "message": "Cannot return null for non-nullable field Hero.nonNullName.",
          "locations": [
            {
              "line": 7,
              "column": 7
            },
            {
              "line": 13,
              "column": 9
            }
          ],
          "path": [
            "hero",
            "second"
          ]
        }
      ]
    }
  ]
}
```

The implementation moves cached-failure inspection from `startTask` to
`startGroup`. It adds an additional pass over a group's tasks before starting
new work to check for cached failures. This also ensures that a group already
known to have failed does not start unnecessary tasks.

Unlike the original implementation's retained group errors, this version reads
the existing computation results. It adds no persistent group-error storage or
WeakSet.

Co-authored-by: Duckki Oe <duckki.dev@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

PR: bug fix 🐞 requires increase of "patch" version number

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant