diff --git a/.changeset/transport-api.md b/.changeset/transport-api.md index fae1589c92a..69979f2f6c8 100644 --- a/.changeset/transport-api.md +++ b/.changeset/transport-api.md @@ -4,9 +4,13 @@ '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. 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. +Add a structured `Transport` API alongside the existing `Fetcher`. `createTransport({...})` performs the GraphQL request and returns a `TransportResponse` carrying the real HTTP wire metadata (method, 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. Queries automatically prefer QUERY, then POST, then GET. Pass `method` to override query selection for testing, or call the optional `setMethod`; calling `setMethod()` without an argument restores automatic selection. GET encodes the query, operation name, variables, and extensions into the URL with no body. QUERY sends a JSON body but is safe and idempotent. Mutations always require POST, and operations whose type cannot be determined also require POST. If POST is unavailable, they are rejected without sending. An unavailable query override also rejects without silently changing methods. + +By default, `createTransport` discovers supported HTTP methods with an `OPTIONS` request before its first HTTP operation, using the effective request headers and reading `Allow`. Discovery is shared by concurrent requests and repeated when headers change. If discovery fails, times out, or returns no usable `Allow` header, the transport falls back to POST (or the configured `method` override); an explicit advertisement with no usable methods blocks execution. Pass `supportedMethods` to bypass discovery for endpoints with known capabilities. For example, `method: 'GET', supportedMethods: ['GET', 'POST']` tests GET queries while keeping POST available for mutations. Cross-origin servers must permit the OPTIONS request and expose `Allow` through `Access-Control-Expose-Headers`. + +The top bar displays the endpoint. The response pane displays the actual HTTP method from `TransportResponse.method` alongside status, timing, and size; it omits the method when a transport cannot report it. GraphiQL delegates routing to the transport without a method switcher or capability subscription. `Transport` exposes `url`, `method`, `supportedMethods`, and an optional `setMethod` for programmatic overrides. Every HTTP operation, incremental delivery on or off, sends `application/graphql-response+json` in its `accept` header alongside `application/json`. 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`. `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..4ce4f432f82 --- /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']` and exposed a method chip and switcher in the top bar. The transport now sends an OPTIONS request without a body before its first HTTP operation, reads `Allow`, and automatically selects QUERY, then POST, then GET for queries. The top bar method controls are removed. The response pane instead shows the actual request method from the optional `TransportResponse.method` field alongside timing and size, including for HTTP error responses and incremental chunks. WebSocket events and custom transports without this metadata omit the method. + +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 falls back to POST (or the configured `method` override). A valid advertisement containing no usable methods blocks HTTP execution. Mutations always require POST; GraphiQL delegates routing to the transport from Run, the operation dropdown, and the keyboard shortcut. An operation whose type cannot be determined also requires POST. If POST is unavailable, these operations fail without sending. + +Pass `method: 'GET'` to override automatic query selection for testing. GET encodes queries, operation names, variables, and extensions in the URL without a body; mutations still require POST. An override that discovery excludes fails without silently choosing another method. `transport.setMethod?.('GET')` changes the override for subsequent queries, and `transport.setMethod?.()` restores automatic selection. Pass `supportedMethods: ['POST']` to preserve the previous POST-only default and skip OPTIONS, or use `method: 'GET', supportedMethods: ['GET', 'POST']` to test GET queries and POST mutations without discovery. If discovery fails with a configured GET override and no explicit capabilities, only GET is assumed available. + +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`, and does not retry operations with another method. Custom transports own routing and must prevent mutations over GET and QUERY. GraphiQL reads metadata from each response without subscribing to transport capability changes. diff --git a/docs/migration/graphiql-6.0.0.md b/docs/migration/graphiql-6.0.0.md index fe9a0431e9d..db1bc28ed6b 100644 --- a/docs/migration/graphiql-6.0.0.md +++ b/docs/migration/graphiql-6.0.0.md @@ -365,32 +365,43 @@ Use `onResponse(response => { ... })` to observe each `TransportResponse`, inclu ### Request methods -`createTransport` supports three HTTP methods: +`createTransport` automatically selects the first supported method in this order for queries: -- `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. +1. [`QUERY`](https://www.rfc-editor.org/rfc/rfc10008.html) sends a JSON request body and is defined as safe and idempotent. +2. `POST` sends a JSON request body. +3. `GET` sends the query, operation name, variables, and extensions in the URL without a request body. -> **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. +Before its first HTTP operation, including introspection, the transport discovers available methods with an `OPTIONS` request without a body. It reads the endpoint's `Allow` header using the effective request 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 falls back to POST (or the configured `method` override). A valid advertisement containing no usable methods blocks HTTP requests. Discovery 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 override the automatic query method for testing. The override fails if discovery says the method is unavailable. To skip discovery, also pass a nonempty `supportedMethods` array listing the endpoint's capabilities: ```ts const transport = createTransport({ url: 'https://my.endpoint/graphql', - method: 'POST', - supportedMethods: ['POST', 'GET', 'QUERY'], + method: 'GET', + supportedMethods: ['GET', 'POST'], }); + +transport.setMethod?.('POST'); // Override subsequent queries. +transport.setMethod?.(); // Restore automatic selection (POST for this endpoint). ``` -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. +This example sends queries through GET and mutations through POST. Omitting `method` would prefer POST over GET. If discovery is unavailable with `method: 'GET'` and no explicit capabilities, only GET is assumed available; configure `supportedMethods: ['GET', 'POST']` when both methods are known to work. -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. +Mutations always require POST, including operations started from GraphiQL's Run button, operation dropdown, or keyboard shortcut. If POST is unavailable, the transport rejects the mutation without sending it. Operations whose type cannot be determined also require POST. Discovery uses the current request headers before making this decision. + +GraphiQL's top bar shows the endpoint without a method chip or switcher. The response pane displays `TransportResponse.method` alongside timing and size, so it shows the actual HTTP method for that response. This field is optional and is omitted for WebSocket events or custom transports that cannot report it. GraphiQL delegates HTTP method policy to the transport; custom transports must also keep mutations off GET and QUERY. ### 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. +Include the actual HTTP method in each `TransportResponse.method` when your transport can observe it. GraphiQL displays response metadata directly; it does not discover capabilities or subscribe to method changes on custom transports. + **Before (custom fetcher):** ```ts @@ -450,6 +461,7 @@ const transport: Transport = { }); return { ok: response.ok && !body.errors?.length, + method: 'POST', status: response.status, statusText: response.statusText, headers, diff --git a/packages/graphiql-e2e/cypress/e2e/keyboard.cy.ts b/packages/graphiql-e2e/cypress/e2e/keyboard.cy.ts index 3c5fddaeba2..85b7e082347 100644 --- a/packages/graphiql-e2e/cypress/e2e/keyboard.cy.ts +++ b/packages/graphiql-e2e/cypress/e2e/keyboard.cy.ts @@ -53,10 +53,10 @@ describe('keyboard navigation', () => { cy.get('.graphiql-query-editor .view-lines').should('exist'); }); - it('Tab from a fresh load lands on the top bar method chip, and Shift+Tab returns to the page', () => { + it('Tab from a fresh load lands on Run, and Shift+Tab returns to the page', () => { cy.get('.graphiql-top-bar-brand').realClick(); cy.realPress('Tab'); - cy.focused().should('have.class', 'graphiql-top-bar-method-toggle'); + cy.focused().should('have.attr', 'aria-label', 'Run operation'); // The brand mark isn't itself focusable, so Shift+Tab from the first // real stop leaves the document (nothing before it in tab order). cy.realPress(['Shift', 'Tab']); @@ -73,7 +73,6 @@ describe('keyboard navigation', () => { // toolbar changes while still proving each segment is keyboard-reachable // in order. const TOP_BAR_AND_RAIL_LABELS = [ - { className: 'graphiql-top-bar-method-toggle' }, { ariaLabel: 'Run operation' }, { ariaLabel: 'Show Documentation Explorer' }, { ariaLabel: 'Show History' }, diff --git a/packages/graphiql-e2e/cypress/e2e/transport.cy.ts b/packages/graphiql-e2e/cypress/e2e/transport.cy.ts index 4a2a4efe995..a658bf37ebb 100644 --- a/packages/graphiql-e2e/cypress/e2e/transport.cy.ts +++ b/packages/graphiql-e2e/cypress/e2e/transport.cy.ts @@ -2,7 +2,7 @@ * The E2E app in `packages/graphiql-e2e/src/e2e.ts` uses `createTransport` and the * `` prop. These tests confirm that the response pane * header shows real wire metadata from the actual HTTP response (status code, - * timing, response size) and that the upgrade banner is NOT shown because + * method, timing, response size) and that the upgrade banner is NOT shown because * `transport` is in use, not `fetcher`. */ @@ -23,6 +23,32 @@ describe('Transport API + response pane header', () => { ); }); + it('Shows the actual POST method with no toolbar method control', () => { + cy.visitGraphiQL({ query: testQuery }); + cy.clickExecuteQuery(); + cy.get('.graphiql-response-method').should('have.text', 'POST'); + cy.get('.graphiql-top-bar-method-toggle').should('not.exist'); + cy.get('.graphiql-top-bar-method-label').should('not.exist'); + }); + + it('Discovers a GET-only endpoint and displays the actual GET method', () => { + cy.intercept('OPTIONS', '/graphql', { + statusCode: 204, + headers: { Allow: 'GET' }, + }).as('discovery'); + cy.intercept('GET', '/graphql*', request => { + if (new URL(request.url).searchParams.get('operationName') === 'Read') { + request.alias = 'read'; + } + }); + cy.visitGraphiQL({ query: 'query Read { __typename }' }); + cy.wait('@discovery'); + cy.clickExecuteQuery(); + cy.wait('@read').its('request.method').should('eq', 'GET'); + cy.get('.graphiql-response-method').should('have.text', 'GET'); + cy.get('.result-window').should('contain.text', '__typename'); + }); + it('Shows a real timing badge in ms', () => { cy.visitGraphiQL({ query: testQuery }); cy.clickExecuteQuery(); @@ -58,39 +84,49 @@ 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', () => { + 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', }); - cy.get('.graphiql-top-bar-method-toggle').click(); - 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-response-method').should('have.text', 'POST'); + + 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.contains('[role="menuitem"]', 'Change') + .should(item => { + expect(item).not.to.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-response-method').should('have.text', 'POST'); + + cy.activateOperation('Change'); + 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.get('[aria-label="Run operation"]').should('be.disabled'); - 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.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-response-method').should('have.text', 'POST'); + cy.then(() => + expect(mutationMethods).to.deep.equal(['POST', 'POST', 'POST']), + ); }); }); diff --git a/packages/graphiql-e2e/src/e2e.ts b/packages/graphiql-e2e/src/e2e.ts index f73d387969a..16909e8d998 100644 --- a/packages/graphiql-e2e/src/e2e.ts +++ b/packages/graphiql-e2e/src/e2e.ts @@ -90,7 +90,6 @@ const root = ReactDOM.createRoot(document.getElementById('graphiql')!); const props: ComponentProps = { transport: createTransport({ url: getSchemaUrl(), - supportedMethods: ['GET', 'POST', 'QUERY'], subscriptionClient: isLocal() ? createClient({ url: `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/subscriptions`, 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)} > { + 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(); +}); + +it('replaces the wrapped transport and can switch back to a fetcher', () => { + const first = createTransport({ url: '/first', supportedMethods: ['POST'] }); + const second = createTransport({ url: '/second', supportedMethods: ['GET'] }); + let transport: Transport | undefined = first; + const fetcher = async () => ({ data: {} }); + const { result, rerender } = renderHook(() => useGraphiQL(state => state), { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }); + + expect(result.current.transport?.url).toBe('/first'); + transport = second; + rerender(); + expect(result.current.transport?.url).toBe('/second'); + expect(result.current.transport?.method).toBe('GET'); + + transport = undefined; + rerender(); + expect(result.current.transport).toBeUndefined(); + expect(result.current.fetcher).toBe(fetcher); +}); + +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', + 'QUERY', + ]); + expect(beforeSend).toHaveBeenCalledOnce(); + expect(onResponse).toHaveBeenCalledOnce(); + expect(onResponse.mock.calls[0]?.[0]).toMatchObject({ method: '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..848da2629b3 100644 --- a/packages/graphiql-react/src/components/provider.tsx +++ b/packages/graphiql-react/src/components/provider.tsx @@ -358,7 +358,6 @@ const InnerGraphiQLProvider: FC = ({ storeRef.current.setState({ fetcher, transport: wrappedTransport, - transportMethod: wrappedTransport?.method ?? null, }); }, [fetcher, transport]); diff --git a/packages/graphiql-react/src/components/response-editor/index.tsx b/packages/graphiql-react/src/components/response-editor/index.tsx index ada2bde98e2..c997ad66a1a 100644 --- a/packages/graphiql-react/src/components/response-editor/index.tsx +++ b/packages/graphiql-react/src/components/response-editor/index.tsx @@ -185,6 +185,7 @@ export const ResponseEditor: FC = ({
{showHeader && ( = ({ + method, status, statusText, timeMs, @@ -110,6 +114,11 @@ export const ResponseHeader: FC = ({ )} )} + {method && ( + + {method} + + )} {timeMs !== undefined && ( {Math.round(timeMs)}ms diff --git a/packages/graphiql-react/src/components/response-editor/response-header/response-header.stories.tsx b/packages/graphiql-react/src/components/response-editor/response-header/response-header.stories.tsx index 9ae358977d4..be4ca89e37d 100644 --- a/packages/graphiql-react/src/components/response-editor/response-header/response-header.stories.tsx +++ b/packages/graphiql-react/src/components/response-editor/response-header/response-header.stories.tsx @@ -33,6 +33,7 @@ export const WithMetadata: Story = { return ( { expect(container.querySelector('.graphiql-response-status')).toBeNull(); }); + it('renders the method reported by the response beside the other metadata', () => { + renderWithTooltip( + {}} + />, + ); + expect(screen.getByTitle('Request method')).toHaveTextContent('POST'); + expect(screen.getByText('42ms')).toBeInTheDocument(); + expect(screen.getByText('512 B')).toBeInTheDocument(); + }); + + it('omits the method when the response does not report one', () => { + renderWithTooltip( + {}} />, + ); + expect(screen.queryByTitle('Request method')).not.toBeInTheDocument(); + }); + it('renders time in ms when provided', () => { renderWithTooltip( {}} />, diff --git a/packages/graphiql-react/src/components/top-bar/index.css b/packages/graphiql-react/src/components/top-bar/index.css index eaa501d6575..a2a503bffba 100644 --- a/packages/graphiql-react/src/components/top-bar/index.css +++ b/packages/graphiql-react/src/components/top-bar/index.css @@ -59,58 +59,6 @@ font-size: var(--font-size-small); } -.graphiql-top-bar-method-label { - color: oklch(var(--accent-green)); - font-weight: 600; - flex-shrink: 0; -} - -/* Click-to-cycle method chip — shown when the transport supports more than one method */ -.graphiql-top-bar-method-toggle { - padding: 0 var(--px-6); - height: 18px; - background: oklch(var(--bg-subtle)); - border: 1px solid oklch(var(--border-strong)); - border-radius: var(--radius-sm); - color: oklch(var(--accent-green)); - font-family: var(--font-family-mono); - font-size: var(--font-size-small); - font-weight: 600; - cursor: pointer; - line-height: 1; - white-space: nowrap; - flex-shrink: 0; - transition: - background 0.1s, - color 0.1s; -} - -.graphiql-top-bar-method-toggle:hover { - background: oklch(var(--bg-canvas)); -} - -.graphiql-top-bar-method-toggle--attention { - color: oklch(var(--accent-orange)); - border-color: oklch(var(--accent-orange)); - animation: graphiql-top-bar-method-pulse 1.4s ease-in-out infinite; -} - -@keyframes graphiql-top-bar-method-pulse { - 0%, - 100% { - box-shadow: 0 0 0 0 oklch(var(--accent-orange) / 0.35); - } - 50% { - box-shadow: 0 0 0 3px oklch(var(--accent-orange) / 0); - } -} - -@media (prefers-reduced-motion: reduce) { - .graphiql-top-bar-method-toggle--attention { - animation: none; - } -} - .graphiql-top-bar-endpoint-url { color: oklch(var(--fg-subtle)); overflow: hidden; diff --git a/packages/graphiql-react/src/components/top-bar/index.tsx b/packages/graphiql-react/src/components/top-bar/index.tsx index c3faaa1ce16..9ca27578342 100644 --- a/packages/graphiql-react/src/components/top-bar/index.tsx +++ b/packages/graphiql-react/src/components/top-bar/index.tsx @@ -1,18 +1,11 @@ // React Compiler can stale-cache the references returned by zustand hooks; -// opt this file out so `useGraphiQL` / `useGraphiQLActions` stay live. +// opt this file out so `useGraphiQL` stays live. 'use no memo'; import type { FC, ReactNode } from 'react'; -import type { HttpMethod } from '@graphiql/toolkit'; -import { useGraphiQL, useGraphiQLActions } from '../provider'; -import { Tooltip } from '../tooltip'; +import { useGraphiQL } from '../provider'; import { ExecuteButton } from '../execute-button'; import { GraphQLLogoIcon } from '../../icons'; -import { clsx } from 'clsx'; -import { - getRunBlockReason, - resolveActiveOperation, -} from '../../utility/run-block'; import './index.css'; export type TopBarProps = { @@ -26,30 +19,14 @@ export type TopBarProps = { }; export const TopBar: FC = ({ version, brand }) => { - const { setTransportMethod } = useGraphiQLActions(); - const transport = useGraphiQL(state => state.transport); - const transportMethod = useGraphiQL(state => state.transportMethod); - const runDisabledReason = useGraphiQL(state => - getRunBlockReason( - state.transportMethod, - resolveActiveOperation(state.operations, state.operationName), - ), - ); - - const url = transport?.url ?? '—'; - const method: HttpMethod = transportMethod ?? 'POST'; - const supportedMethods = transport?.supportedMethods ?? ['POST']; + const url = useGraphiQL(state => state.transport?.url ?? '—'); return ( } - onSetMethod={setTransportMethod} /> ); }; @@ -58,40 +35,16 @@ export type TopBarViewProps = { version?: string; brand?: ReactNode; 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; }; export const TopBarView: FC = ({ version, brand, 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. - const nextMethod = - supportedMethods[ - (supportedMethods.indexOf(method) + 1) % supportedMethods.length - ] ?? method; - const switchTarget = - isBlocked && supportedMethods.includes('POST') ? 'POST' : nextMethod; - return (
@@ -109,23 +62,6 @@ export const TopBarView: FC = ({