Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .changeset/transport-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
'graphiql': minor
---

Add a structured `Transport` API alongside the existing `Fetcher`. `createTransport({...})` performs the GraphQL request and returns a `TransportResponse` carrying the real HTTP wire metadata (status, headers, timing, size) for queries, mutations, subscriptions, and incremental delivery, so the response pane can surface those values directly instead of fabricating them. `<GraphiQL>` accepts a new `transport` prop, mutually exclusive with `fetcher` at the type level.
Add a structured `Transport` API alongside the existing `Fetcher`. `createTransport({...})` performs the GraphQL request and returns a `TransportResponse` carrying the real HTTP wire metadata (status, headers, timing, size) for queries, mutations, subscriptions, and incremental delivery, so the response pane can surface those values directly instead of fabricating them. That metadata is there even when the response body isn't valid JSON (an HTML error page from a proxy, a plain-text 401), so a broken response still shows its real status code instead of a generic error. `<GraphiQL>` accepts a new `transport` prop, mutually exclusive with `fetcher` at the type level.

Transports support GET, POST, and the [HTTP `QUERY`](https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/) method per the GraphQL over HTTP spec. Pass `method` / `supportedMethods` to choose; GET encodes the query into the URL with no body, `QUERY` sends a JSON body but is safe and idempotent, and mutations are always sent over POST (or blocked when POST is unavailable). `Transport` exposes `url`, `method`, `supportedMethods`, and an optional `setMethod`, and the top bar shows the active method and endpoint with an inline switcher that cycles through the supported methods. Subscriptions require an explicit `subscriptionClient` satisfying a small `SubscriptionClient` contract: a single `.subscribe(request, sink)` method that `graphql-ws` and `graphql-sse` clients meet directly. The low-level `simpleHttpTransport` and `multipartHttpTransport` primitives also accept an optional `method`.
Transports support GET, POST, and the [HTTP `QUERY`](https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/) method per the GraphQL over HTTP spec. Pass `method` / `supportedMethods` to choose; GET encodes the query into the URL with no body, `QUERY` sends a JSON body but is safe and idempotent, and mutations are always sent over POST (or blocked when POST is unavailable). `Transport` exposes `url`, `method`, `supportedMethods`, and an optional `setMethod`, and the top bar shows the active method and endpoint with an inline switcher that cycles through the supported methods. Every request, incremental delivery on or off, sends `application/graphql-response+json` in its `accept` header alongside `application/json`, so spec-compliant servers don't fall back to legacy response semantics. Subscriptions require an explicit `subscriptionClient` satisfying a small `SubscriptionClient` contract: a single `.subscribe(request, sink)` method that `graphql-ws` and `graphql-sse` clients meet directly. The low-level `simpleHttpTransport` and `multipartHttpTransport` primitives also accept an optional `method`.

Plugins can observe and transform traffic through `transport.onBeforeSend` and `transport.onResponse`, available via `useGraphiQLPluginContext()` (both return a cleanup function; the `transport` field is `undefined` under the legacy `fetcher` path, so guard with optional chaining).
`TransportRequest` carries `extensions` for GraphQL-over-HTTP extensions such as automatic persisted queries (encoded into the URL for `GET`/`QUERY`, included in the body for `POST`), and `signal`, an `AbortSignal` that cancels an in-flight query or mutation. Stopping a running query or mutation aborts the request; stopping a subscription closes the underlying socket or SSE connection instead of just detaching the UI from it. `TransportResponse.ok` reflects both layers: the HTTP status and the absence of top-level GraphQL errors, so a 401 or 500 is never `ok: true` just because its body happens to parse as JSON with no `errors`.

Plugins can observe and transform traffic through `transport.onBeforeSend`, `transport.onResponse`, and `transport.onError`, available via `useGraphiQLPluginContext()` (all three return a cleanup function; the `transport` field is `undefined` under the legacy `fetcher` path, so guard with optional chaining). `onError` fires when a request fails outright, such as a network error, so plugins can react to failures the same way they observe successful responses.

