enhancement: lab to fetch schema if no introspection provided in inte… (#7888)

This commit is contained in:
Michael Skorokhodov 2026-03-30 16:02:23 +02:00 committed by GitHub
parent d3e0ef500f
commit 574a5d823e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 151 additions and 50 deletions

View file

@ -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.

View file

@ -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('.')

View file

@ -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<EditorHandle, EditorProps>((props, ref) => {
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const { introspection, endpoint, theme } = useLaboratory();
const [typescriptReady, setTypescriptReady] = useState(!!monaco.languages.typescript);
const apiRef = useRef<MonacoGraphQLAPI | null>(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 () {

View file

@ -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,

View file

@ -348,8 +348,6 @@ export async function getOperationHash(
operation: Pick<LaboratoryOperation, 'query' | 'variables'>,
) {
try {
console.log(operation.query, operation.variables);
const canonicalQuery = print(parse(operation.query));
const canonicalVariables = '';
const canonical = `${canonicalQuery}\n${canonicalVariables}`;

View file

@ -17,3 +17,26 @@ export function splitIdentifier(input: string): string[] {
.split(/\s+/)
.map(w => w.toLowerCase());
}
export async function asyncInterval(
fn: () => Promise<void>,
delay: number,
signal?: AbortSignal,
): Promise<void> {
while (!signal?.aborted) {
await fn();
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, delay);
signal?.addEventListener(
'abort',
() => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
},
{ once: true },
);
});
}
}