From 4fe3cf3b17d84f6a6be52ecdb4b3c593caec034c Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Thu, 22 Oct 2026 23:25:06 +0000 Subject: [PATCH 1/9] discover transport methods with OPTIONS --- .../__tests__/createTransport.spec.ts | 14 +- .../__tests__/discovery.spec.ts | 391 ++++++++++++++++++ .../src/create-transport/createTransport.ts | 321 +++++++++++--- .../src/create-transport/types.ts | 18 +- 4 files changed, 679 insertions(+), 65 deletions(-) create mode 100644 packages/graphiql-toolkit/src/create-transport/__tests__/discovery.spec.ts diff --git a/packages/graphiql-toolkit/src/create-transport/__tests__/createTransport.spec.ts b/packages/graphiql-toolkit/src/create-transport/__tests__/createTransport.spec.ts index f67d38a11bd..8afb16ab598 100644 --- a/packages/graphiql-toolkit/src/create-transport/__tests__/createTransport.spec.ts +++ b/packages/graphiql-toolkit/src/create-transport/__tests__/createTransport.spec.ts @@ -183,7 +183,7 @@ describe('createTransport — query / mutation', () => { extensions: { persistedQuery: { sha256Hash: 'abc' } }, }); - const [, fetchInit] = mockFetch.mock.calls[0] as [string, RequestInit]; + const [, fetchInit] = mockFetch.mock.calls.at(-1)! as [string, RequestInit]; expect(JSON.parse(fetchInit.body as string)).toMatchObject({ extensions: { persistedQuery: { sha256Hash: 'abc' } }, }); @@ -202,7 +202,7 @@ describe('createTransport — query / mutation', () => { await transport.send({ query: QUERY, signal: controller.signal }); - const [, fetchInit] = mockFetch.mock.calls[0] as [string, RequestInit]; + const [, fetchInit] = mockFetch.mock.calls.at(-1)! as [string, RequestInit]; expect(fetchInit.signal).toBe(controller.signal); }); }); @@ -281,14 +281,14 @@ describe('createTransport — GET method', () => { vi.clearAllMocks(); }); - it('defaults to POST-only: no setMethod, requests go out as POST', () => { + it('starts with POST available before discovery', () => { const transport = createTransport({ url: URL, fetch: mockFetch as typeof fetch, }); expect(transport.method).toBe('POST'); expect(transport.supportedMethods).toEqual(['POST']); - expect(transport.setMethod).toBeUndefined(); + expect(transport.setMethod).toBeDefined(); }); it("GET-only: active method is GET when supportedMethods is ['GET']", () => { @@ -299,7 +299,7 @@ describe('createTransport — GET method', () => { }); expect(transport.method).toBe('GET'); expect(transport.supportedMethods).toEqual(['GET']); - expect(transport.setMethod).toBeUndefined(); + expect(transport.setMethod).toBeDefined(); }); it('GET-only: a query goes out as GET with params in the URL', async () => { @@ -506,7 +506,7 @@ describe('createTransport — GET method', () => { await transport.send({ query: QUERY }); - const [, fetchInit] = mockFetch.mock.calls[0] as [string, RequestInit]; + const [, fetchInit] = mockFetch.mock.calls.at(-1)! as [string, RequestInit]; expect((fetchInit.headers as Record)['content-type']).toBe( 'application/json', ); @@ -534,7 +534,7 @@ describe('createTransport — GET method', () => { // consume } - const [, fetchInit] = mockFetch.mock.calls[0] as [string, RequestInit]; + const [, fetchInit] = mockFetch.mock.calls.at(-1)! as [string, RequestInit]; expect((fetchInit.headers as Record).accept).toMatch( 'application/graphql-response+json', ); diff --git a/packages/graphiql-toolkit/src/create-transport/__tests__/discovery.spec.ts b/packages/graphiql-toolkit/src/create-transport/__tests__/discovery.spec.ts new file mode 100644 index 00000000000..6ef214b50b4 --- /dev/null +++ b/packages/graphiql-toolkit/src/create-transport/__tests__/discovery.spec.ts @@ -0,0 +1,391 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import 'isomorphic-fetch'; +import { createTransport } from '../createTransport'; +import type { CreateTransportOptions, TransportResponse } from '../types'; + +const url = 'http://localhost/graphql'; +const query = '{ __typename }'; +const mutation = 'mutation { update }'; +const response = (allow?: string, status = 200) => ({ + ok: status >= 200 && status < 300, + status, + headers: new Headers(allow === undefined ? {} : { allow }), + text: async () => '{"data":{}}', +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { + resolve = done; + }); + return { promise, resolve }; +} + +function setup(allow?: string, options: Partial = {}) { + const fetch = vi.fn().mockResolvedValue(response(allow)); + const transport = createTransport({ + url, + fetch, + enableIncrementalDelivery: false, + ...options, + }); + return { transport, fetch }; +} + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('HTTP method discovery', () => { + it('discovers lazily, updates subscribers, and preserves POST when available', async () => { + const { transport, fetch } = setup('QUERY, POST, GET, QUERY, OPTIONS'); + const listener = vi.fn(); + const unsubscribe = transport.subscribe!(listener); + expect(fetch).not.toHaveBeenCalled(); + const result = transport.send({ query }); + expect(result).toBeInstanceOf(Promise); + await result; + expect(fetch.mock.calls.map(([, init]) => init.method)).toEqual([ + 'OPTIONS', + 'POST', + ]); + expect(fetch.mock.calls[0]).toEqual([ + url, + expect.objectContaining({ method: 'OPTIONS' }), + ]); + expect(fetch.mock.calls[0][1].body).toBeUndefined(); + expect(transport.supportedMethods).toEqual(['GET', 'POST', 'QUERY']); + expect(listener).toHaveBeenCalledTimes(1); + transport.setMethod!('QUERY'); + transport.setMethod!('QUERY'); + expect(listener).toHaveBeenCalledTimes(2); + unsubscribe(); + await transport.send({ query }); + expect(fetch.mock.calls.map(([, init]) => init.method)).toEqual([ + 'OPTIONS', + 'POST', + 'QUERY', + ]); + transport.setMethod!('GET'); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it.each(['GET, QUERY', 'QUERY', 'GET'])( + 'selects an advertised replacement from %s', + async allow => { + const { transport, fetch } = setup(allow); + await transport.send({ query }); + expect(transport.method).toBe(allow.includes('QUERY') ? 'QUERY' : 'GET'); + expect(fetch.mock.calls[1][1].method).toBe(transport.method); + }, + ); + + it('supports an initial QUERY method without a manual capability list', async () => { + const { transport } = setup('GET, QUERY', { method: 'QUERY' }); + await transport.send({ query }); + expect(transport.method).toBe('QUERY'); + }); + + it.each([undefined, 'GET QUERY', 'GET; foo=bar'])( + 'falls back when Allow is unavailable or malformed (%s)', + async allow => { + const { transport, fetch } = setup(allow, { method: 'QUERY' }); + await transport.send({ query }); + await transport.send({ query }); + expect(transport.supportedMethods).toEqual(['QUERY']); + expect(fetch.mock.calls.map(([, init]) => init.method)).toEqual([ + 'OPTIONS', + 'QUERY', + 'QUERY', + ]); + }, + ); + + it.each(['', 'OPTIONS, HEAD', 'post, query', ', ,'])( + 'does not execute when advertised methods are unusable (%s)', + async allow => { + const { transport, fetch } = setup(allow); + await expect(transport.send({ query })).rejects.toThrow( + /no supported GraphQL HTTP methods/, + ); + expect(transport.supportedMethods).toEqual([]); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + it('accepts optional empty list elements and whitespace in Allow', async () => { + const { transport } = setup(', GET, ,\tQUERY,'); + await transport.send({ query }); + expect(transport.supportedMethods).toEqual(['GET', 'QUERY']); + }); + + it.each([401, 405, 500])( + 'falls back when OPTIONS is rejected with %s', + async status => { + const { transport, fetch } = setup(); + fetch.mockResolvedValueOnce(response('GET', status)); + await transport.send({ query }); + expect(transport.supportedMethods).toEqual(['POST']); + expect(fetch.mock.calls[1][1].method).toBe('POST'); + }, + ); + + it('falls back after a network failure', async () => { + const { transport, fetch } = setup(); + fetch.mockRejectedValueOnce(new TypeError('Failed to fetch')); + await transport.send({ query }); + expect(fetch.mock.calls[1][1].method).toBe('POST'); + }); + + it('bounds discovery even if a fetch implementation ignores abort', async () => { + vi.useFakeTimers(); + const { transport, fetch } = setup(); + fetch.mockReturnValueOnce(new Promise(() => {})); + const result = transport.send({ query }); + await vi.advanceTimersByTimeAsync(2000); + await result; + expect(fetch.mock.calls[0][1].signal.aborted).toBe(true); + expect(fetch.mock.calls[1][1].method).toBe('POST'); + }); + + it('shares concurrent discovery and normalizes effective authentication headers', async () => { + const { transport, fetch } = setup(undefined, { + headers: { Authorization: 'static', 'X-Custom': 'value' }, + }); + const options = deferred>(); + fetch.mockReturnValueOnce(options.promise); + const first = transport.send({ + query, + headers: { authorization: 'current' }, + }); + const second = transport.send({ + query, + headers: { AUTHORIZATION: 'current' }, + }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch.mock.calls[0][1].headers).toEqual({ + authorization: 'current', + 'x-custom': 'value', + }); + options.resolve(response('QUERY')); + await Promise.all([first, second]); + expect(fetch.mock.calls.map(([, init]) => init.method)).toEqual([ + 'OPTIONS', + 'QUERY', + 'QUERY', + ]); + expect(fetch.mock.calls[1][1].headers.authorization).toBe('current'); + await transport.send({ query, headers: { authorization: 'new' } }); + expect(fetch.mock.calls[3][1].method).toBe('OPTIONS'); + }); + + it('preserves a selected method when a new header context also supports it', async () => { + const { transport } = setup('GET, POST, QUERY'); + await transport.send({ query }); + transport.setMethod!('QUERY'); + await transport.send({ query, headers: { authorization: 'new' } }); + expect(transport.method).toBe('QUERY'); + }); + + it.each([false, true])( + 'captures the method when sending before discovery settles (incremental: %s)', + async incremental => { + const { transport, fetch } = setup('GET, POST, QUERY', { + enableIncrementalDelivery: incremental, + }); + async function consume(result: ReturnType) { + if (Symbol.asyncIterator in result) { + for await (const _ of result) { + /* consume */ + } + } else { + await result; + } + } + await consume(transport.send({ query })); + transport.setMethod!('QUERY'); + const options = deferred>(); + fetch.mockReturnValueOnce(options.promise); + const result = transport.send({ + query, + headers: { authorization: 'new' }, + }); + transport.setMethod!('GET'); + const pending = consume(result); + options.resolve(response('GET, POST, QUERY')); + await pending; + expect(fetch.mock.calls.at(-1)![1].method).toBe('QUERY'); + expect(transport.method).toBe('GET'); + }, + ); + + it('isolates failing listeners from discovery, later listeners, and execution', async () => { + const { transport, fetch } = setup('GET, POST, QUERY'); + const error = new Error('observer failed'); + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + transport.subscribe!(() => { + throw error; + }); + const listener = vi.fn(); + transport.subscribe!(listener); + await transport.send({ query }); + expect(listener).toHaveBeenCalledTimes(1); + expect(logged).toHaveBeenCalledWith( + 'Transport change listener threw:', + error, + ); + expect(() => transport.setMethod!('QUERY')).not.toThrow(); + expect(listener).toHaveBeenCalledTimes(2); + await transport.send({ query }); + expect(fetch.mock.calls.map(([, init]) => init.method)).toEqual([ + 'OPTIONS', + 'POST', + 'QUERY', + ]); + }); + + it('preserves the configured manual order when POST is absent', async () => { + const { transport, fetch } = setup(undefined, { + supportedMethods: ['GET', 'QUERY'], + }); + expect(transport.method).toBe('GET'); + await transport.send({ query }); + expect(fetch.mock.calls.map(([, init]) => init.method)).toEqual(['GET']); + }); + + it('does not let an older header context overwrite current capabilities', async () => { + const { transport, fetch } = setup(); + const old = deferred>(); + const current = deferred>(); + fetch.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise); + const first = transport.send({ query, headers: { authorization: 'old' } }); + const second = transport.send({ query, headers: { authorization: 'new' } }); + current.resolve(response('GET')); + await second; + old.resolve(response('QUERY')); + await first; + expect(transport.supportedMethods).toEqual(['GET']); + expect(transport.method).toBe('GET'); + expect(fetch.mock.calls.slice(2).map(([, init]) => init.method)).toEqual([ + 'GET', + 'QUERY', + ]); + }); + + it('rechecks a previously unusable endpoint when headers change', async () => { + const { transport, fetch } = setup(''); + await expect(transport.send({ query })).rejects.toThrow(/no supported/); + fetch.mockResolvedValue(response('POST')); + await transport.send({ query, headers: { authorization: 'new' } }); + expect(transport.supportedMethods).toEqual(['POST']); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it('cancels one waiting execution without canceling shared discovery', async () => { + const { transport, fetch } = setup(); + const options = deferred>(); + fetch.mockReturnValueOnce(options.promise); + const controller = new AbortController(); + const canceled = transport.send({ query, signal: controller.signal }); + const other = transport.send({ query }); + controller.abort(); + await expect(canceled).rejects.toMatchObject({ name: 'AbortError' }); + expect(fetch.mock.calls[0][1].signal.aborted).toBe(false); + options.resolve(response('QUERY')); + await other; + expect(fetch.mock.calls.map(([, init]) => init.method)).toEqual([ + 'OPTIONS', + 'QUERY', + ]); + }); + + it('does not discover or execute with an already aborted signal', async () => { + const { transport, fetch } = setup(); + const controller = new AbortController(); + controller.abort(); + await expect( + transport.send({ query, signal: controller.signal }), + ).rejects.toMatchObject({ name: 'AbortError' }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('preserves incremental AsyncIterable behavior and discovers only when consumed', async () => { + const { transport, fetch } = setup('QUERY', { + enableIncrementalDelivery: true, + }); + const result = transport.send({ + query, + }) as AsyncIterable; + expect(result[Symbol.asyncIterator]).toBeTypeOf('function'); + expect(fetch).not.toHaveBeenCalled(); + const chunks = []; + for await (const chunk of result) { + chunks.push(chunk); + } + expect(chunks).toHaveLength(1); + expect(fetch.mock.calls.map(([, init]) => init.method)).toEqual([ + 'OPTIONS', + 'QUERY', + ]); + }); + + it('closing an incremental iterator while discovering prevents execution', async () => { + const { transport, fetch } = setup('QUERY', { + enableIncrementalDelivery: true, + }); + const options = deferred>(); + fetch.mockReturnValueOnce(options.promise); + const iterator = ( + transport.send({ query }) as AsyncIterable + )[Symbol.asyncIterator](); + const pending = iterator.next(); + await Promise.all([ + expect(pending).rejects.toMatchObject({ name: 'AbortError' }), + iterator.return!(), + ]); + options.resolve(response('QUERY')); + await Promise.resolve(); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('routes mutations only through discovered POST', async () => { + const { transport, fetch } = setup('POST, QUERY', { method: 'QUERY' }); + await transport.send({ query: mutation }); + expect(fetch.mock.calls[1][1].method).toBe('POST'); + expect(transport.method).toBe('QUERY'); + const safeOnly = setup('QUERY'); + await expect(safeOnly.transport.send({ query: mutation })).rejects.toThrow( + /mutation.*QUERY/, + ); + expect(safeOnly.fetch).toHaveBeenCalledTimes(1); + }); + + it('keeps explicit supportedMethods authoritative and validates empty lists', async () => { + const { transport, fetch } = setup('QUERY', { supportedMethods: ['POST'] }); + await transport.send({ query }); + expect(fetch.mock.calls.map(([, init]) => init.method)).toEqual(['POST']); + expect(() => transport.setMethod!('QUERY')).toThrow(/not supported/); + expect(() => setup(undefined, { supportedMethods: [] })).toThrow( + /at least one/, + ); + }); + + it('does not discover for subscriptions', async () => { + const { transport, fetch } = setup(undefined, { + subscriptionClient: { + async *iterate() { + yield { data: { tick: 1 } }; + }, + }, + }); + const events = []; + for await (const event of transport.send({ + query: 'subscription { tick }', + }) as AsyncIterable) { + events.push(event); + } + expect(events).toHaveLength(1); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/graphiql-toolkit/src/create-transport/createTransport.ts b/packages/graphiql-toolkit/src/create-transport/createTransport.ts index 1bc740b3593..095ef90ea64 100644 --- a/packages/graphiql-toolkit/src/create-transport/createTransport.ts +++ b/packages/graphiql-toolkit/src/create-transport/createTransport.ts @@ -92,25 +92,71 @@ export function createTransport(opts: CreateTransportOptions): Transport { const incrementalDelivery = opts.enableIncrementalDelivery !== false; - const fetcherOptions: CreateFetcherOptions = { - url: opts.url, - headers: opts.headers, - }; - - const supportedMethods: HttpMethod[] = opts.supportedMethods ?? ['POST']; - + const initialMethod = opts.method ?? 'POST'; + let supportedMethods = opts.supportedMethods?.slice() ?? [initialMethod]; + if (!supportedMethods.length) { + throw new Error('supportedMethods must contain at least one HTTP method.'); + } if (opts.method !== undefined && !supportedMethods.includes(opts.method)) { throw new Error( `"${opts.method}" is not in supportedMethods (${supportedMethods.join(', ')}). ` + `Either add it to supportedMethods or omit the method option.`, ); } - - // Default to POST when available; otherwise fall back to whichever single - // method was configured (e.g. GET-only). - let activeMethod: HttpMethod = + let activeMethod = opts.method ?? (supportedMethods.includes('POST') ? 'POST' : supportedMethods[0]); + const listeners = new Set<() => void>(); + let discovery: { key: string; result: Promise } | undefined; + + function notify() { + for (const listener of listeners) { + try { + listener(); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Transport change listener threw:', error); + } + } + } + + function updateMethods(methods: HttpMethod[]) { + const method = methods.includes(activeMethod) + ? activeMethod + : methods.length + ? preferredMethod(methods) + : activeMethod; + if ( + method !== activeMethod || + methods.join(',') !== supportedMethods.join(',') + ) { + activeMethod = method; + supportedMethods = methods; + notify(); + } + } + + function discover(headers: Record): Promise { + const key = JSON.stringify(Object.entries(headers).sort()); + if (discovery?.key === key) { + return discovery.result; + } + // Keep only the latest context. A response for older credentials must not + // overwrite the capabilities currently shown to the caller. + const current = { + key, + result: probeMethods(httpFetch, opts.url, headers, initialMethod), + }; + discovery = current; + current.result = (async () => { + const methods = await current.result; + if (discovery === current) { + updateMethods(methods); + } + return methods; + })(); + return current.result; + } function send( req: TransportRequest, @@ -121,72 +167,245 @@ export function createTransport(opts: CreateTransportOptions): Transport { variables: req.variables, extensions: req.extensions, }; - const fetcherOpts: FetcherOpts | undefined = - req.headers || req.signal - ? { headers: req.headers, signal: req.signal } - : undefined; - const selectedOperation = classifySelectedOperation( req.query, req.operationName, ); - if (selectedOperation === 'subscription') { return subscribe(opts.subscriptionClient, params); } - // Mutations may only be sent over POST. GET and QUERY are both safe methods - // (https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/), - // and the GraphQL over HTTP spec forbids mutations over GET; the same safety - // reasoning applies to QUERY. When a safe method is active and the operation - // is a mutation, use POST if it is available. If POST is not supported, - // throw — a spec-compliant server would reject the request anyway. - let method: HttpMethod = activeMethod; - if (activeMethod !== 'POST' && selectedOperation === 'mutation') { - if (!supportedMethods.includes('POST')) { + const selectedMethod = activeMethod; + const headers = new Headers(opts.headers); + for (const [name, value] of Object.entries(req.headers ?? {})) { + headers.set(name, value); + } + const effectiveHeaders: Record = {}; + headers.forEach((value, name) => { + effectiveHeaders[name] = value; + }); + const fetcherOptions: CreateFetcherOptions = { url: opts.url }; + const fetcherOpts: FetcherOpts = { + headers: effectiveHeaders, + signal: req.signal, + }; + + function executionMethod(methods: HttpMethod[]) { + if (!methods.length) { throw new Error( - `Cannot execute a mutation over ${activeMethod}. ` + - `This transport does not support POST (supportedMethods: ${supportedMethods.join(', ')}). ` + - 'Add POST to supportedMethods or switch to a POST-capable transport.', + 'This endpoint advertises no supported GraphQL HTTP methods.', ); } - method = 'POST'; + const method = methods.includes(selectedMethod) + ? selectedMethod + : preferredMethod(methods); + if (selectedOperation === 'mutation') { + if (!methods.includes('POST')) { + throw new Error( + `Cannot execute a mutation over ${method}. ` + + `This transport does not support POST (supportedMethods: ${methods.join(', ')}).`, + ); + } + return 'POST'; + } + return method; } - if (incrementalDelivery) { - return multipartHttpTransport( - fetcherOptions, - httpFetch, - method, - )(params, fetcherOpts); + // Explicit capabilities retain synchronous validation and skip discovery. + if (opts.supportedMethods) { + throwIfAborted(req.signal); + const method = executionMethod(supportedMethods); + return incrementalDelivery + ? multipartHttpTransport( + fetcherOptions, + httpFetch, + method, + )(params, fetcherOpts) + : simpleHttpTransport( + fetcherOptions, + httpFetch, + method, + )(params, fetcherOpts); } - return simpleHttpTransport( - fetcherOptions, - httpFetch, - method, - )(params, fetcherOpts); + + async function prepare(signal?: AbortSignal) { + throwIfAborted(signal); + const methods = await waitForDiscovery( + discover(effectiveHeaders), + signal, + ); + throwIfAborted(signal); + return executionMethod(methods); + } + + if (!incrementalDelivery) { + return (async () => { + const method = await prepare(req.signal); + throwIfAborted(req.signal); + return simpleHttpTransport( + fetcherOptions, + httpFetch, + method, + )(params, fetcherOpts); + })(); + } + + // An async generator alone queues return() behind a pending next(). Abort + // the wait first so closing an iterator cannot send an unwanted operation. + const controller = new AbortController(); + const abort = () => controller.abort(req.signal?.reason); + const iterator = (async function* () { + if (req.signal?.aborted) { + abort(); + } + req.signal?.addEventListener('abort', abort, { once: true }); + try { + const method = await prepare(controller.signal); + throwIfAborted(controller.signal); + yield* multipartHttpTransport( + fetcherOptions, + httpFetch, + method, + )(params, { + ...fetcherOpts, + signal: controller.signal, + }); + } finally { + req.signal?.removeEventListener('abort', abort); + } + })(); + return { + next: () => iterator.next(), + return() { + controller.abort(); + return iterator.return(); + }, + [Symbol.asyncIterator]() { + return this; + }, + } as AsyncIterableIterator; } const transport: Transport = { url: opts.url, - method: activeMethod, - supportedMethods, + get method() { + return activeMethod; + }, + get supportedMethods() { + return supportedMethods; + }, send, - }; - - if (supportedMethods.length > 1) { - transport.setMethod = (method: HttpMethod) => { + setMethod(method) { if (!supportedMethods.includes(method)) { throw new Error( `"${method}" is not supported by this transport (supportedMethods: ${supportedMethods.join(', ')}).`, ); } - activeMethod = method; - transport.method = method; - }; + if (activeMethod !== method) { + activeMethod = method; + notify(); + } + }, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; + return transport; +} + +function preferredMethod(methods: HttpMethod[]): HttpMethod { + return methods.includes('POST') + ? 'POST' + : methods.includes('QUERY') + ? 'QUERY' + : 'GET'; +} + +function parseAllow(value: string | null): HttpMethod[] | undefined { + if (value === null) { + return undefined; + } + const methods = value + .split(',') + .map(method => method.replaceAll(/^[ \t]+|[ \t]+$/g, '')) + .filter(Boolean); + // HTTP method tokens are case-sensitive. Empty list elements are permitted + // by HTTP's list syntax, but malformed tokens make discovery unavailable. + if (methods.some(method => !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(method))) { + return undefined; } + return (['GET', 'POST', 'QUERY'] as const).filter(method => + methods.includes(method), + ); +} - return transport; +async function probeMethods( + httpFetch: typeof fetch, + url: string, + headers: Record, + initialMethod: HttpMethod, +): Promise { + const controller = new AbortController(); + let timeout: ReturnType | undefined; + try { + const response = await Promise.race([ + httpFetch(url, { method: 'OPTIONS', headers, signal: controller.signal }), + new Promise(resolve => { + timeout = setTimeout(() => { + resolve(); + controller.abort(); + }, 2000); + }), + ]); + if (response?.ok) { + return parseAllow(response.headers.get('allow')) ?? [initialMethod]; + } + } catch { + // OPTIONS is optional and may be blocked by servers or CORS policies. + } finally { + clearTimeout(timeout); + // Only response headers are needed; stop any remaining OPTIONS body. + controller.abort(); + } + return [initialMethod]; +} + +function throwIfAborted(signal?: AbortSignal) { + if (signal?.aborted) { + throw ( + signal.reason ?? + new DOMException('The request was aborted.', 'AbortError') + ); + } +} + +async function waitForDiscovery( + result: Promise, + signal?: AbortSignal, +): Promise { + if (!signal) { + return result; + } + let abort: () => void = () => {}; + const aborted = new Promise((_resolve, reject) => { + abort = () => + reject( + signal.reason ?? + new DOMException('The request was aborted.', 'AbortError'), + ); + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) { + abort(); + } + }); + try { + return await Promise.race([result, aborted]); + } finally { + signal.removeEventListener('abort', abort); + } } function subscribe( diff --git a/packages/graphiql-toolkit/src/create-transport/types.ts b/packages/graphiql-toolkit/src/create-transport/types.ts index 4965acc8f68..3dd173f698d 100644 --- a/packages/graphiql-toolkit/src/create-transport/types.ts +++ b/packages/graphiql-toolkit/src/create-transport/types.ts @@ -60,7 +60,7 @@ export type TransportResponse = { * but subject to URL length limits. * - `QUERY` sends a JSON request body like `POST`, but is safe and idempotent * like `GET` (per the HTTP QUERY method), so responses stay cacheable. See - * https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/ + * https://www.rfc-editor.org/rfc/rfc10008.html * * Mutations may only be sent over `POST`, since `GET` and `QUERY` are safe. */ @@ -81,14 +81,17 @@ export type Transport = { */ method: HttpMethod; /** - * HTTP methods this transport is configured to use. Defaults to `['POST']`. + * Currently available HTTP methods. May change after discovery; an empty + * list means the endpoint advertises no usable methods. */ supportedMethods: HttpMethod[]; /** - * Present only when `supportedMethods` has more than one entry. Switches the - * active HTTP method used by subsequent `send()` calls. + * Switches the active HTTP method used by subsequent `send()` calls. + * Optional for custom transports that do not support method selection. */ setMethod?: (method: HttpMethod) => void; + /** Subscribe to changes in method or supportedMethods. Returns an unsubscribe function. */ + subscribe?: (listener: () => void) => () => void; send( request: TransportRequest, ): Promise | AsyncIterable; @@ -159,9 +162,10 @@ export type CreateTransportOptions = { */ method?: HttpMethod; /** - * HTTP methods this transport should advertise as supported. - * Defaults to `['POST']`. Pass e.g. `['GET', 'POST']` or - * `['GET', 'POST', 'QUERY']` to allow switching. + * Explicit supported methods. A nonempty list bypasses automatic discovery. + * When omitted, the first HTTP execution discovers methods using OPTIONS + * and Allow, repeating when effective request headers change. If discovery + * is unavailable, the initial method (normally POST) remains available. */ supportedMethods?: HttpMethod[]; }; From a31dd2db87bbd5485284ccdcaa3fc4577d57f7fe Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Thu, 22 Oct 2026 23:25:06 +0000 Subject: [PATCH 2/9] keep transport method controls in sync --- .../components/provider-transport.test.tsx | 180 ++++++++++++++++++ .../src/components/provider.tsx | 17 ++ .../src/components/top-bar/index.tsx | 5 +- .../graphiql-react/src/stores/execution.ts | 8 +- .../src/transport-hooks.spec.ts | 32 ++++ .../graphiql-react/src/transport-hooks.ts | 8 + 6 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 packages/graphiql-react/src/components/provider-transport.test.tsx diff --git a/packages/graphiql-react/src/components/provider-transport.test.tsx b/packages/graphiql-react/src/components/provider-transport.test.tsx new file mode 100644 index 00000000000..9ee04c108ad --- /dev/null +++ b/packages/graphiql-react/src/components/provider-transport.test.tsx @@ -0,0 +1,180 @@ +'use no memo'; + +import type { ReactNode } from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { + createTransport, + type Transport, + type TransportResponse, +} from '@graphiql/toolkit'; +import { GraphiQLProvider, useGraphiQL } from './provider'; +import { useGraphiQLPluginContext } from '../transport-hooks.context'; + +vi.mock('../stores/monaco', () => { + const state = { actions: { async initialize() {} } }; + return { + monacoStore: { getState: () => state, subscribe: () => () => {} }, + useMonaco: (selector?: (value: typeof state) => unknown) => + selector ? selector(state) : state, + }; +}); + +beforeEach(() => { + localStorage.clear(); + vi.stubGlobal('matchMedia', () => ({ matches: false })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function observableTransport() { + const listeners = new Set<() => void>(); + const transport: Transport = { + url: '/graphql', + method: 'POST', + supportedMethods: ['POST'], + send: async () => ({ + ok: true, + body: {}, + timing: { totalMs: 0 }, + size: {}, + }), + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + setMethod(method) { + this.method = method; + for (const listener of listeners) { + listener(); + } + }, + }; + return { transport, listeners }; +} + +it('synchronizes available methods even when the selected method does not change', () => { + const { transport, listeners } = observableTransport(); + const { result, unmount } = renderHook(() => useGraphiQL(state => state), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + act(() => { + transport.supportedMethods = ['POST', 'QUERY']; + for (const listener of listeners) { + listener(); + } + }); + expect(result.current.transportMethod).toBe('POST'); + expect(result.current.transportSupportedMethods).toEqual(['POST', 'QUERY']); + + act(() => result.current.actions.setTransportMethod('QUERY')); + expect(transport.method).toBe('QUERY'); + expect(result.current.transportMethod).toBe('QUERY'); + expect(result.current.transport!.method).toBe('QUERY'); + + act(() => transport.setMethod!('POST')); + expect(result.current.transportMethod).toBe('POST'); + unmount(); + expect(listeners.size).toBe(0); +}); + +it('releases subscriptions when replacing a transport or switching to a fetcher', () => { + const first = observableTransport(); + const second = observableTransport(); + second.transport.method = 'GET'; + second.transport.supportedMethods = ['GET']; + let transport: Transport | undefined = first.transport; + const fetcher = async () => ({ data: {} }); + const { result, rerender } = renderHook(() => useGraphiQL(state => state), { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }); + + transport = second.transport; + rerender(); + expect(first.listeners.size).toBe(0); + expect(second.listeners.size).toBe(1); + expect(result.current.transportMethod).toBe('GET'); + expect(result.current.transportSupportedMethods).toEqual(['GET']); + + transport = undefined; + rerender(); + expect(second.listeners.size).toBe(0); + expect(result.current.transportMethod).toBeNull(); + expect(result.current.transportSupportedMethods).toBeNull(); +}); + +it('discovers through the wrapped transport using headers produced by request hooks', async () => { + const fetch = vi + .fn() + .mockImplementation(async (_url, init) => { + expect(new Headers(init?.headers).get('authorization')).toBe( + 'Bearer plugin', + ); + return init?.method === 'OPTIONS' + ? new Response(null, { status: 204, headers: { Allow: 'POST, QUERY' } }) + : Response.json({ data: { hello: 'world' } }); + }); + const transport = createTransport({ + url: '/graphql', + fetch, + enableIncrementalDelivery: false, + }); + const { result } = renderHook( + () => ({ + state: useGraphiQL(state => state), + hooks: useGraphiQLPluginContext().transport!, + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + const onResponse = vi.fn(); + const beforeSend = vi.fn((request: Parameters[0]) => ({ + ...request, + headers: { authorization: 'Bearer plugin' }, + })); + result.current.hooks.onBeforeSend(beforeSend); + result.current.hooks.onResponse(onResponse); + + await act(async () => { + const responses = result.current.state.transport!.send({ + query: '{ hello }', + }) as AsyncIterable; + for await (const _response of responses) { + // Consume the operation through the same wrapper used by execution. + } + }); + + expect(fetch.mock.calls.map(([, init]) => init?.method)).toEqual([ + 'OPTIONS', + 'POST', + ]); + expect(beforeSend).toHaveBeenCalledOnce(); + expect(onResponse).toHaveBeenCalledOnce(); + expect(result.current.state.transportSupportedMethods).toEqual([ + 'POST', + 'QUERY', + ]); + expect(result.current.state.transport!.supportedMethods).toEqual([ + 'POST', + 'QUERY', + ]); +}); diff --git a/packages/graphiql-react/src/components/provider.tsx b/packages/graphiql-react/src/components/provider.tsx index 0b66ff5d219..54ddc78e5eb 100644 --- a/packages/graphiql-react/src/components/provider.tsx +++ b/packages/graphiql-react/src/components/provider.tsx @@ -359,9 +359,26 @@ const InnerGraphiQLProvider: FC = ({ fetcher, transport: wrappedTransport, transportMethod: wrappedTransport?.method ?? null, + transportSupportedMethods: wrappedTransport?.supportedMethods ?? null, }); }, [fetcher, transport]); + useEffect(() => { + const syncTransport = () => { + storeRef.current.setState({ + transportMethod: transport?.method ?? null, + transportSupportedMethods: transport + ? [...transport.supportedMethods] + : null, + }); + }; + const unsubscribe = transport?.subscribe?.(syncTransport); + // Catch changes between rendering and subscribing, including discovery + // started by a child that sends an introspection request. + syncTransport(); + return unsubscribe; + }, [transport]); + // Plugin sync useDidUpdate(() => { const { actions } = storeRef.current.getState(); diff --git a/packages/graphiql-react/src/components/top-bar/index.tsx b/packages/graphiql-react/src/components/top-bar/index.tsx index c3faaa1ce16..7243b888577 100644 --- a/packages/graphiql-react/src/components/top-bar/index.tsx +++ b/packages/graphiql-react/src/components/top-bar/index.tsx @@ -29,6 +29,9 @@ export const TopBar: FC = ({ version, brand }) => { const { setTransportMethod } = useGraphiQLActions(); const transport = useGraphiQL(state => state.transport); const transportMethod = useGraphiQL(state => state.transportMethod); + const transportSupportedMethods = useGraphiQL( + state => state.transportSupportedMethods, + ); const runDisabledReason = useGraphiQL(state => getRunBlockReason( state.transportMethod, @@ -38,7 +41,7 @@ export const TopBar: FC = ({ version, brand }) => { const url = transport?.url ?? '—'; const method: HttpMethod = transportMethod ?? 'POST'; - const supportedMethods = transport?.supportedMethods ?? ['POST']; + const supportedMethods = transportSupportedMethods ?? ['POST']; return ( { diff --git a/packages/graphiql-react/src/transport-hooks.spec.ts b/packages/graphiql-react/src/transport-hooks.spec.ts index 5b98f46658e..48c2b415345 100644 --- a/packages/graphiql-react/src/transport-hooks.spec.ts +++ b/packages/graphiql-react/src/transport-hooks.spec.ts @@ -45,6 +45,38 @@ async function collect( } describe('TransportHookRegistry', () => { + it('forwards live transport state and binds methods to the original transport', () => { + const registry = new TransportHookRegistry(); + const listeners = new Set<() => void>(); + const transport: Transport = { + ...makeTransport(async () => makeResponse()), + setMethod(method) { + this.method = method; + for (const listener of listeners) { + listener(); + } + }, + subscribe(listener) { + expect(this).toBe(transport); + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + const wrapped = registry.wrap(transport); + const listener = vi.fn(); + const unsubscribe = wrapped.subscribe!(listener); + + transport.supportedMethods = ['POST', 'QUERY']; + wrapped.setMethod!('QUERY'); + + expect(wrapped.method).toBe('QUERY'); + expect(wrapped.supportedMethods).toEqual(['POST', 'QUERY']); + expect(listener).toHaveBeenCalledOnce(); + unsubscribe(); + wrapped.setMethod!('POST'); + expect(listener).toHaveBeenCalledOnce(); + }); + describe('onBeforeSend', () => { it('calls registered callbacks before send', async () => { const registry = new TransportHookRegistry(); diff --git a/packages/graphiql-react/src/transport-hooks.ts b/packages/graphiql-react/src/transport-hooks.ts index 74bf1af738c..e0122be9de3 100644 --- a/packages/graphiql-react/src/transport-hooks.ts +++ b/packages/graphiql-react/src/transport-hooks.ts @@ -136,6 +136,14 @@ export class TransportHookRegistry { return { ...transport, + get method() { + return transport.method; + }, + get supportedMethods() { + return transport.supportedMethods; + }, + setMethod: transport.setMethod?.bind(transport), + subscribe: transport.subscribe?.bind(transport), send(request: TransportRequest): AsyncIterable { return { [Symbol.asyncIterator]() { From 950d17c2aeea7ef9f0b9617fd618a509250fe6b8 Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Thu, 22 Oct 2026 23:25:07 +0000 Subject: [PATCH 3/9] document method discovery for the RC and final release --- .changeset/transport-api.md | 4 ++- .changeset/transport-method-discovery.md | 13 ++++++++ docs/migration/graphiql-6.0.0.md | 16 ++++++--- .../src/create-transport/README.md | 33 +++++++++++++++++-- 4 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 .changeset/transport-method-discovery.md diff --git a/.changeset/transport-api.md b/.changeset/transport-api.md index fae1589c92a..9121f114031 100644 --- a/.changeset/transport-api.md +++ b/.changeset/transport-api.md @@ -6,7 +6,9 @@ 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. `` 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. 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 `.iterate(request)` method that `graphql-ws` v6 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://www.rfc-editor.org/rfc/rfc10008.html) method. Pass `method` to choose the initial method; 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`, an optional `setMethod`, and an optional `subscribe` for capability changes, 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 `.iterate(request)` method that `graphql-ws` v6 and `graphql-sse` clients meet directly. The low-level `simpleHttpTransport` and `multipartHttpTransport` primitives also accept an optional `method`. + +By default, `createTransport` discovers supported HTTP methods with an `OPTIONS` request before its first HTTP operation, using the effective request headers. It reads `Allow` and updates the method selector while preserving the selected method when supported. Discovery is shared by concurrent requests and repeated when headers change. If discovery fails, times out, or returns no `Allow` header, the transport keeps the initial method (POST by default); an explicit advertisement with no usable methods blocks execution. Pass `supportedMethods` to override discovery for endpoints with known capabilities. Cross-origin servers must permit the `OPTIONS` request and expose `Allow` through `Access-Control-Expose-Headers`. `TransportRequest` carries `extensions` for GraphQL-over-HTTP extensions such as automatic persisted queries (encoded into the URL for `GET`, included in the JSON body for `POST` and `QUERY`), 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. `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`. diff --git a/.changeset/transport-method-discovery.md b/.changeset/transport-method-discovery.md new file mode 100644 index 00000000000..dffc935d09f --- /dev/null +++ b/.changeset/transport-method-discovery.md @@ -0,0 +1,13 @@ +--- +'@graphiql/toolkit': patch +'@graphiql/react': patch +'graphiql': patch +--- + +Discover HTTP methods automatically when `createTransport` is called without `supportedMethods`. Previous RCs treated an omitted `supportedMethods` as `['POST']`. The transport now sends an OPTIONS request without a body before its first HTTP operation and uses the endpoint's `Allow` header to update the available GET, POST, and QUERY methods in GraphiQL. It preserves the selected method when supported; otherwise it selects POST, then QUERY, then GET. + +Discovery uses the effective request headers, is shared by concurrent requests, and runs again when headers change. If OPTIONS fails, times out after two seconds, or returns no usable `Allow` header, execution uses the initial method (POST by default). A valid advertisement containing no usable methods blocks HTTP execution. Mutations still require POST. + +Pass `supportedMethods: ['POST']` to preserve the previous default and skip OPTIONS, or supply another nonempty array to override discovery. For cross-origin discovery, the server must permit OPTIONS and expose `Allow` through `Access-Control-Expose-Headers`. This discovers methods only, not QUERY request formats through `Accept-Query`. + +`Transport` now has an optional `subscribe(listener)` method that returns an unsubscribe function. GraphiQL uses notifications to update its method selector. Custom transports with static capabilities need no changes. diff --git a/docs/migration/graphiql-6.0.0.md b/docs/migration/graphiql-6.0.0.md index fe9a0431e9d..bca48c3109c 100644 --- a/docs/migration/graphiql-6.0.0.md +++ b/docs/migration/graphiql-6.0.0.md @@ -369,11 +369,15 @@ Use `onResponse(response => { ... })` to observe each `TransportResponse`, inclu - `POST` is the default. It sends queries and mutations in a JSON request body. - `GET` sends queries in the URL without a request body. -- [`QUERY`](https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/) sends queries in a JSON request body but is defined as safe and idempotent. +- [`QUERY`](https://www.rfc-editor.org/rfc/rfc10008.html) sends queries in a JSON request body but is defined as safe and idempotent. -> **Note:** At the time of writing, `QUERY` is a very new HTTP method and is unlikely to be supported by your GraphQL server. Verify support across your server and any intervening proxies before enabling it. +By default, the transport discovers available methods with an `OPTIONS` request without a body before its first HTTP operation, including introspection. It reads the endpoint's `Allow` header and updates the method selector. The request uses the effective headers, including changes made by `onBeforeSend` hooks. Concurrent operations share discovery; changing the headers triggers it again. If you supply a schema, discovery waits until you execute an HTTP operation. -Use `method` to set the initial method and `supportedMethods` to list the methods users can select: +If discovery fails, exceeds two seconds, or returns no usable `Allow` header, the transport keeps the initial method (POST by default). A valid advertisement containing no usable methods blocks HTTP requests. Discovery preserves the selected method when supported; otherwise it selects POST, then QUERY, then GET. It does not automatically retry operations or negotiate QUERY request formats through `Accept-Query`. + +For cross-origin discovery, the server must permit OPTIONS and expose `Allow` with `Access-Control-Expose-Headers: Allow`. Browser preflight and its `Access-Control-Allow-Methods` header do not replace endpoint discovery. + +Use `method` to set the initial method. To skip discovery, pass a nonempty `supportedMethods` array listing the methods users can select: ```ts const transport = createTransport({ @@ -385,12 +389,16 @@ const transport = createTransport({ Direct calls to `transport.send()` route mutations through `POST`, even when `GET` or `QUERY` is selected. If `POST` isn't included in `supportedMethods`, the transport rejects the mutation. -In GraphiQL, select **POST** before running a mutation. With **GET** or **QUERY** selected, Run is disabled with the explanation **Mutations can only be sent via POST**. The operation dropdown and keyboard shortcut apply the same restriction. GraphiQL keeps your selected method until you change it. When more than one method is configured, the top bar's method control cycles through `supportedMethods`; for a blocked mutation, selecting the control chooses **POST** when supported. +In GraphiQL, select **POST** before running a mutation. With **GET** or **QUERY** selected, Run is disabled with the explanation **Mutations can only be sent via POST**. The operation dropdown and keyboard shortcut apply the same restriction. When more than one method is available, the top bar's method control cycles through `supportedMethods`; for a blocked mutation, selecting the control chooses **POST** when supported. + +Discovery runs when an HTTP operation executes. If POST is unavailable in the selector and you change authentication headers, run a query to refresh the available methods before selecting POST for a mutation. You can also configure `supportedMethods` explicitly when the endpoint's capabilities are known. ### Custom transports (FAQ) If you hand-rolled a `Fetcher` rather than using `createGraphiQLFetcher`, migrate by implementing the `Transport` interface directly. A `Transport` declares its endpoint `url`, active `method`, `supportedMethods`, and a `send(request)` method. For queries and mutations it returns a `Promise`; for subscriptions and incremental delivery it returns an `AsyncIterable`, one entry per event or chunk. +If your transport changes `method` or `supportedMethods` asynchronously, implement the optional `subscribe(listener)` method and notify listeners after updating those properties. Return an unsubscribe function. GraphiQL reads the current properties on each notification; it does not perform discovery for custom transports. Transports with static capabilities can omit `subscribe`. + **Before (custom fetcher):** ```ts diff --git a/packages/graphiql-toolkit/src/create-transport/README.md b/packages/graphiql-toolkit/src/create-transport/README.md index 6b77f0bebd9..75ae99b62bd 100644 --- a/packages/graphiql-toolkit/src/create-transport/README.md +++ b/packages/graphiql-toolkit/src/create-transport/README.md @@ -14,8 +14,37 @@ const transport = createTransport({ url: 'https://my.endpoint/graphql' }); Queries and mutations work with nothing but a `url`. `@defer`/`@stream` incremental delivery over `multipart/mixed` is on by default (disable it with -`enableIncrementalDelivery: false`). GET is opt-in via `method`/ -`supportedMethods`. +`enableIncrementalDelivery: false`). The initial HTTP method is POST. + +## HTTP method discovery + +Before the first HTTP operation, `createTransport` sends an `OPTIONS` +request without a body and reads the endpoint's `Allow` header to discover support for GET, +POST, and QUERY. Discovery uses the same fetch implementation and effective +request headers as the operation. Concurrent requests share discovery, and a +change in headers triggers discovery again. Subscriptions don't trigger it. + +The transport keeps the selected method if it is supported; otherwise it +selects POST, then QUERY, then GET. If discovery fails, takes more than two +seconds, or provides no usable `Allow` header, the transport keeps the initial +method (`method`, or POST by default). A valid `Allow` header with no supported +GraphQL HTTP methods causes HTTP operations to fail without sending them. + +To skip discovery and configure the available methods yourself, pass a nonempty +`supportedMethods` array. For example, `supportedMethods: ['POST']` keeps every +HTTP operation on POST and makes no OPTIONS request. + +For cross-origin discovery, the server must allow the OPTIONS request and +expose `Allow` with `Access-Control-Expose-Headers: Allow`. Browser CORS +preflight is separate from this discovery request. Discovery checks HTTP +methods only; it does not negotiate QUERY formats through `Accept-Query`. + +`transport.subscribe(listener)` returns an unsubscribe function. The listener +runs when `method` or `supportedMethods` changes; read those properties for the +current values. GraphiQL subscribes automatically to update its method selector. +Custom transports with static capabilities can omit `subscribe`. + +## Sending operations `send()` takes a `TransportRequest`: `query`, `operationName`, `variables`, `extensions` (GraphQL-over-HTTP extensions, e.g. for automatic persisted From 6d4253228cf919c4705a7c6dee3cebf81e9a6bde Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Thu, 22 Oct 2026 23:32:41 +0000 Subject: [PATCH 4/9] let transports own mutation routing --- .changeset/transport-api.md | 2 +- .changeset/transport-method-discovery.md | 4 +- docs/migration/graphiql-6.0.0.md | 6 +- .../graphiql-e2e/cypress/e2e/transport.cy.ts | 54 +++++++----- .../execute-button/execute-button.stories.tsx | 19 +---- .../execute-button/execute-button.test.tsx | 84 +++++-------------- .../src/components/execute-button/index.css | 19 ----- .../src/components/execute-button/index.tsx | 40 +-------- .../src/components/top-bar/index.css | 22 ----- .../src/components/top-bar/index.tsx | 41 +++------ .../components/top-bar/top-bar.stories.tsx | 21 +---- .../src/components/top-bar/top-bar.test.tsx | 43 ---------- .../graphiql-react/src/stores/editor.spec.ts | 23 ++--- .../src/stores/execution.spec.ts | 60 ++++++++++++- .../graphiql-react/src/stores/execution.ts | 15 ---- ...block.test.ts => active-operation.test.ts} | 42 +--------- .../src/utility/active-operation.ts | 19 +++++ .../graphiql-react/src/utility/run-block.ts | 47 ----------- .../src/create-transport/README.md | 5 +- .../__tests__/discovery.spec.ts | 46 ++++++++++ .../src/create-transport/types.ts | 10 ++- 21 files changed, 218 insertions(+), 404 deletions(-) rename packages/graphiql-react/src/utility/{run-block.test.ts => active-operation.test.ts} (51%) create mode 100644 packages/graphiql-react/src/utility/active-operation.ts delete mode 100644 packages/graphiql-react/src/utility/run-block.ts diff --git a/.changeset/transport-api.md b/.changeset/transport-api.md index 9121f114031..30742c91b7b 100644 --- a/.changeset/transport-api.md +++ b/.changeset/transport-api.md @@ -6,7 +6,7 @@ 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. `` accepts a new `transport` prop, mutually exclusive with `fetcher` at the type level. -Transports support GET, POST, and the [HTTP `QUERY`](https://www.rfc-editor.org/rfc/rfc10008.html) method. Pass `method` to choose the initial method; 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`, an optional `setMethod`, and an optional `subscribe` for capability changes, 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 `.iterate(request)` method that `graphql-ws` v6 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://www.rfc-editor.org/rfc/rfc10008.html) method. Pass `method` to choose the initial query method; 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), including from GraphiQL. The selected query method stays unchanged after running a mutation. `Transport` exposes `url`, `method`, `supportedMethods`, an optional `setMethod`, and an optional `subscribe` for capability changes, 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 `.iterate(request)` method that `graphql-ws` v6 and `graphql-sse` clients meet directly. The low-level `simpleHttpTransport` and `multipartHttpTransport` primitives also accept an optional `method`. By default, `createTransport` discovers supported HTTP methods with an `OPTIONS` request before its first HTTP operation, using the effective request headers. It reads `Allow` and updates the method selector while preserving the selected method when supported. Discovery is shared by concurrent requests and repeated when headers change. If discovery fails, times out, or returns no `Allow` header, the transport keeps the initial method (POST by default); an explicit advertisement with no usable methods blocks execution. Pass `supportedMethods` to override discovery for endpoints with known capabilities. Cross-origin servers must permit the `OPTIONS` request and expose `Allow` through `Access-Control-Expose-Headers`. diff --git a/.changeset/transport-method-discovery.md b/.changeset/transport-method-discovery.md index dffc935d09f..11610866c42 100644 --- a/.changeset/transport-method-discovery.md +++ b/.changeset/transport-method-discovery.md @@ -6,8 +6,8 @@ Discover HTTP methods automatically when `createTransport` is called without `supportedMethods`. Previous RCs treated an omitted `supportedMethods` as `['POST']`. The transport now sends an OPTIONS request without a body before its first HTTP operation and uses the endpoint's `Allow` header to update the available GET, POST, and QUERY methods in GraphiQL. It preserves the selected method when supported; otherwise it selects POST, then QUERY, then GET. -Discovery uses the effective request headers, is shared by concurrent requests, and runs again when headers change. If OPTIONS fails, times out after two seconds, or returns no usable `Allow` header, execution uses the initial method (POST by default). A valid advertisement containing no usable methods blocks HTTP execution. Mutations still require POST. +Discovery uses the effective request headers, is shared by concurrent requests, and runs again when headers change. If OPTIONS fails, times out after two seconds, or returns no usable `Allow` header, execution uses the initial method (POST by default). A valid advertisement containing no usable methods blocks HTTP execution. Mutations still require POST. GraphiQL now delegates method routing to the transport: Run, the operation dropdown, and the keyboard shortcut can execute mutations while GET or QUERY is selected. The transport discovers capabilities with the current headers, sends the mutation via POST when available, or rejects it without sending. The selected query method stays unchanged. Pass `supportedMethods: ['POST']` to preserve the previous default and skip OPTIONS, or supply another nonempty array to override discovery. For cross-origin discovery, the server must permit OPTIONS and expose `Allow` through `Access-Control-Expose-Headers`. This discovers methods only, not QUERY request formats through `Accept-Query`. -`Transport` now has an optional `subscribe(listener)` method that returns an unsubscribe function. GraphiQL uses notifications to update its method selector. Custom transports with static capabilities need no changes. +`Transport` now has an optional `subscribe(listener)` method that returns an unsubscribe function. GraphiQL uses notifications to update its method selector. Custom transports with static capabilities can omit `subscribe`. Custom transports own method routing and must prevent mutations over GET or QUERY. diff --git a/docs/migration/graphiql-6.0.0.md b/docs/migration/graphiql-6.0.0.md index bca48c3109c..228ba5dff15 100644 --- a/docs/migration/graphiql-6.0.0.md +++ b/docs/migration/graphiql-6.0.0.md @@ -387,11 +387,9 @@ const transport = createTransport({ }); ``` -Direct calls to `transport.send()` route mutations through `POST`, even when `GET` or `QUERY` is selected. If `POST` isn't included in `supportedMethods`, the transport rejects the mutation. +`createTransport` routes mutations through POST, including operations started from GraphiQL's Run button, operation dropdown, or keyboard shortcut. The selected method applies to queries and remains selected after running a mutation. If POST is unavailable, the transport rejects the mutation without sending it. Discovery uses the current request headers before making this decision, so a header change can reveal newly available POST support. -In GraphiQL, select **POST** before running a mutation. With **GET** or **QUERY** selected, Run is disabled with the explanation **Mutations can only be sent via POST**. The operation dropdown and keyboard shortcut apply the same restriction. When more than one method is available, the top bar's method control cycles through `supportedMethods`; for a blocked mutation, selecting the control chooses **POST** when supported. - -Discovery runs when an HTTP operation executes. If POST is unavailable in the selector and you change authentication headers, run a query to refresh the available methods before selecting POST for a mutation. You can also configure `supportedMethods` explicitly when the endpoint's capabilities are known. +When more than one method is available, the top bar's method control cycles through `supportedMethods`. GraphiQL delegates HTTP method policy to the transport; custom transports must also keep mutations off GET and QUERY. ### Custom transports (FAQ) diff --git a/packages/graphiql-e2e/cypress/e2e/transport.cy.ts b/packages/graphiql-e2e/cypress/e2e/transport.cy.ts index 4a2a4efe995..839edb4db1a 100644 --- a/packages/graphiql-e2e/cypress/e2e/transport.cy.ts +++ b/packages/graphiql-e2e/cypress/e2e/transport.cy.ts @@ -58,14 +58,19 @@ describe('Transport API + response pane header', () => { }); }); -describe('Manual POST selection for mutations', () => { - it('keeps GET selected and blocks button, dropdown, and shortcut execution until POST is selected', () => { - const mutationRequests: unknown[] = []; - cy.intercept('POST', '/graphql', request => { - if (request.body.operationName === 'Change') { - mutationRequests.push(request.body); +describe('Transport routing for mutations', () => { + it('executes mutations through POST from the button, dropdown, and shortcut while GET stays selected', () => { + const mutationMethods: string[] = []; + cy.intercept('/graphql*', request => { + const operationName = + request.method === 'GET' + ? new URL(request.url).searchParams.get('operationName') + : request.body?.operationName; + if (operationName === 'Change') { + mutationMethods.push(request.method); + request.alias = 'mutation'; } - }).as('graphql'); + }); cy.visitGraphiQL({ query: 'query Read { __typename }\nmutation Change { __typename }\n', }); @@ -73,24 +78,29 @@ describe('Manual POST selection for mutations', () => { cy.get('.graphiql-top-bar-method-toggle').click(); cy.get('.graphiql-top-bar-method-toggle').should('have.text', 'GET'); cy.activateOperation('Change'); - cy.get('[aria-label="Run operation"]').should('be.disabled'); + + cy.get('[aria-label="Run operation"]').should('be.enabled').click(); + cy.wait('@mutation').its('request.method').should('eq', 'POST'); + cy.get('.result-window').should('contain.text', 'MutationType'); + cy.get('.graphiql-top-bar-method-toggle').should('have.text', 'GET'); + + cy.activateOperation('Read'); cy.get('[aria-label="Choose operation to run"]').click(); - cy.contains('[role="menuitem"]', 'Change').should( - 'have.attr', - 'aria-disabled', - 'true', - ); - cy.realPress('Escape'); - cy.get('.graphiql-query-editor textarea').focus(); - cy.realPress([Cypress.platform === 'darwin' ? 'Meta' : 'Control', 'Enter']); - cy.get('[aria-label="Run operation"]').should('be.disabled'); + cy.contains('[role="menuitem"]', 'Change') + .should('not.have.attr', 'aria-disabled') + .click(); + cy.wait('@mutation').its('request.method').should('eq', 'POST'); + cy.get('.result-window').should('contain.text', 'MutationType'); cy.get('.graphiql-top-bar-method-toggle').should('have.text', 'GET'); - cy.then(() => expect(mutationRequests).to.have.length(0)); - cy.get('.graphiql-top-bar-method-toggle').click(); - cy.get('.graphiql-top-bar-method-toggle').should('have.text', 'POST'); - cy.get('[aria-label="Run operation"]').should('be.enabled').click(); + cy.get('[aria-label="Run operation"]').should('be.enabled'); + cy.get('.graphiql-query-editor textarea').focus(); + cy.realPress([Cypress.platform === 'darwin' ? 'Meta' : 'Control', 'Enter']); + cy.wait('@mutation').its('request.method').should('eq', 'POST'); cy.get('.result-window').should('contain.text', 'MutationType'); - cy.then(() => expect(mutationRequests).to.have.length(1)); + cy.get('.graphiql-top-bar-method-toggle').should('have.text', 'GET'); + cy.then(() => + expect(mutationMethods).to.deep.equal(['POST', 'POST', 'POST']), + ); }); }); diff --git a/packages/graphiql-react/src/components/execute-button/execute-button.stories.tsx b/packages/graphiql-react/src/components/execute-button/execute-button.stories.tsx index cd2b214df49..9c743e622ea 100644 --- a/packages/graphiql-react/src/components/execute-button/execute-button.stories.tsx +++ b/packages/graphiql-react/src/components/execute-button/execute-button.stories.tsx @@ -1,7 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { parse, OperationDefinitionNode } from 'graphql'; import { ExecuteButtonView } from './'; -import { Tooltip } from '../tooltip'; function opsOf(source: string): OperationDefinitionNode[] { return parse(source).definitions.filter( @@ -13,13 +12,6 @@ const meta: Meta = { title: 'Components/ExecuteButton', component: ExecuteButtonView, tags: ['autodocs'], - decorators: [ - Story => ( - - - - ), - ], args: { isFetching: false, onRun() {}, @@ -45,11 +37,10 @@ export const Default: Story = { /** * Several named operations: the button grows a caret opening the operation - * picker. GET is selected, so the mutation's menu item is disabled. + * picker for queries and mutations. */ export const MultipleOperationsWithPicker: Story = { args: { - transportMethod: 'GET', operationName: 'Beta', onSetOperationName() {}, }, @@ -67,12 +58,8 @@ export const MultipleOperationsWithPicker: Story = { ), }; -/** GET selected with a mutation active: the button is disabled and explains why on hover. */ -export const BlockedOverGet: Story = { - args: { - transportMethod: 'GET', - runDisabledReason: 'Mutations can only be sent via POST', - }, +/** A mutation uses the same Run control as any other operation. */ +export const Mutation: Story = { render: args => ( - render({ui}); - describe('ExecuteButtonView', () => { it.each(['query', 'mutation', 'subscription'])( 'uses operation-neutral labels for a %s', @@ -51,44 +45,19 @@ describe('ExecuteButtonView', () => { expect(onRun).toHaveBeenCalled(); }); - it('disables the Run button when a mutation is blocked over GET', () => { - renderBlocked( - , - ); - expect( - screen.getByRole('button', { name: /Run operation/i }), - ).toBeDisabled(); - }); - - it('wraps the disabled Run button in a focusable tooltip target when blocked', () => { - renderBlocked( + it('runs the selected mutation with the primary button', async () => { + const user = userEvent.setup(); + const onRun = vi.fn(); + render( , ); - // A native disabled button emits no events, so the tooltip needs a - // focusable wrapper to receive hover/focus and open. - const target = screen - .getByRole('button', { name: /Run operation/i }) - .closest('.graphiql-execute-button-tooltip-target'); - expect(target).not.toBeNull(); - expect(target).toHaveAttribute('tabindex', '0'); - }); - - it('does not disable or wrap the button when not blocked', () => { - const { container } = render(); - expect( - screen.getByRole('button', { name: /Run operation/i }), - ).not.toBeDisabled(); - expect( - container.querySelector('.graphiql-execute-button-tooltip-target'), - ).toBeNull(); + await user.click(screen.getByRole('button', { name: 'Run operation' })); + expect(onRun).toHaveBeenCalledOnce(); }); describe('stop', () => { @@ -126,20 +95,6 @@ describe('ExecuteButtonView', () => { expect(onRun).not.toHaveBeenCalled(); }); - it('leaves Stop enabled even when a run would be blocked', () => { - renderBlocked( - , - ); - expect( - screen.getByRole('button', { name: /Stop operation/i }), - ).not.toBeDisabled(); - }); - it('hides the operation picker while running', () => { render( { expect(onRun).toHaveBeenCalled(); }); - it('disables a menu item whose operation is blocked for the current method', async () => { + it('selects and runs a mutation from the operation picker', async () => { const user = userEvent.setup(); - const ops = opsOf('query Q { a }\nmutation M { b }'); + const onRun = vi.fn(); + const onSetOperationName = vi.fn(); render( , ); await user.click( screen.getByRole('button', { name: 'Choose operation to run' }), ); - const queryItem = await screen.findByRole('menuitem', { name: 'Q' }); - const mutationItem = screen.getByRole('menuitem', { name: 'M' }); - expect(queryItem).not.toHaveAttribute('data-disabled'); - expect(mutationItem).toHaveAttribute('data-disabled'); + await user.click(await screen.findByRole('menuitem', { name: 'M' })); + expect(onSetOperationName).toHaveBeenCalledWith('M'); + expect(onRun).toHaveBeenCalledOnce(); + expect(onSetOperationName.mock.invocationCallOrder[0]!).toBeLessThan( + onRun.mock.invocationCallOrder[0]!, + ); }); }); }); diff --git a/packages/graphiql-react/src/components/execute-button/index.css b/packages/graphiql-react/src/components/execute-button/index.css index 12181752739..650710037e2 100644 --- a/packages/graphiql-react/src/components/execute-button/index.css +++ b/packages/graphiql-react/src/components/execute-button/index.css @@ -89,19 +89,6 @@ color: #fff; } -.graphiql-execute-button-primary:disabled { - opacity: 0.6; - cursor: not-allowed; - /* Let pointer events fall through to the tooltip-target wrapper so the - "switch to POST" tooltip can open over the disabled button. */ - pointer-events: none; -} - -.graphiql-execute-button-caret:disabled { - opacity: 0.6; - cursor: not-allowed; -} - /* Scoped under `.graphiql-execute-button` so this outranks the global `.graphiql-container :focus-visible` ring on specificity, not load order — an inset ring (rather than the global's outward one) keeps the two halves' @@ -112,12 +99,6 @@ outline-offset: -2px; } -.graphiql-execute-button-tooltip-target { - display: inline-flex; - flex-shrink: 0; - cursor: not-allowed; -} - .graphiql-execute-button-menu-item { display: inline-flex; align-items: center; diff --git a/packages/graphiql-react/src/components/execute-button/index.tsx b/packages/graphiql-react/src/components/execute-button/index.tsx index b42aaa0e63a..fe86d2f4474 100644 --- a/packages/graphiql-react/src/components/execute-button/index.tsx +++ b/packages/graphiql-react/src/components/execute-button/index.tsx @@ -3,18 +3,13 @@ 'use no memo'; import type { FC } from 'react'; -import type { HttpMethod } from '@graphiql/toolkit'; import type { OperationDefinitionNode } from 'graphql'; import { clsx } from 'clsx'; import { useGraphiQL, useGraphiQLActions } from '../provider'; import { KeycapHint, MODIFIER } from '../keycap-hint'; -import { Tooltip } from '../tooltip'; import { DropdownMenu } from '../dropdown-menu'; import { PlayIcon, StopIcon, ChevronDownIcon } from '../../icons'; -import { - getRunBlockReason, - resolveActiveOperation, -} from '../../utility/run-block'; +import { resolveActiveOperation } from '../../utility/active-operation'; import './index.css'; /** @@ -28,13 +23,6 @@ export const ExecuteButton: FC = () => { const isSubscribed = useGraphiQL(state => Boolean(state.subscription)); const operations = useGraphiQL(state => state.operations); const operationName = useGraphiQL(state => state.operationName); - const transportMethod = useGraphiQL(state => state.transportMethod); - const runDisabledReason = useGraphiQL(state => - getRunBlockReason( - state.transportMethod, - resolveActiveOperation(state.operations, state.operationName), - ), - ); return ( { isSubscribed={isSubscribed} operations={operations} operationName={operationName} - transportMethod={transportMethod} - runDisabledReason={runDisabledReason} onRun={run} onStop={stop} onSetOperationName={setOperationName} @@ -55,12 +41,9 @@ export type ExecuteButtonViewProps = { isFetching: boolean; /** An active subscription keeps the button in its stop state. */ isSubscribed?: boolean; - /** Non-null when a run is blocked; the string is the reason shown in a tooltip. */ - runDisabledReason?: string | null; /** The document's operations. A caret + picker only appears for more than one. */ operations?: OperationDefinitionNode[]; operationName?: string | null; - transportMethod?: HttpMethod | null; onRun: () => void; onStop: () => void; onSetOperationName?: (operationName: string) => void; @@ -69,17 +52,13 @@ export type ExecuteButtonViewProps = { export const ExecuteButtonView: FC = ({ isFetching, isSubscribed = false, - runDisabledReason = null, operations = [], operationName = null, - transportMethod = null, onRun, onStop, onSetOperationName, }) => { const isRunning = isFetching || isSubscribed; - // Never block the stop affordance; only a fresh run can be blocked. - const isBlocked = !isRunning && runDisabledReason !== null; const hasOptions = operations.length > 1; // While running, the button is a single-purpose stop control; a picker // alongside it would offer to start something that can't start yet. @@ -101,7 +80,6 @@ export const ExecuteButtonView: FC = ({ !showCaret && 'graphiql-execute-button-primary--solo', )} onClick={isRunning ? onStop : onRun} - disabled={isBlocked} aria-label={isRunning ? 'Stop operation' : 'Run operation'} > {isRunning ? ( @@ -124,18 +102,7 @@ export const ExecuteButtonView: FC = ({ return (
- {isBlocked ? ( - - {/* A native disabled button emits no pointer/focus events, so Radix - would never open the tooltip. Wrap it in a focusable span that - receives the events instead. */} - - {primaryButton} - - - ) : ( - primaryButton - )} + {primaryButton} {showCaret && ( <> @@ -159,9 +126,6 @@ export const ExecuteButtonView: FC = ({ return ( selectOperation(opName)} > = ({ version, brand }) => { const transportSupportedMethods = useGraphiQL( state => state.transportSupportedMethods, ); - const runDisabledReason = useGraphiQL(state => - getRunBlockReason( - state.transportMethod, - resolveActiveOperation(state.operations, state.operationName), - ), - ); const url = transport?.url ?? '—'; const method: HttpMethod = transportMethod ?? 'POST'; @@ -50,7 +39,6 @@ export const TopBar: FC = ({ version, brand }) => { url={url} method={method} supportedMethods={supportedMethods} - runDisabledReason={runDisabledReason} runButton={} onSetMethod={setTransportMethod} /> @@ -63,11 +51,6 @@ export type TopBarViewProps = { url: string; method: HttpMethod; supportedMethods: HttpMethod[]; - /** - * Non-null when a run is blocked; drives the method toggle's attention state. - * The reason itself is surfaced by the run control. - */ - runDisabledReason?: string | null; /** The run control, rendered at the end of the bar. */ runButton?: ReactNode; onSetMethod: (method: HttpMethod) => void; @@ -79,21 +62,15 @@ export const TopBarView: FC = ({ url, method, supportedMethods, - runDisabledReason = null, runButton, onSetMethod, }) => { const canSwitch = supportedMethods.length > 1; - const isBlocked = runDisabledReason !== null; - // Clicking the chip cycles to the next supported method. When a mutation is - // blocked on a safe method (GET/QUERY), the chip pulses and instead jumps - // straight to POST so a single click resolves the block. + // Clicking the chip cycles to the next supported method. const nextMethod = supportedMethods[ (supportedMethods.indexOf(method) + 1) % supportedMethods.length ] ?? method; - const switchTarget = - isBlocked && supportedMethods.includes('POST') ? 'POST' : nextMethod; return (
@@ -113,20 +90,22 @@ export const TopBarView: FC = ({