`createGraphiQLFetcher`, the `Fetcher` type and its companions, and `<GraphiQL fetcher={...}>` are deprecated but continue to work unchanged. Consumers on the deprecated path see a one-time dismissible banner in the response pane pointing at `docs/migration/graphiql-6.0.0.md` rather than fabricated status/timing/size values. The CDN bundle exposes `GraphiQL.createTransport` and `GraphiQL.createWsClient` so script-tag consumers can adopt without a bundler.
235 changes: 235 additions & 0 deletions packages/graphiql-react/src/stores/execution.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
import { describe, it, expect, vi } from 'vitest';
import { create } from 'zustand';
import { StorageAPI } from '@graphiql/toolkit';
import type { Transport, TransportResponse } from '@graphiql/toolkit';
import { createExecutionSlice, isResponseView } from './execution';
import { createStorageSlice } from './storage';
import { createEditorSlice } from './editor';
import { createTab } from '../utility/tabs';
import { TransportHookRegistry } from '../transport-hooks';
import { STORAGE_KEY } from '../constants';
import type { SlicesWithActions } from '../types';

Expand Down Expand Up @@ -47,6 +51,65 @@ function makeStore() {
return { store, storage };
}

/**
* A fuller store, wiring `editor` alongside `execution`, so `run()`/`stop()`
* can actually be driven end-to-end (they need `queryEditor`/`responseEditor`
* plus the tab-related actions `run()` calls, like `updateActiveTabValues`).
*/
function makeRunnableStore(initial: { fetcher?: any; transport?: Transport }) {
const storage = new StorageAPI(makeMemoryStorage());
const tab = createTab({ query: 'query Foo { bar }' });

const store = create<SlicesWithActions>((...args) => {
const storageSlice = createStorageSlice({ storage })(...args);
const editorSlice = createEditorSlice({
activeTabIndex: 0,
defaultHeaders: undefined,
defaultQuery: '',
externalFragments: new Map(),
initialHeaders: '',
initialQuery: '',
initialVariables: '',
onCopyQuery: undefined,
onEditOperationName: undefined,
onPrettifyQuery: async q => q,
onTabChange: undefined,
shouldPersistHeaders: false,
tabs: [tab],
uriInstanceId: 'test-',
})(...args);
const executionSlice = createExecutionSlice({
fetcher: initial.fetcher,
transport: initial.transport,
getDefaultFieldNames: undefined,
overrideOperationName: null,
})(...args);

return {
...storageSlice,
...editorSlice,
...executionSlice,
actions: {
...editorSlice.actions,
...executionSlice.actions,
} as any,
} as SlicesWithActions;
});

const queryEditor = {
getValue: vi.fn(() => 'query Foo { bar }'),
} as any;
const responseEditor = {
getValue: vi.fn(() => ''),
setValue: vi.fn(),
} as any;
store.getState().actions.setEditor({ queryEditor, responseEditor });

return { store, queryEditor, responseEditor };
}

const tick = () => new Promise<void>(resolve => setTimeout(resolve, 0));

