Skip to content

Support @defer and @stream in the e2e test suite - #5409

Draft
captbaritone wants to merge 1 commit into
facebook:mainfrom
captbaritone:e2e-incremental-delivery
Draft

captbaritone wants to merge 1 commit into
facebook:mainfrom
captbaritone:e2e-incremental-delivery

Conversation

@captbaritone

Copy link
Copy Markdown
Contributor

Adds @defer/@stream support to the markdown-driven e2e suite, plus three fixtures covering it.

Incremental delivery did not work in these fixtures, and neither reason it did not work was loud.

The compiler stripped @defer from the query text

relay-compiler does not define @defer/@stream — it reads them off the schema, and it has to be the server schema. Grats has no reason to emit them (they are executor-level, not part of anyone's resolvers), so runFixture now appends the definitions to the generated schema.graphql between the Grats and relay-compiler steps.

Supplying them through schemaExtensions instead does compile, which is the trap: skip_client_directives then strips the directive out of the printed query text as a client-only directive, so the server is never asked to defer. The field comes back inline while the generated artifact still holds a Defer node waiting for a patch that never arrives, so the field reads as missing. The fixture passes with its deferred content silently empty.

graphql-js ignored the directive regardless

collectFields checks schema._enableDeferStream and returns before it ever looks at @defer. The flag only takes effect through the GraphQLSchema constructor and does not round-trip through toConfig(), so GratsNetwork re-wraps the Grats schema to turn it on.

Trailing payload

GratsNetwork also drops graphql-js's closing {hasNext: false} payload, which carries neither data nor errors and so is not a GraphQLResponse. Relay reported it as No data returned for operation and logged a warning. Nothing is lost: Relay learns the request finished when the observable completes. @defer rarely hits this — graphql-js folds hasNext: false into the final patch when it knows that patch is last — but @stream emits the trailing payload every time.

Fixtures

Fixture Covers
defer/basic Deferred fragment renders once its patch is normalized
defer/suspense-fallback Fallback is on screen while the patch is outstanding
stream/list-field Streamed list items; the trailing-payload path

Fixtures need no per-fixture setup now — spread with @defer and give the field an async resolver.

defer/suspense-fallback uses a technique worth flagging in review: an async resolver settles a microtask later, so asserting on the pending state would be a race. Instead server.ts exports a plain non-schema releaseBio() that a button calls, since fixture server and client share a module realm. That makes wait "Loading bio..." deterministic. Documented in the writing guide.

@stream is included because the graphql-js flag enables both; declaring only @defer would leave half of it enabled and untested. The stream fixture is what caught the trailing-payload bug.

Test plan

yarn test:e2e — the three new fixtures pass, and the rest of the suite is unchanged by the harness edits (verified by stashing them and re-running).

Four fixtures (errors/nested-catch-field-error{,-flag-enabled}, pagination/root-catch-{load-next,refetch}) fail in my working tree, but they fail identically without these changes — unrelated in-progress local work, not caused by this PR and not touched by it.

@meta-cla meta-cla Bot added the CLA Signed label Sep 2, 2026
@captbaritone
captbaritone force-pushed the e2e-incremental-delivery branch from d74e3f5 to c496d26 Compare September 2, 2026 21:09
Incremental delivery did not work in the markdown e2e fixtures, and both
reasons it did not work were silent rather than loud.

The Relay compiler does not define `@defer`/`@stream`; it reads them off
the schema, and it has to be the *server* schema. Grats has no reason to
emit them -- they are executor-level, not part of anyone's resolvers --
so `runFixture` now appends the two definitions to the generated
`schema.graphql` between the Grats and relay-compiler steps. Supplying
them through `schemaExtensions` instead does compile, but
`skip_client_directives` then strips the directive out of the printed
query text as a client-only directive, so the server is never asked to
defer. The field comes back inline while the generated artifact still
holds a `Defer` node waiting for a patch, and the fixture passes with
its deferred field silently empty.

graphql-js ignored the directive regardless: `collectFields` checks
`schema._enableDeferStream` and returns before looking at it. The flag
only takes effect through the `GraphQLSchema` constructor and does not
round-trip through `toConfig()`, so `GratsNetwork` re-wraps the Grats
schema to turn it on.

`GratsNetwork` also drops graphql-js's trailing `{hasNext: false}`
payload, which carries neither `data` nor `errors` and so is not a
`GraphQLResponse`. Relay reported it as "No data returned for operation"
and logged a warning. Nothing is lost by dropping it: Relay learns the
request finished when the observable completes. `@defer` rarely hits
this because graphql-js folds `hasNext: false` into the final patch,
but `@stream` emits the trailing payload every time.

Three fixtures. `defer/basic` covers a deferred fragment rendering once
its patch is normalized. `defer/suspense-fallback` asserts on the
pending state, which needs a resolver held open by the client rather
than a bare `async` resolver -- otherwise the fallback settles a
microtask later and the assertion is a race. `stream/list-field` keeps
the other half of the schema flag honest and is what caught the
trailing-payload bug.
@captbaritone
captbaritone force-pushed the e2e-incremental-delivery branch from c496d26 to 767a804 Compare September 2, 2026 21:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant