v6: fix transport error metadata, subscription teardown, and the spec accept header - #4429
Draft
trevor-scheer wants to merge 7 commits into
Draft
v6: fix transport error metadata, subscription teardown, and the spec accept header#4429trevor-scheer wants to merge 7 commits into
trevor-scheer wants to merge 7 commits into
Conversation
|
| Name | Type |
|---|---|
| @graphiql/toolkit | Minor |
| @graphiql/react | Minor |
| graphiql | Minor |
| @graphiql/plugin-history | Major |
| @graphiql/plugin-code-exporter | Major |
| @graphiql/plugin-collections | Major |
| @graphiql/plugin-doc-explorer | Major |
| @graphiql/plugin-query-builder | Major |
Click here to learn what changesets are, and how to add one.
Click here if you're a maintainer who wants to add a changeset to this PR
trevor-scheer
force-pushed
the
trevor/transport-correctness
branch
from
August 11, 2026 01:11
0b32914 to
aded491
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Behavioral fixes to the transport layer, plus two additive request fields, before
Transportfreezes at6.0.0.create-fetcher/lib.tscalledresponse.json()unconditionally. A non-JSON error body (an HTML page from a proxy, a plain-text 401, an empty 204) made.json()throw, and the rejection collapsed into a generic error string exactly when the caller most neededstatus/statusText/headers. The body is now read as text first; if it parses as JSON it's used as-is, otherwise the raw text becomes the error message, but either way a realTransportResponsecarrying the actual HTTP status comes back instead of a rejection. Reported response sizes use the raw wire text rather than a re-stringification of the parsed body.transport-hooks.ts#wrap) hands back an object whose[Symbol.asyncIterator]()mints a fresh iterator on every call.execution.ts's unsubscribe logic called that method a second time to get something to.return(), which built a throwaway iterator and closed it while the real one, still driving the loop, kept running untouched. Clicking Stop looked like it worked but the underlying socket or SSE connection just kept going. Both the transport and fetcher paths now capture the iterator once and drive/dispose that same object, feeding it tofor awaitthrough a one-shot iterable so the loop's automatic.return()on abrupt completion (say, a throwing response handler) still disposes the right iterator.wrap()itself handles the disposal races:return()during an awaitedonBeforeSendhook means the underlyingsend()is never invoked, andreturn()while a request is in flight drops the late value instead of delivering it.run()creates anAbortControllerper request and passes its signal throughTransportRequest.signalto the underlyingfetch;stop()aborts it, and the aborted run stays silent instead of painting an abort error into the response pane.stop()also invalidates the run's query id, so even a custom transport that ignores the signal can't paint a late response into a pane the user already cleared, and starting a new run aborts whatever a previous run left in flight so a superseded request can neither paint results nor surface its errors over the newer run's. For streams (subscriptions, incremental delivery) the controller stays armed for the whole lifetime as an HTTP-level backstop — primary teardown is iterator disposal, but an aborted signal guarantees the connection dies even if some link in the disposal chain fails to forward.return().multipartHttpTransport, whose accept header wasapplication/json, multipart/mixedwith noapplication/graphql-response+json. Out of the box this asked spec-compliant servers to fall back to legacy response semantics. It now sendsmultipart/mixed, application/graphql-response+json;q=0.9, application/json;q=0.8— explicit q-values so a server tie-break can't route an incremental response away from multipart, with the spec media type preferred among the JSON forms (matching the simple transport'sapplication/graphql-response+json, application/json;q=0.9).extensionsandsignaltoTransportRequest.extensionsrides along GraphQL-over-HTTP requests for things like persisted queries, and was already claimed (incorrectly) by a doc comment that never encoded it. It's JSON-stringified into the URL forGETand sent in the JSON body forPOSTandQUERY.signalis a plainAbortSignal, wired intorun()/stop()as above. (Subscription Stop is the teardown fix above, not the abort signal — sockets need real teardown, not just an aborted fetch.)A few judgment calls, made rather than left as an inconsistent matrix:
okis nowresponse.ok && !hasGraphQLErrorsinstead of only looking at the GraphQL body. A 401 or 500 was previouslyok: trueif the body happened to parse as JSON with noerrorsarray, which collides with whatResponse.okmeans everywhere else. Subscriptions have no HTTP status to consult, so theirokstays purely GraphQL-error-based.onErrorhook to the plugin transport context, alongside the existingonBeforeSend/onResponse. Without it a plugin had no way to observe a request that failed outright (network error, thrownonBeforeSendhook); for a frozen plugin API that asymmetry seemed like the likeliest regret to leave unaddressed.onErrorreceives the request as transformed by whicheveronBeforeSendhooks had run before the failure — the closest thing to what went on the wire — and does not fire for a request the user already stopped (an aborted fetch isn't a failure anyone is listening to).onResponseandonErrorare observe-only in the strong sense: a callback that throws is logged to the console and skipped, so one buggy plugin observer can't kill the stream or masquerade as a transport failure.ResolverTracetype andtiming.resolverTracesfield. Adding fields later is non-breaking, so reserving them now bought nothing and just shipped a field that always readsundefined.wrap()'s hand-rolled async iterator alone rather than rewriting it as anasync function*. The iterator-capture fix plus the disposal-race checks are the actual bugs; a generator rewrite is a real simplification but a separate, riskier change.This PR also updates
create-transport/README.md: documents the newextensions/signalfields and fixes a stalelegacyClientreference (should belegacyWsClient).Test plan
/graphqlin devtools and force an HTML/plain-text error response) and confirm the response pane shows a real500status badge, not a generic error message.Acceptlistsmultipart/mixedfirst and includesapplication/graphql-response+json.extensionsset on aTransportand confirm it shows up in the request (query string for GET, JSON body for POST/QUERY).yarn workspace @graphiql/toolkit test && yarn workspace @graphiql/react testpasses.Refs: #4219