describe('isResponseView', () => {
it('accepts the three known views', () => {
expect(isResponseView('json')).toBe(true);
Expand Down Expand Up @@ -108,3 +171,175 @@ describe('dismissTransportUpgradeBanner', () => {
);
});
});

describe('run/stop — subscription teardown', () => {
/**
* A `Transport` whose `send()` returns an object shaped exactly like
* `transport-hooks.ts#wrap`'s output: `[Symbol.asyncIterator]()` mints a
* brand-new, independently-disposable iterator on every call, instead of
* returning the same one. `onReturn` is called with the id of whichever
* iterator instance actually had `.return()` invoked on it.
*/
function makeFreshIteratorTransport(onReturn: (id: number) => void) {
let nextId = 0;
let emittedCount = 0;
const transport: Transport = {
url: 'https://example.test/graphql',
method: 'POST',
supportedMethods: ['POST'],
send: () => ({
[Symbol.asyncIterator]() {
const id = nextId++;
let disposed = false;
return {
async next(): Promise<IteratorResult<TransportResponse>> {
if (disposed) {
return { value: undefined as never, done: true };
}
await tick();
if (disposed) {
return { value: undefined as never, done: true };
}
emittedCount += 1;
return {
value: {
ok: true,
body: { data: { tick: emittedCount } },
timing: { totalMs: 0 },
size: {},
},
done: false,
};
},
async return(value?: unknown) {
disposed = true;
onReturn(id);
return { value: value as TransportResponse, done: true as const };
},
};
},
}),
};
return { transport, getEmittedCount: () => emittedCount };
}

it('stop() disposes the iterator actually driving the subscription and stops delivery', async () => {
const disposedIds: number[] = [];
const { transport, getEmittedCount } = makeFreshIteratorTransport(id =>
disposedIds.push(id),
);
const { store } = makeRunnableStore({ transport });

const runPromise = store.getState().actions.run();
// Everything before the first `await it.next()` runs synchronously, so
// `subscription` is already populated by the time `run()` returns its
// promise.
expect(store.getState().subscription).not.toBeNull();

await tick();
await tick();
const emittedBeforeStop = getEmittedCount();
expect(emittedBeforeStop).toBeGreaterThan(0);

store.getState().actions.stop();
await runPromise;

const emittedRightAfterStop = getEmittedCount();
await tick();
await tick();
await tick();

// The defining assertion: no more events after `stop()`. Under the old
// `iter[Symbol.asyncIterator]().return?.()` bug, `unsubscribe` disposes a
// throwaway iterator while the real one keeps running forever, so this
// would keep growing and `runPromise` would never resolve.
expect(getEmittedCount()).toBe(emittedRightAfterStop);
expect(store.getState().subscription).toBeNull();
expect(store.getState().isFetching).toBe(false);
});

it('the wrapped-transport path (registry.wrap) still stops delivery on stop()', async () => {
// Exercises the real `TransportHookRegistry.wrap()` shape (the thing bug
// 2 was actually reported against), composed the same way
// `<GraphiQLProvider transport={...}>` wires it up in production.
const disposedIds: number[] = [];
const { transport: rawTransport, getEmittedCount } =
makeFreshIteratorTransport(id => disposedIds.push(id));
const registry = new TransportHookRegistry();
const transport = registry.wrap(rawTransport);

const { store } = makeRunnableStore({ transport });

const runPromise = store.getState().actions.run();
expect(store.getState().subscription).not.toBeNull();

await tick();
await tick();
expect(getEmittedCount()).toBeGreaterThan(0);

store.getState().actions.stop();
await runPromise;

const emittedRightAfterStop = getEmittedCount();
await tick();
await tick();
await tick();

expect(getEmittedCount()).toBe(emittedRightAfterStop);
expect(store.getState().subscription).toBeNull();
});
});

describe('run/stop — abort in-flight query/mutation', () => {
it('stop() aborts the request and no response is ever dispatched', async () => {
let capturedSignal: AbortSignal | undefined;
const transport: Transport = {
url: 'https://example.test/graphql',
method: 'POST',
supportedMethods: ['POST'],
send: req =>
new Promise((_resolve, reject) => {
capturedSignal = req.signal;
req.signal?.addEventListener('abort', () => {
reject(new DOMException('The operation was aborted', 'AbortError'));
});
// Deliberately never resolves on its own — only `stop()` ends it.
}),
};
const { store, responseEditor } = makeRunnableStore({ transport });

const runPromise = store.getState().actions.run();
expect(store.getState().isFetching).toBe(true);
expect(store.getState().abortController).not.toBeNull();
expect(capturedSignal?.aborted).toBe(false);

store.getState().actions.stop();
await runPromise;

expect(capturedSignal?.aborted).toBe(true);
expect(store.getState().isFetching).toBe(false);
expect(store.getState().abortController).toBeNull();
// No response was ever dispatched: `lastResponse` stays at its initial
// `null`, and the response pane is never told to render an abort error.
expect(store.getState().lastResponse).toBeNull();
expect(responseEditor.setValue).not.toHaveBeenCalledWith(
expect.stringContaining('abort'),
);
});

it('a genuine request failure (not caused by stop()) still surfaces an error', async () => {
const transport: Transport = {
url: 'https://example.test/graphql',
method: 'POST',
supportedMethods: ['POST'],
send: () => Promise.reject(new Error('network down')),
};
const { store } = makeRunnableStore({ transport });

await store.getState().actions.run();

expect(store.getState().lastResponse).not.toBeNull();
expect(store.getState().lastResponse?.ok).toBe(false);
expect(store.getState().isFetching).toBe(false);
});
});
Loading
Loading