From 2fa8a55bbed18235e0f4491c3fd789136944a4bd Mon Sep 17 00:00:00 2001 From: Mykhailo Skorokhodov Date: Tue, 5 May 2026 21:32:28 +0200 Subject: [PATCH 1/3] lab to append active operation headers to introspection header --- .../src/components/laboratory/laboratory.tsx | 12 +- .../src/components/laboratory/settings.tsx | 1 + .../libraries/laboratory/src/lib/endpoint.ts | 171 +++++++++++------- 3 files changed, 115 insertions(+), 69 deletions(-) diff --git a/packages/libraries/laboratory/src/components/laboratory/laboratory.tsx b/packages/libraries/laboratory/src/components/laboratory/laboratory.tsx index 44c887be38..0f6dd1ad6d 100644 --- a/packages/libraries/laboratory/src/components/laboratory/laboratory.tsx +++ b/packages/libraries/laboratory/src/components/laboratory/laboratory.tsx @@ -514,15 +514,10 @@ export const Laboratory = ( const pluginsApi = usePlugins(props); const testsApi = useTests(props); const tabsApi = useTabs(props); - const endpointApi = useEndpoint({ - ...props, - settingsApi, - }); const collectionsApi = useCollections({ ...props, tabsApi, }); - const operationsApi = useOperations({ ...props, collectionsApi, @@ -533,6 +528,13 @@ export const Laboratory = ( pluginsApi, checkPermissions, }); + const endpointApi = useEndpoint({ + ...props, + settingsApi, + operationsApi, + envApi, + pluginsApi, + }); const historyApi = useHistory(props); diff --git a/packages/libraries/laboratory/src/components/laboratory/settings.tsx b/packages/libraries/laboratory/src/components/laboratory/settings.tsx index 92f6a9802f..a335481692 100644 --- a/packages/libraries/laboratory/src/components/laboratory/settings.tsx +++ b/packages/libraries/laboratory/src/components/laboratory/settings.tsx @@ -20,6 +20,7 @@ const settingsFormSchema = z.object({ introspection: z.object({ method: z.enum(['GET', 'POST']).optional(), schemaDescription: z.boolean().optional(), + headers: z.string().optional(), }), }); diff --git a/packages/libraries/laboratory/src/lib/endpoint.ts b/packages/libraries/laboratory/src/lib/endpoint.ts index 2e653ff3fe..25ceb64037 100644 --- a/packages/libraries/laboratory/src/lib/endpoint.ts +++ b/packages/libraries/laboratory/src/lib/endpoint.ts @@ -5,7 +5,12 @@ import { introspectionFromSchema, type IntrospectionQuery, } from 'graphql'; +import { throttle } from 'lodash'; import { toast } from 'sonner'; +import { LaboratoryEnv, LaboratoryEnvActions, LaboratoryEnvState } from '@/lib/env'; +import { LaboratoryOperationsActions, LaboratoryOperationsState } from '@/lib/operations'; +import { handleTemplate } from '@/lib/operations.utils'; +import { LaboratoryPluginsActions, LaboratoryPluginsState } from '@/lib/plugins'; // import z from 'zod'; import { asyncInterval } from '@/lib/utils'; import { SubscriptionProtocol, UrlLoader } from '@graphql-tools/url-loader'; @@ -24,11 +29,16 @@ export interface LaboratoryEndpointActions { restoreDefaultEndpoint: () => void; } +export const EXPECTED_ERROR_REASON = 'Expected error reason'; + export const useEndpoint = (props: { defaultEndpoint?: string | null; onEndpointChange?: (endpoint: string | null) => void; defaultSchemaIntrospection?: IntrospectionQuery | null; settingsApi?: LaboratorySettingsState & LaboratorySettingsActions; + operationsApi?: LaboratoryOperationsState & LaboratoryOperationsActions; + envApi?: LaboratoryEnvState & LaboratoryEnvActions; + pluginsApi?: LaboratoryPluginsState & LaboratoryPluginsActions; }): LaboratoryEndpointState & LaboratoryEndpointActions => { const [endpoint, _setEndpoint] = useState(props.defaultEndpoint ?? null); const [introspection, setIntrospection] = useState(null); @@ -47,72 +57,99 @@ export const useEndpoint = (props: { const loader = useMemo(() => new UrlLoader(), []); - const fetchSchema = useCallback( - async (signal?: AbortSignal) => { - if (endpoint === props.defaultEndpoint && props.defaultSchemaIntrospection) { - setIntrospection(props.defaultSchemaIntrospection); - return; - } - - if (!endpoint) { - setIntrospection(null); - return; - } - - try { - const result = await loader.load(endpoint, { - subscriptionsEndpoint: endpoint, - subscriptionsProtocol: - (props.settingsApi?.settings.subscriptions.protocol as SubscriptionProtocol) ?? - SubscriptionProtocol.GRAPHQL_SSE, - credentials: props.settingsApi?.settings.fetch.credentials, - specifiedByUrl: true, - directiveIsRepeatable: true, - inputValueDeprecation: true, - retry: props.settingsApi?.settings.fetch.retry, - timeout: props.settingsApi?.settings.fetch.timeout, - useGETForQueries: props.settingsApi?.settings.fetch.useGETForQueries, - exposeHTTPDetailsInExtensions: true, - descriptions: props.settingsApi?.settings.introspection.schemaDescription ?? false, - method: props.settingsApi?.settings.introspection.method ?? 'POST', - fetch: (input: string | URL | Request, init?: RequestInit) => - fetch(input, { - ...init, - signal, - }), - }); - - if (result.length === 0) { - throw new Error('Failed to fetch schema'); - } - - if (!result[0].schema) { - throw new Error('Failed to fetch schema'); - } - - setIntrospection(introspectionFromSchema(result[0].schema)); - } catch (error: unknown) { - if ( - error && - typeof error === 'object' && - 'message' in error && - typeof error.message === 'string' - ) { - toast.error(error.message); - } else { - toast.error('Failed to fetch schema'); - } - - setIntrospection(null); - - throw error; - } - }, + const fetchSchema = useMemo( + () => + throttle( + async ( + signal?: AbortSignal, + options?: { + env?: LaboratoryEnv; + pluginsState?: Record; + }, + ) => { + if (endpoint === props.defaultEndpoint && props.defaultSchemaIntrospection) { + setIntrospection(props.defaultSchemaIntrospection); + return; + } + + if (!endpoint) { + setIntrospection(null); + return; + } + + try { + const parsedHeaders = props.operationsApi?.activeOperation?.headers + ? JSON.parse( + handleTemplate(props.operationsApi?.activeOperation?.headers, { + ...(options?.env?.variables ?? {}), + plugins: options?.pluginsState ?? {}, + }), + ) + : {}; + + const result = await loader.load(endpoint, { + subscriptionsEndpoint: endpoint, + subscriptionsProtocol: + (props.settingsApi?.settings.subscriptions.protocol as SubscriptionProtocol) ?? + SubscriptionProtocol.GRAPHQL_SSE, + headers: parsedHeaders, + credentials: props.settingsApi?.settings.fetch.credentials, + specifiedByUrl: true, + directiveIsRepeatable: true, + inputValueDeprecation: true, + retry: props.settingsApi?.settings.fetch.retry, + timeout: props.settingsApi?.settings.fetch.timeout, + useGETForQueries: props.settingsApi?.settings.fetch.useGETForQueries, + exposeHTTPDetailsInExtensions: true, + descriptions: props.settingsApi?.settings.introspection.schemaDescription ?? false, + method: props.settingsApi?.settings.introspection.method ?? 'POST', + fetch: (input: string | URL | Request, init?: RequestInit) => + fetch(input, { + ...init, + signal, + }), + }); + + if (result.length === 0) { + throw new Error('Failed to fetch schema'); + } + + if (!result[0].schema) { + throw new Error('Failed to fetch schema'); + } + + setIntrospection(introspectionFromSchema(result[0].schema)); + } catch (error: unknown) { + if ( + error && + typeof error === 'object' && + 'message' in error && + typeof error.message === 'string' + ) { + if (error.message === EXPECTED_ERROR_REASON) { + return; + } + + toast.error(error.message); + } else { + toast.error('Failed to fetch schema'); + } + + setIntrospection(null); + + throw error; + } + }, + 500, + ), [ endpoint, props.settingsApi?.settings.fetch.timeout, props.settingsApi?.settings.introspection.method, props.settingsApi?.settings.introspection.schemaDescription, + props.operationsApi?.activeOperation?.headers, + props.envApi?.env?.variables, + props.pluginsApi?.pluginsState, ], ); @@ -132,7 +169,7 @@ export const useEndpoint = (props: { try { await fetchSchema(intervalController.signal); } catch { - intervalController.abort(); + intervalController.abort(new Error('Aborted because of schema polling error')); } }, 5000, @@ -140,7 +177,7 @@ export const useEndpoint = (props: { ); return () => { - intervalController.abort(); + intervalController.abort(new Error(EXPECTED_ERROR_REASON)); }; }, [shouldPollSchema, fetchSchema]); @@ -152,7 +189,13 @@ export const useEndpoint = (props: { useEffect(() => { if (endpoint && !shouldPollSchema) { - void fetchSchema(); + const abortController = new AbortController(); + + void fetchSchema(abortController.signal); + + return () => { + abortController.abort(new Error(EXPECTED_ERROR_REASON)); + }; } }, [endpoint, fetchSchema, shouldPollSchema]); From 53fb6318a69aeb6a5b7da7328d7c8bf5e6280a9d Mon Sep 17 00:00:00 2001 From: Mykhailo Skorokhodov Date: Thu, 7 May 2026 11:22:35 +0200 Subject: [PATCH 2/3] fix: resolved deps to properly debounce schema fetch --- .../src/components/laboratory/settings.tsx | 1 - .../libraries/laboratory/src/lib/endpoint.ts | 73 +++++++++++++++---- 2 files changed, 58 insertions(+), 16 deletions(-) diff --git a/packages/libraries/laboratory/src/components/laboratory/settings.tsx b/packages/libraries/laboratory/src/components/laboratory/settings.tsx index a335481692..92f6a9802f 100644 --- a/packages/libraries/laboratory/src/components/laboratory/settings.tsx +++ b/packages/libraries/laboratory/src/components/laboratory/settings.tsx @@ -20,7 +20,6 @@ const settingsFormSchema = z.object({ introspection: z.object({ method: z.enum(['GET', 'POST']).optional(), schemaDescription: z.boolean().optional(), - headers: z.string().optional(), }), }); diff --git a/packages/libraries/laboratory/src/lib/endpoint.ts b/packages/libraries/laboratory/src/lib/endpoint.ts index 25ceb64037..f9ff88c7f0 100644 --- a/packages/libraries/laboratory/src/lib/endpoint.ts +++ b/packages/libraries/laboratory/src/lib/endpoint.ts @@ -1,17 +1,16 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { buildClientSchema, GraphQLSchema, introspectionFromSchema, type IntrospectionQuery, } from 'graphql'; -import { throttle } from 'lodash'; +import { debounce } from 'lodash'; import { toast } from 'sonner'; import { LaboratoryEnv, LaboratoryEnvActions, LaboratoryEnvState } from '@/lib/env'; import { LaboratoryOperationsActions, LaboratoryOperationsState } from '@/lib/operations'; import { handleTemplate } from '@/lib/operations.utils'; import { LaboratoryPluginsActions, LaboratoryPluginsState } from '@/lib/plugins'; -// import z from 'zod'; import { asyncInterval } from '@/lib/utils'; import { SubscriptionProtocol, UrlLoader } from '@graphql-tools/url-loader'; import type { LaboratorySettingsActions, LaboratorySettingsState } from './settings'; @@ -57,9 +56,21 @@ export const useEndpoint = (props: { const loader = useMemo(() => new UrlLoader(), []); + const activeOperationHeadersRef = useRef( + props.operationsApi?.activeOperation?.headers, + ); + const envVariablesRef = useRef( + props.envApi?.env?.variables, + ); + const pluginsStateRef = useRef | undefined>(props.pluginsApi?.pluginsState); + + activeOperationHeadersRef.current = props.operationsApi?.activeOperation?.headers; + envVariablesRef.current = props.envApi?.env?.variables; + pluginsStateRef.current = props.pluginsApi?.pluginsState; + const fetchSchema = useMemo( () => - throttle( + debounce( async ( signal?: AbortSignal, options?: { @@ -78,14 +89,20 @@ export const useEndpoint = (props: { } try { - const parsedHeaders = props.operationsApi?.activeOperation?.headers - ? JSON.parse( - handleTemplate(props.operationsApi?.activeOperation?.headers, { - ...(options?.env?.variables ?? {}), - plugins: options?.pluginsState ?? {}, - }), - ) - : {}; + let parsedHeaders: Record = {}; + + try { + parsedHeaders = activeOperationHeadersRef.current + ? JSON.parse( + handleTemplate(activeOperationHeadersRef.current, { + ...(options?.env?.variables ?? envVariablesRef.current ?? {}), + plugins: options?.pluginsState ?? pluginsStateRef.current ?? {}, + }), + ) + : {}; + } catch (error: unknown) { + toast.error('Failed to parse headers'); + } const result = await loader.load(endpoint, { subscriptionsEndpoint: endpoint, @@ -147,12 +164,15 @@ export const useEndpoint = (props: { props.settingsApi?.settings.fetch.timeout, props.settingsApi?.settings.introspection.method, props.settingsApi?.settings.introspection.schemaDescription, - props.operationsApi?.activeOperation?.headers, - props.envApi?.env?.variables, - props.pluginsApi?.pluginsState, ], ); + useEffect(() => { + return () => { + fetchSchema.cancel(); + }; + }, [fetchSchema]); + const shouldPollSchema = useMemo(() => { return endpoint !== props.defaultEndpoint || !props.defaultSchemaIntrospection; }, [endpoint, props.defaultEndpoint, props.defaultSchemaIntrospection]); @@ -199,6 +219,29 @@ export const useEndpoint = (props: { } }, [endpoint, fetchSchema, shouldPollSchema]); + useEffect(() => { + if (!endpoint || !shouldPollSchema) { + return; + } + + const abortController = new AbortController(); + void fetchSchema(abortController.signal, { + env: props.envApi?.env ?? undefined, + pluginsState: props.pluginsApi?.pluginsState, + }); + + return () => { + abortController.abort(new Error(EXPECTED_ERROR_REASON)); + }; + }, [ + endpoint, + shouldPollSchema, + fetchSchema, + props.operationsApi?.activeOperation?.headers, + props.envApi?.env?.variables, + props.pluginsApi?.pluginsState, + ]); + return { endpoint, setEndpoint, From 195b82ac9bf727e9cecf25ba5c714b278d9e8f7d Mon Sep 17 00:00:00 2001 From: Mykhailo Skorokhodov Date: Sat, 9 May 2026 00:19:55 +0200 Subject: [PATCH 3/3] added changeset --- .changeset/ripe-loops-drum.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/ripe-loops-drum.md diff --git a/.changeset/ripe-loops-drum.md b/.changeset/ripe-loops-drum.md new file mode 100644 index 0000000000..9bca28d131 --- /dev/null +++ b/.changeset/ripe-loops-drum.md @@ -0,0 +1,6 @@ +--- +'@graphql-hive/laboratory': patch +'@graphql-hive/render-laboratory': patch +--- + +Hive laboratory introspection query to include active tab headers