Skip to content

fix(incremental): prevent error completions for unannounced fragments - #4869

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

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

Conversation

@yaacovCR

Copy link
Copy Markdown
Contributor

Follow-up to merged PR #4868. Thanks to Duckki Oe (@duckki) for identifying these issues and providing an initial fix in #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

{
  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:

{
  "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:

{
  "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:

{
  "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

{
  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:

{
  "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:

{
  "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:

{
  "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"]
        }
      ]
    }
  ]
}

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>
@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.

@yaacovCR yaacovCR added the PR: bug fix 🐞 requires increase of "patch" version number label Oct 16, 2026
@yaacovCR
yaacovCR merged commit 4a9b312 into graphql:17.x.x Oct 17, 2026
23 of 24 checks passed
@yaacovCR
yaacovCR deleted the incremental-fix2 branch October 17, 2026 18:25
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