From 574a5d823e71ca1d0628897a73e2fab1d0d5bfe0 Mon Sep 17 00:00:00 2001 From: Michael Skorokhodov Date: Mon, 30 Mar 2026 16:02:23 +0200 Subject: [PATCH] =?UTF-8?q?enhancement:=20lab=20to=20fetch=20schema=20if?= =?UTF-8?q?=20no=20introspection=20provided=20in=20inte=E2=80=A6=20(#7888)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/loud-mammals-clean.md | 7 ++ .../src/components/laboratory/builder.tsx | 2 - .../src/components/laboratory/editor.tsx | 49 +++++--- .../libraries/laboratory/src/lib/endpoint.ts | 118 ++++++++++++++---- .../laboratory/src/lib/operations.utils.ts | 2 - .../libraries/laboratory/src/lib/utils.ts | 23 ++++ 6 files changed, 151 insertions(+), 50 deletions(-) create mode 100644 .changeset/loud-mammals-clean.md diff --git a/.changeset/loud-mammals-clean.md b/.changeset/loud-mammals-clean.md new file mode 100644 index 000000000..1bb461ff8 --- /dev/null +++ b/.changeset/loud-mammals-clean.md @@ -0,0 +1,7 @@ +--- +'@graphql-hive/laboratory': patch +'@graphql-hive/render-laboratory': patch +--- + +If schema introspection isn't provided as property to Laboratory, lab will start interval to fetch +schema every second. diff --git a/packages/libraries/laboratory/src/components/laboratory/builder.tsx b/packages/libraries/laboratory/src/components/laboratory/builder.tsx index bdfe305a0..41eaf2e0a 100644 --- a/packages/libraries/laboratory/src/components/laboratory/builder.tsx +++ b/packages/libraries/laboratory/src/components/laboratory/builder.tsx @@ -791,8 +791,6 @@ export const Builder = (props: { }); }, [schema, deferredSearchValue, isSearchActive, tabValue]); - console.log(searchResult); - const visiblePaths = isSearchActive ? (searchResult?.visiblePaths ?? null) : null; const forcedOpenPaths = isSearchActive && deferredSearchValue.includes('.') diff --git a/packages/libraries/laboratory/src/components/laboratory/editor.tsx b/packages/libraries/laboratory/src/components/laboratory/editor.tsx index ead2b11e4..b19bdac7f 100644 --- a/packages/libraries/laboratory/src/components/laboratory/editor.tsx +++ b/packages/libraries/laboratory/src/components/laboratory/editor.tsx @@ -1,5 +1,6 @@ import { forwardRef, useEffect, useId, useImperativeHandle, useRef, useState } from 'react'; import * as monaco from 'monaco-editor'; +import { MonacoGraphQLAPI } from 'monaco-graphql/esm/api.js'; import { initializeMode } from 'monaco-graphql/initializeMode'; import MonacoEditor, { loader } from '@monaco-editor/react'; import { useLaboratory } from './context'; @@ -128,34 +129,44 @@ const EditorInner = forwardRef((props, ref) => { const editorRef = useRef(null); const { introspection, endpoint, theme } = useLaboratory(); const [typescriptReady, setTypescriptReady] = useState(!!monaco.languages.typescript); + const apiRef = useRef(null); useEffect(() => { if (introspection) { - const api = initializeMode({ - schemas: [ + if (apiRef.current) { + apiRef.current.setSchemaConfig([ { introspectionJSON: introspection, uri: `schema_${endpoint}.graphql`, }, - ], - diagnosticSettings: - props.uri && props.variablesUri - ? { - validateVariablesJSON: { - [props.uri.toString()]: [props.variablesUri.toString()], - }, - jsonDiagnosticSettings: { - allowComments: true, // allow json, parse with a jsonc parser to make requests - }, - } - : undefined, - }); + ]); + } else { + apiRef.current = initializeMode({ + schemas: [ + { + introspectionJSON: introspection, + uri: `schema_${endpoint}.graphql`, + }, + ], + diagnosticSettings: + props.uri && props.variablesUri + ? { + validateVariablesJSON: { + [props.uri.toString()]: [props.variablesUri.toString()], + }, + jsonDiagnosticSettings: { + allowComments: true, // allow json, parse with a jsonc parser to make requests + }, + } + : undefined, + }); - api.setCompletionSettings({ - __experimental__fillLeafsOnComplete: true, - }); + apiRef.current.setCompletionSettings({ + __experimental__fillLeafsOnComplete: true, + }); + } } - }, [introspection, props.uri?.toString(), props.variablesUri?.toString()]); + }, [endpoint, introspection, props.uri?.toString(), props.variablesUri?.toString()]); useEffect(() => { void (async function () { diff --git a/packages/libraries/laboratory/src/lib/endpoint.ts b/packages/libraries/laboratory/src/lib/endpoint.ts index f4ab1a4ac..f2daa3681 100644 --- a/packages/libraries/laboratory/src/lib/endpoint.ts +++ b/packages/libraries/laboratory/src/lib/endpoint.ts @@ -6,6 +6,8 @@ import { type IntrospectionQuery, } from 'graphql'; import { toast } from 'sonner'; +import z from 'zod'; +import { asyncInterval } from '@/lib/utils'; export interface LaboratoryEndpointState { endpoint: string | null; @@ -20,6 +22,16 @@ export interface LaboratoryEndpointActions { restoreDefaultEndpoint: () => void; } +const GraphQLResponseErrorSchema = z + .object({ + errors: z.array( + z.object({ + message: z.string(), + }), + ), + }) + .strict(); + export const useEndpoint = (props: { defaultEndpoint?: string | null; onEndpointChange?: (endpoint: string | null) => void; @@ -40,35 +52,87 @@ export const useEndpoint = (props: { return introspection ? buildClientSchema(introspection) : null; }, [introspection]); - const fetchSchema = useCallback(async () => { - if (endpoint === props.defaultEndpoint && props.defaultSchemaIntrospection) { - setIntrospection(props.defaultSchemaIntrospection); + const fetchSchema = useCallback( + async (signal?: AbortSignal) => { + if (endpoint === props.defaultEndpoint && props.defaultSchemaIntrospection) { + setIntrospection(props.defaultSchemaIntrospection); + return; + } + + if (!endpoint) { + setIntrospection(null); + return; + } + + try { + const response = await fetch(endpoint, { + signal, + method: 'POST', + body: JSON.stringify({ + query: getIntrospectionQuery(), + }), + headers: { + 'Content-Type': 'application/json', + }, + }).then(r => r.json()); + + const parsedResponse = GraphQLResponseErrorSchema.safeParse(response); + + if (parsedResponse.success) { + throw new Error(parsedResponse.data.errors.map(e => e.message).join('\n')); + } + + if (response.error && typeof response.error === 'string') { + throw new Error(response.error); + } + + setIntrospection(response.data as IntrospectionQuery); + } 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; + } + }, + [endpoint], + ); + + const shouldPollSchema = useMemo(() => { + return endpoint !== props.defaultEndpoint || !props.defaultSchemaIntrospection; + }, [endpoint, props.defaultEndpoint, props.defaultSchemaIntrospection]); + + useEffect(() => { + if (!shouldPollSchema || !endpoint) { return; } - if (!endpoint) { - setIntrospection(null); - return; - } + const intervalController = new AbortController(); - try { - const response = await fetch(endpoint, { - method: 'POST', - body: JSON.stringify({ - query: getIntrospectionQuery(), - }), - headers: { - 'Content-Type': 'application/json', - }, - }).then(r => r.json()); - - setIntrospection(response.data as IntrospectionQuery); - } catch { - toast.error('Failed to fetch schema'); - setIntrospection(null); - return; - } - }, [endpoint]); + void asyncInterval( + async () => { + try { + await fetchSchema(intervalController.signal); + } catch { + intervalController.abort(); + } + }, + 5000, + intervalController.signal, + ); + return () => { + intervalController.abort(); + }; + }, [shouldPollSchema, fetchSchema]); const restoreDefaultEndpoint = useCallback(() => { if (props.defaultEndpoint) { @@ -77,10 +141,10 @@ export const useEndpoint = (props: { }, [props.defaultEndpoint]); useEffect(() => { - if (endpoint) { + if (endpoint && !shouldPollSchema) { void fetchSchema(); } - }, [endpoint, fetchSchema]); + }, [endpoint, fetchSchema, shouldPollSchema]); return { endpoint, diff --git a/packages/libraries/laboratory/src/lib/operations.utils.ts b/packages/libraries/laboratory/src/lib/operations.utils.ts index b1aff3c26..1fa6fd6bf 100644 --- a/packages/libraries/laboratory/src/lib/operations.utils.ts +++ b/packages/libraries/laboratory/src/lib/operations.utils.ts @@ -348,8 +348,6 @@ export async function getOperationHash( operation: Pick, ) { try { - console.log(operation.query, operation.variables); - const canonicalQuery = print(parse(operation.query)); const canonicalVariables = ''; const canonical = `${canonicalQuery}\n${canonicalVariables}`; diff --git a/packages/libraries/laboratory/src/lib/utils.ts b/packages/libraries/laboratory/src/lib/utils.ts index 325ed8ba6..2f4142e3e 100644 --- a/packages/libraries/laboratory/src/lib/utils.ts +++ b/packages/libraries/laboratory/src/lib/utils.ts @@ -17,3 +17,26 @@ export function splitIdentifier(input: string): string[] { .split(/\s+/) .map(w => w.toLowerCase()); } + +export async function asyncInterval( + fn: () => Promise, + delay: number, + signal?: AbortSignal, +): Promise { + while (!signal?.aborted) { + await fn(); + + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, delay); + + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer); + reject(new DOMException('Aborted', 'AbortError')); + }, + { once: true }, + ); + }); + } +}