From 3467cd33264e0766a0a43cf53e52ec371df26962 Mon Sep 17 00:00:00 2001 From: Thomas Heyenbrock Date: Mon, 23 May 2022 17:26:11 +0200 Subject: [PATCH] add `SchemaContext` to `@graphiql/react` (#2420) * set initial value for editors * make editor context non-null * remove unused prop * add schema context * consume schema context in doc explorer * stop fetching and validating schema in GraphiQL component * avoid passing schema as prop to query editor * replace schema from state with schema from context * move auto-complete logic to context * fix tests * adjust changesets --- .changeset/spicy-moose-look.md | 5 + .changeset/three-bugs-develop.md | 5 + .changeset/unlucky-chefs-hope.md | 6 + .../graphiql-react/src/editor/context.tsx | 101 ++++- .../src/editor/header-editor.tsx | 3 + packages/graphiql-react/src/editor/hooks.ts | 6 +- .../src/editor/query-editor.tsx | 13 +- .../src/editor/response-editor.tsx | 24 +- .../src/editor/variable-editor.tsx | 4 +- packages/graphiql-react/src/explorer.tsx | 22 +- packages/graphiql-react/src/index.ts | 8 + .../graphiql-react/src/schema/context.tsx | 286 ++++++++++++++ packages/graphiql-react/src/schema/hooks.ts | 17 + packages/graphiql-react/src/schema/index.ts | 8 + .../graphiql/__mocks__/@graphiql/react.ts | 8 + .../graphiql/src/components/DocExplorer.tsx | 215 ++++++----- packages/graphiql/src/components/GraphiQL.tsx | 356 ++++-------------- .../components/__tests__/DocExplorer.spec.tsx | 38 +- .../src/utility/introspectionQueries.ts | 10 - 19 files changed, 707 insertions(+), 428 deletions(-) create mode 100644 .changeset/spicy-moose-look.md create mode 100644 .changeset/three-bugs-develop.md create mode 100644 .changeset/unlucky-chefs-hope.md create mode 100644 packages/graphiql-react/src/schema/context.tsx create mode 100644 packages/graphiql-react/src/schema/hooks.ts create mode 100644 packages/graphiql-react/src/schema/index.ts delete mode 100644 packages/graphiql/src/utility/introspectionQueries.ts diff --git a/.changeset/spicy-moose-look.md b/.changeset/spicy-moose-look.md new file mode 100644 index 00000000000..359e1c93a9d --- /dev/null +++ b/.changeset/spicy-moose-look.md @@ -0,0 +1,5 @@ +--- +'graphiql': patch +--- + +Fix sending multiple introspection requests when loading the page diff --git a/.changeset/three-bugs-develop.md b/.changeset/three-bugs-develop.md new file mode 100644 index 00000000000..83bc66140a2 --- /dev/null +++ b/.changeset/three-bugs-develop.md @@ -0,0 +1,5 @@ +--- +'graphiql': patch +--- + +Deprecate the `autoCompleteLeafs` method of the `GraphiQL` component in favor of the function provided by the `EditorContext` from `@graphiql/react` diff --git a/.changeset/unlucky-chefs-hope.md b/.changeset/unlucky-chefs-hope.md new file mode 100644 index 00000000000..498f9c16ecb --- /dev/null +++ b/.changeset/unlucky-chefs-hope.md @@ -0,0 +1,6 @@ +--- +'graphiql': patch +'@graphiql/react': minor +--- + +Add a `SchemaContext` to `@graphiql/react` that replaces the logic for fetching and validating the schema in the `graphiql` package diff --git a/packages/graphiql-react/src/editor/context.tsx b/packages/graphiql-react/src/editor/context.tsx index c58d2529653..0c4c35d706d 100644 --- a/packages/graphiql-react/src/editor/context.tsx +++ b/packages/graphiql-react/src/editor/context.tsx @@ -1,8 +1,17 @@ -import { createContext, ReactNode, useState } from 'react'; +import { fillLeafs, GetDefaultFieldNamesFn } from '@graphiql/toolkit'; +import { + createContext, + ReactNode, + useCallback, + useMemo, + useState, +} from 'react'; +import { useSchemaWithError } from '../schema'; import { CodeMirrorEditor } from './types'; export type EditorContextType = { + autoCompleteLeafs(): string | undefined; headerEditor: CodeMirrorEditor | null; queryEditor: CodeMirrorEditor | null; responseEditor: CodeMirrorEditor | null; @@ -14,6 +23,9 @@ export type EditorContextType = { }; export const EditorContext = createContext({ + autoCompleteLeafs() { + return undefined; + }, headerEditor: null, queryEditor: null, responseEditor: null, @@ -24,10 +36,13 @@ export const EditorContext = createContext({ setVariableEditor() {}, }); -export function EditorContextProvider(props: { +type EditorContextProviderProps = { children: ReactNode; - initialValue?: string; -}) { + getDefaultFieldNames?: GetDefaultFieldNamesFn; +}; + +export function EditorContextProvider(props: EditorContextProviderProps) { + const { schema } = useSchemaWithError('component', 'EditorContextProvider'); const [headerEditor, setHeaderEditor] = useState( null, ); @@ -38,18 +53,74 @@ export function EditorContextProvider(props: { const [variableEditor, setVariableEditor] = useState( null, ); + + const autoCompleteLeafs = useCallback< + EditorContextType['autoCompleteLeafs'] + >(() => { + if (!queryEditor) { + return; + } + + const query = queryEditor.getValue(); + const { insertions, result } = fillLeafs( + schema, + query, + props.getDefaultFieldNames, + ); + if (insertions && insertions.length > 0) { + queryEditor.operation(() => { + const cursor = queryEditor.getCursor(); + const cursorIndex = queryEditor.indexFromPos(cursor); + queryEditor.setValue(result || ''); + let added = 0; + const markers = insertions.map(({ index, string }) => + queryEditor.markText( + queryEditor.posFromIndex(index + added), + queryEditor.posFromIndex(index + (added += string.length)), + { + className: 'autoInsertedLeaf', + clearOnEnter: true, + title: 'Automatically added leaf fields', + }, + ), + ); + setTimeout(() => markers.forEach(marker => marker.clear()), 7000); + let newCursorIndex = cursorIndex; + insertions.forEach(({ index, string }) => { + if (index < cursorIndex) { + newCursorIndex += string.length; + } + }); + queryEditor.setCursor(queryEditor.posFromIndex(newCursorIndex)); + }); + } + + return result; + }, [props.getDefaultFieldNames, queryEditor, schema]); + + const value = useMemo( + () => ({ + autoCompleteLeafs, + headerEditor, + queryEditor, + responseEditor, + variableEditor, + setHeaderEditor, + setQueryEditor, + setResponseEditor, + setVariableEditor, + }), + [ + autoCompleteLeafs, + headerEditor, + queryEditor, + responseEditor, + variableEditor, + ], + ); + return ( - + {props.children} ); diff --git a/packages/graphiql-react/src/editor/header-editor.tsx b/packages/graphiql-react/src/editor/header-editor.tsx index 44d56b9f4c1..fd9dc420b0a 100644 --- a/packages/graphiql-react/src/editor/header-editor.tsx +++ b/packages/graphiql-react/src/editor/header-editor.tsx @@ -45,6 +45,8 @@ export function useHeaderEditor({ const { headerEditor, setHeaderEditor } = context; + const initialValue = useRef(value); + useEffect(() => { let isActive = true; @@ -63,6 +65,7 @@ export function useHeaderEditor({ } const newEditor = CodeMirror(container, { + value: initialValue.current || '', lineNumbers: true, tabSize: 2, mode: { name: 'javascript', json: true }, diff --git a/packages/graphiql-react/src/editor/hooks.ts b/packages/graphiql-react/src/editor/hooks.ts index 30768a66d3f..1db6ad07fd0 100644 --- a/packages/graphiql-react/src/editor/hooks.ts +++ b/packages/graphiql-react/src/editor/hooks.ts @@ -9,10 +9,8 @@ export function useSynchronizeValue( value: string | undefined, ) { useEffect(() => { - if (editor && typeof value !== 'undefined') { - if (value !== editor.getValue()) { - editor.setValue(value); - } + if (editor && typeof value !== 'undefined' && value !== editor.getValue()) { + editor.setValue(value); } }, [editor, value]); } diff --git a/packages/graphiql-react/src/editor/query-editor.tsx b/packages/graphiql-react/src/editor/query-editor.tsx index 6c462b4f997..750ff4851e2 100644 --- a/packages/graphiql-react/src/editor/query-editor.tsx +++ b/packages/graphiql-react/src/editor/query-editor.tsx @@ -21,6 +21,7 @@ import { import { markdown } from '../markdown'; import { normalizeWhitespace } from './whitespace'; import { CodeMirrorType, CodeMirrorEditor } from './types'; +import { useSchemaWithError } from '../schema'; type OnClickReference = (reference: SchemaReference) => void; @@ -35,7 +36,6 @@ export type UseQueryEditorArgs = { onMergeQuery?: EmptyCallback; onRunQuery?: EmptyCallback; readOnly?: boolean; - schema?: GraphQLSchema | null; validationRules?: ValidationRule[]; value?: string; }; @@ -51,27 +51,29 @@ export function useQueryEditor({ onPrettifyQuery, onRunQuery, readOnly = false, - schema, validationRules, value, }: UseQueryEditorArgs = {}) { - const context = useContext(EditorContext); + const { schema } = useSchemaWithError('hook', 'useQueryEditor'); + const editorContext = useContext(EditorContext); const ref = useRef(null); const codeMirrorRef = useRef(); - if (!context) { + if (!editorContext) { throw new Error( 'Tried to call the `useQueryEditor` hook without the necessary context. Make sure that the `EditorContextProvider` from `@graphiql/react` is rendered higher in the tree.', ); } - const { queryEditor, setQueryEditor } = context; + const { queryEditor, setQueryEditor } = editorContext; const onClickReferenceRef = useRef(); useEffect(() => { onClickReferenceRef.current = onClickReference; }, [onClickReference]); + const initialValue = useRef(value); + useEffect(() => { let isActive = true; @@ -97,6 +99,7 @@ export function useQueryEditor({ } const newEditor = CodeMirror(container, { + value: initialValue.current || '', lineNumbers: true, tabSize: 2, foldGutter: true, diff --git a/packages/graphiql-react/src/editor/response-editor.tsx b/packages/graphiql-react/src/editor/response-editor.tsx index feaa654fa40..13d7b5de620 100644 --- a/packages/graphiql-react/src/editor/response-editor.tsx +++ b/packages/graphiql-react/src/editor/response-editor.tsx @@ -1,3 +1,4 @@ +import { formatError } from '@graphiql/toolkit'; import type { Position, Token } from 'codemirror'; import { ComponentType, useContext, useEffect, useRef } from 'react'; import ReactDOM from 'react-dom'; @@ -7,6 +8,7 @@ import { ImagePreview } from './components'; import { EditorContext } from './context'; import { useResizeEditor, useSynchronizeValue } from './hooks'; import { CodeMirrorEditor } from './types'; +import { useSchemaWithError } from '../schema'; export type ResponseTooltipType = ComponentType<{ pos: Position }>; @@ -21,7 +23,11 @@ export function useResponseEditor({ editorTheme = 'graphiql', value, }: UseResponseEditorArgs = {}) { - const context = useContext(EditorContext); + const { fetchError, validationErrors } = useSchemaWithError( + 'hook', + 'useResponseEditor', + ); + const editorContext = useContext(EditorContext); const ref = useRef(null); const responseTooltipRef = useRef( @@ -31,13 +37,15 @@ export function useResponseEditor({ responseTooltipRef.current = ResponseTooltip; }, [ResponseTooltip]); - if (!context) { + if (!editorContext) { throw new Error( 'Tried to call the `useResponseEditor` hook without the necessary context. Make sure that the `EditorContextProvider` from `@graphiql/react` is rendered higher in the tree.', ); } - const { responseEditor, setResponseEditor } = context; + const { responseEditor, setResponseEditor } = editorContext; + + const initialValue = useRef(value); useEffect(() => { let isActive = true; @@ -95,6 +103,7 @@ export function useResponseEditor({ } const newEditor = CodeMirror(container, { + value: initialValue.current || '', lineWrapping: true, readOnly: true, theme: editorTheme, @@ -119,5 +128,14 @@ export function useResponseEditor({ useResizeEditor(responseEditor, ref); + useEffect(() => { + if (fetchError) { + responseEditor?.setValue(fetchError); + } + if (validationErrors) { + responseEditor?.setValue(formatError(validationErrors)); + } + }, [responseEditor, fetchError, validationErrors]); + return ref; } diff --git a/packages/graphiql-react/src/editor/variable-editor.tsx b/packages/graphiql-react/src/editor/variable-editor.tsx index ee0139cd62e..5b83144e867 100644 --- a/packages/graphiql-react/src/editor/variable-editor.tsx +++ b/packages/graphiql-react/src/editor/variable-editor.tsx @@ -50,6 +50,8 @@ export function useVariableEditor({ const { variableEditor, setVariableEditor } = context; + const initialValue = useRef(value); + useEffect(() => { let isActive = true; @@ -71,7 +73,7 @@ export function useVariableEditor({ } const newEditor = CodeMirror(container, { - value: '', + value: initialValue.current || '', lineNumbers: true, tabSize: 2, mode: 'graphql-variables', diff --git a/packages/graphiql-react/src/explorer.tsx b/packages/graphiql-react/src/explorer.tsx index 810e79d2418..e9815cd079c 100644 --- a/packages/graphiql-react/src/explorer.tsx +++ b/packages/graphiql-react/src/explorer.tsx @@ -9,8 +9,11 @@ import { ReactNode, useCallback, useContext, + useEffect, + useMemo, useState, } from 'react'; +import { useSchemaWithError } from './schema'; export type ExplorerFieldDef = | GraphQLField<{}, {}, {}> @@ -46,6 +49,11 @@ export type ExplorerContextType = { export const ExplorerContext = createContext(null); export function ExplorerContextProvider(props: { children: ReactNode }) { + const { isFetching } = useSchemaWithError( + 'component', + 'ExplorerContextProvider', + ); + const [state, setState] = useState([initialNavStackItem]); const push = useCallback((item: ExplorerNavStackItem) => { @@ -80,9 +88,19 @@ export function ExplorerContextProvider(props: { children: ReactNode }) { }); }, []); + useEffect(() => { + if (isFetching) { + reset(); + } + }, [isFetching, reset]); + + const value = useMemo( + () => ({ explorerNavStack: state, push, pop, reset, showSearch }), + [state, push, pop, reset, showSearch], + ); + return ( - + {props.children} ); diff --git a/packages/graphiql-react/src/index.ts b/packages/graphiql-react/src/index.ts index 1481055f8d3..4a377c77be8 100644 --- a/packages/graphiql-react/src/index.ts +++ b/packages/graphiql-react/src/index.ts @@ -14,6 +14,7 @@ import { useExplorerNavStack, } from './explorer'; import { HistoryContext, HistoryContextProvider } from './history'; +import { SchemaContext, SchemaContextProvider, useSchema } from './schema'; import { StorageContext, StorageContextProvider } from './storage'; import type { @@ -31,6 +32,7 @@ import type { ExplorerNavStackItem, } from './explorer'; import type { HistoryContextType } from './history'; +import type { SchemaContextType } from './schema'; import type { StorageContextType } from './storage'; export { @@ -50,6 +52,10 @@ export { // history HistoryContext, HistoryContextProvider, + // schema + SchemaContext, + SchemaContextProvider, + useSchema, // storage StorageContext, StorageContextProvider, @@ -70,6 +76,8 @@ export type { ExplorerNavStackItem, // history HistoryContextType, + // schema + SchemaContextType, // storage StorageContextType, }; diff --git a/packages/graphiql-react/src/schema/context.tsx b/packages/graphiql-react/src/schema/context.tsx new file mode 100644 index 00000000000..cd56948b6c3 --- /dev/null +++ b/packages/graphiql-react/src/schema/context.tsx @@ -0,0 +1,286 @@ +import { + Fetcher, + FetcherOpts, + fetcherReturnToPromise, + formatError, + formatResult, + isPromise, +} from '@graphiql/toolkit'; +import { + buildClientSchema, + getIntrospectionQuery, + GraphQLError, + GraphQLSchema, + IntrospectionQuery, + validateSchema, +} from 'graphql'; +import { + createContext, + Dispatch, + ReactNode, + SetStateAction, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { EditorContext } from '../editor'; + +/** + * There's a semantic difference between `null` and `undefined`: + * - When `null` is passed explicitly as prop, GraphiQL will run schemaless + * (i.e. it will never attempt to fetch the schema, even when calling the + * `useFetchSchema` hook). + * - When `schema` is `undefined` GraphiQL will attempt to fetch the schema + * when calling `useFetchSchema`. + */ +type MaybeGraphQLSchema = GraphQLSchema | null | undefined; + +export type SchemaContextType = { + fetchError: string | null; + isFetching: boolean; + schema: MaybeGraphQLSchema; + setFetchError: Dispatch>; + setSchema: Dispatch>; + validationErrors: readonly GraphQLError[] | null; +}; + +export const SchemaContext = createContext({ + fetchError: null, + isFetching: false, + schema: null, + setFetchError() {}, + setSchema() {}, + validationErrors: null, +}); + +type SchemaContextProviderProps = { + children: ReactNode; + dangerouslyAssumeSchemaIsValid?: boolean; + fetcher: Fetcher; + initialHeaders?: string; + schema?: GraphQLSchema | null; +} & IntrospectionArgs; + +export function SchemaContextProvider(props: SchemaContextProviderProps) { + const [schema, setSchema] = useState( + props.schema || null, + ); + const [isFetching, setIsFetching] = useState(false); + const [fetchError, setFetchError] = useState(null); + + /** + * Synchronize prop changes with state + */ + useEffect(() => { + setSchema(props.schema); + }, [props.schema]); + + /** + * Keep a ref to the current headers + */ + const headersRef = useRef(parseHeaderString(props.initialHeaders)); + const { headerEditor } = useContext(EditorContext); + useEffect(() => { + if (!headerEditor) { + return; + } + headersRef.current = parseHeaderString(headerEditor.getValue()); + }, [headerEditor]); + + /** + * Get introspection query for settings given via props + */ + const { + introspectionQuery, + introspectionQueryName, + introspectionQuerySansSubscriptions, + } = useIntrospectionQuery({ + inputValueDeprecation: props.inputValueDeprecation, + introspectionQueryName: props.introspectionQueryName, + schemaDescription: props.schemaDescription, + }); + + /** + * Fetch the schema + */ + const { fetcher } = props; + useEffect(() => { + let isActive = true; + + if (!headersRef.current.isValidJSON) { + setFetchError('Introspection failed as headers are invalid.'); + return; + } + + const fetcherOpts: FetcherOpts = headersRef.current.headers + ? { headers: headersRef.current.headers } + : {}; + + const fetch = fetcherReturnToPromise( + fetcher( + { + query: introspectionQuery, + operationName: introspectionQueryName, + }, + fetcherOpts, + ), + ); + + if (!isPromise(fetch)) { + setFetchError('Fetcher did not return a Promise for introspection.'); + return; + } + + setIsFetching(true); + + fetch + .then(result => { + if (typeof result === 'object' && result !== null && 'data' in result) { + return result; + } + + // Try the stock introspection query first, falling back on the + // sans-subscriptions query for services which do not yet support it. + const fetch2 = fetcherReturnToPromise( + fetcher( + { + query: introspectionQuerySansSubscriptions, + operationName: introspectionQueryName, + }, + fetcherOpts, + ), + ); + if (!isPromise(fetch2)) { + throw new Error( + 'Fetcher did not return a Promise for introspection.', + ); + } + return fetch2; + }) + .then(result => { + // Don't continue if the effect has already been cleaned up + if (!isActive) { + return; + } + + if (result && result.data && '__schema' in result.data) { + try { + const newSchema = buildClientSchema( + result.data as IntrospectionQuery, + ); + setSchema(newSchema); + } catch (error) { + setFetchError(formatError(error as Error)); + } + } else { + // handle as if it were an error if the fetcher response is not a string or response.data is not present + const responseString = + typeof result === 'string' ? result : formatResult(result); + setFetchError(responseString); + } + + setIsFetching(false); + }) + .catch(error => { + // Don't continue if the effect has already been cleaned up + if (!isActive) { + return; + } + + setFetchError(formatError(error)); + setIsFetching(false); + }); + + return () => { + isActive = false; + }; + }, [ + fetcher, + introspectionQueryName, + introspectionQuery, + introspectionQuerySansSubscriptions, + ]); + + /** + * Derive validation errors from the schema + */ + const validationErrors = useMemo(() => { + if (!schema || props.dangerouslyAssumeSchemaIsValid) { + return null; + } + const errors = validateSchema(schema); + return errors.length > 0 ? errors : null; + }, [schema, props.dangerouslyAssumeSchemaIsValid]); + + /** + * Memoize context value + */ + const value = useMemo( + () => ({ + fetchError, + isFetching, + schema, + setFetchError, + setSchema, + validationErrors, + }), + [fetchError, isFetching, schema, validationErrors], + ); + + return ( + + {props.children} + + ); +} + +type IntrospectionArgs = { + inputValueDeprecation?: boolean; + introspectionQueryName?: string; + schemaDescription?: boolean; +}; + +function useIntrospectionQuery({ + inputValueDeprecation, + introspectionQueryName, + schemaDescription, +}: IntrospectionArgs) { + return useMemo(() => { + const queryName = introspectionQueryName || 'IntrospectionQuery'; + + let query = getIntrospectionQuery({ + inputValueDeprecation, + schemaDescription, + }); + if (introspectionQueryName) { + query = query.replace('query IntrospectionQuery', `query ${queryName}`); + } + + const querySansSubscriptions = query.replace( + 'subscriptionType { name }', + '', + ); + + return { + introspectionQueryName: queryName, + introspectionQuery: query, + introspectionQuerySansSubscriptions: querySansSubscriptions, + }; + }, [inputValueDeprecation, introspectionQueryName, schemaDescription]); +} + +function parseHeaderString(headersString: string | undefined) { + let headers: Record | null = null; + let isValidJSON = true; + + try { + if (headersString) { + headers = JSON.parse(headersString); + } + } catch (err) { + isValidJSON = false; + } + return { headers, isValidJSON }; +} diff --git a/packages/graphiql-react/src/schema/hooks.ts b/packages/graphiql-react/src/schema/hooks.ts new file mode 100644 index 00000000000..d49ae777fce --- /dev/null +++ b/packages/graphiql-react/src/schema/hooks.ts @@ -0,0 +1,17 @@ +import { useContext } from 'react'; + +import { SchemaContext } from './context'; + +export function useSchemaWithError(type: 'component' | 'hook', name: string) { + const context = useContext(SchemaContext); + if (!context) { + throw new Error( + `Tried to call the \`${name}\` ${type} without the necessary context. Make sure that the \`SchemaContextProvider\` from \`@graphiql/react\` is rendered higher in the tree.`, + ); + } + return context; +} + +export function useSchema() { + return useSchemaWithError('hook', 'useSchema'); +} diff --git a/packages/graphiql-react/src/schema/index.ts b/packages/graphiql-react/src/schema/index.ts new file mode 100644 index 00000000000..cf1ff715b32 --- /dev/null +++ b/packages/graphiql-react/src/schema/index.ts @@ -0,0 +1,8 @@ +import { SchemaContext, SchemaContextProvider } from './context'; +import { useSchema, useSchemaWithError } from './hooks'; + +import type { SchemaContextType } from './context'; + +export { SchemaContext, SchemaContextProvider, useSchema, useSchemaWithError }; + +export type { SchemaContextType }; diff --git a/packages/graphiql/__mocks__/@graphiql/react.ts b/packages/graphiql/__mocks__/@graphiql/react.ts index 1ea5a3c888a..edbd159dd99 100644 --- a/packages/graphiql/__mocks__/@graphiql/react.ts +++ b/packages/graphiql/__mocks__/@graphiql/react.ts @@ -7,12 +7,15 @@ import { HistoryContextProvider, ImagePreview, onHasCompletion, + SchemaContext, + SchemaContextProvider, StorageContext, StorageContextProvider, useExplorerNavStack, useHeaderEditor as _useHeaderEditor, useQueryEditor as _useQueryEditor, useResponseEditor as _useResponseEditor, + useSchema, useVariableEditor as _useVariableEditor, } from '@graphiql/react'; import type { @@ -23,6 +26,7 @@ import type { ExplorerNavStackItem, HistoryContextType, ResponseTooltipType, + SchemaContextType, StorageContextType, UseHeaderEditorArgs, UseResponseEditorArgs, @@ -40,9 +44,12 @@ export { HistoryContextProvider, ImagePreview, onHasCompletion, + SchemaContext, + SchemaContextProvider, StorageContext, StorageContextProvider, useExplorerNavStack, + useSchema, }; export type { @@ -53,6 +60,7 @@ export type { ExplorerNavStackItem, HistoryContextType, ResponseTooltipType, + SchemaContextType, StorageContextType, UseHeaderEditorArgs, UseResponseEditorArgs, diff --git a/packages/graphiql/src/components/DocExplorer.tsx b/packages/graphiql/src/components/DocExplorer.tsx index 6c5322f96c0..cbc7579bca6 100644 --- a/packages/graphiql/src/components/DocExplorer.tsx +++ b/packages/graphiql/src/components/DocExplorer.tsx @@ -5,9 +5,13 @@ * LICENSE file in the root directory of this source tree. */ -import React, { memo, ReactNode } from 'react'; -import { GraphQLSchema, isType, GraphQLNamedType, GraphQLError } from 'graphql'; -import { ExplorerFieldDef, useExplorerNavStack } from '@graphiql/react'; +import React, { ReactNode } from 'react'; +import { isType, GraphQLNamedType } from 'graphql'; +import { + ExplorerFieldDef, + useExplorerNavStack, + useSchema, +} from '@graphiql/react'; import FieldDoc from './DocExplorer/FieldDoc'; import SchemaDoc from './DocExplorer/SchemaDoc'; @@ -15,129 +19,120 @@ import SearchBox from './DocExplorer/SearchBox'; import SearchResults from './DocExplorer/SearchResults'; import TypeDoc from './DocExplorer/TypeDoc'; -type DocExplorerProps = { - schema?: GraphQLSchema | null; - schemaErrors?: readonly GraphQLError[]; - children?: ReactNode; -}; - /** * DocExplorer * * Shows documentations for GraphQL definitions from the schema. * - * Props: - * - * - schema: A required GraphQLSchema instance that provides GraphQL document - * definitions. - * * Children: * * - Any provided children will be positioned in the right-hand-side of the * top bar. Typically this will be a "close" button for temporary explorer. * */ -export const DocExplorer = memo( - function DocExplorer({ children, schema, schemaErrors }: DocExplorerProps) { - const explorerContext = useExplorerNavStack(); - if (!explorerContext) { - throw new Error( - 'Tried to render the `DocExplorer` component without the necessary context. Make sure that the `ExplorerContextProvider` from `@graphiql/react` is rendered higher in the tree.', - ); - } +export function DocExplorer(props: { children?: ReactNode }) { + const { fetchError, isFetching, schema, validationErrors } = useSchema(); + const explorerContext = useExplorerNavStack(); + if (!explorerContext) { + throw new Error( + 'Tried to render the `DocExplorer` component without the necessary context. Make sure that the `ExplorerContextProvider` from `@graphiql/react` is rendered higher in the tree.', + ); + } - const { explorerNavStack, pop, push, showSearch } = explorerContext; - const navItem = explorerNavStack[explorerNavStack.length - 1]; + const { explorerNavStack, pop, push, showSearch } = explorerContext; + const navItem = explorerNavStack[explorerNavStack.length - 1]; - function handleClickType(type: GraphQLNamedType) { - push({ name: type.name, def: type }); - } + function handleClickType(type: GraphQLNamedType) { + push({ name: type.name, def: type }); + } - function handleClickField(field: ExplorerFieldDef) { - push({ name: field.name, def: field }); - } + function handleClickField(field: ExplorerFieldDef) { + push({ name: field.name, def: field }); + } - let content: ReactNode; - if (schemaErrors) { - content =
Error fetching schema
; - } else if (schema === undefined) { - // Schema is undefined when it is being loaded via introspection. - content = ( -
-
-
- ); - } else if (!schema) { - // Schema is null when it explicitly does not exist, typically due to - // an error during introspection. - content =
No Schema Available
; - } else if (navItem.search) { - content = ( - - ); - } else if (explorerNavStack.length === 1) { - content = ; - } else if (isType(navItem.def)) { - content = ( - - ); - } else { - content = ; - } + let content: ReactNode; + if (fetchError) { + content =
Error fetching schema
; + } else if (validationErrors) { + content = ( +
+ Schema is invalid: {validationErrors[0].message} +
+ ); + } else if (isFetching) { + // Schema is undefined when it is being loaded via introspection. + content = ( +
+
+
+ ); + } else if (!schema) { + // Schema is null when it explicitly does not exist, typically due to + // an error during introspection. + content =
No Schema Available
; + } else if (navItem.search) { + content = ( + + ); + } else if (explorerNavStack.length === 1) { + content = ; + } else if (isType(navItem.def)) { + content = ( + + ); + } else { + content = ; + } - const shouldSearchBoxAppear = - explorerNavStack.length === 1 || - (isType(navItem.def) && 'getFields' in navItem.def); + const shouldSearchBoxAppear = + explorerNavStack.length === 1 || + (isType(navItem.def) && 'getFields' in navItem.def); - let prevName; - if (explorerNavStack.length > 1) { - prevName = explorerNavStack[explorerNavStack.length - 2].name; - } + let prevName; + if (explorerNavStack.length > 1) { + prevName = explorerNavStack[explorerNavStack.length - 2].name; + } - return ( -
-
- {prevName && ( - - )} -
- {navItem.title || navItem.name} -
-
{children}
+ return ( +
+
+ {prevName && ( + + )} +
+ {navItem.title || navItem.name}
-
- {shouldSearchBoxAppear && ( - - )} - {content} -
-
- ); - }, - (prevProps, nextProps) => - prevProps.schema === nextProps.schema && - prevProps.schemaErrors === nextProps.schemaErrors, -); +
{props.children}
+
+
+ {shouldSearchBoxAppear && ( + + )} + {content} +
+
+ ); +} diff --git a/packages/graphiql/src/components/GraphiQL.tsx b/packages/graphiql/src/components/GraphiQL.tsx index fe5325c4bd1..70713a49600 100644 --- a/packages/graphiql/src/components/GraphiQL.tsx +++ b/packages/graphiql/src/components/GraphiQL.tsx @@ -12,7 +12,6 @@ import React, { ReactNode, } from 'react'; import { - buildClientSchema, GraphQLSchema, parse, print, @@ -21,9 +20,6 @@ import { ValidationRule, FragmentDefinitionNode, DocumentNode, - GraphQLError, - IntrospectionQuery, - getIntrospectionQuery, GraphQLNamedType, } from 'graphql'; import copyToClipboard from 'copy-to-clipboard'; @@ -41,6 +37,8 @@ import { ExplorerContextProvider, HistoryContext, HistoryContextProvider, + SchemaContext, + SchemaContextProvider, StorageContext, StorageContextProvider, } from '@graphiql/react'; @@ -50,6 +48,7 @@ import type { ExplorerFieldDef, HistoryContextType, ResponseTooltipType, + SchemaContextType, StorageContextType, } from '@graphiql/react'; @@ -66,23 +65,18 @@ import { QueryHistory } from './QueryHistory'; import debounce from '../utility/debounce'; import find from '../utility/find'; import { getLeft, getTop } from '../utility/elementPosition'; -import { introspectionQueryName } from '../utility/introspectionQueries'; import setValue from 'set-value'; import { - fetcherReturnToPromise, - fillLeafs, formatError, formatResult, getSelectedOperationName, isAsyncIterable, isObservable, - isPromise, mergeAst, } from '@graphiql/toolkit'; import type { Fetcher, - FetcherOpts, FetcherResult, FetcherResultPayload, GetDefaultFieldNamesFn, @@ -91,7 +85,6 @@ import type { Unsubscribable, } from '@graphiql/toolkit'; -import { validateSchema } from 'graphql'; import { Tab, TabAddButton, Tabs } from './Tabs'; import { fuzzyExtractOperationTitle } from '../utility/fuzzyExtractOperationTitle'; import { idFromTabContents } from '../utility/id-from-tab-contents'; @@ -340,7 +333,6 @@ export type GraphiQLState = { headerEditorActive: boolean; headerEditorEnabled: boolean; shouldPersistHeaders: boolean; - schemaErrors?: readonly GraphQLError[]; docExplorerWidth: number; isWaitingForResponse: boolean; subscription?: Unsubscribable | null; @@ -377,33 +369,53 @@ export function GraphiQL(props: GraphiQLProps) { {storageContext => ( - - - - - {editorContext => ( - - {historyContext => ( - - {explorerContext => ( - - )} - - )} - - )} - - - - + + + + + + {schemaContext => ( + + {editorContext => ( + + {historyContext => ( + + {explorerContext => ( + + )} + + )} + + )} + + )} + + + + + )} @@ -452,9 +464,10 @@ type GraphiQLWithContextProps = Omit< GraphiQLProps, 'maxHistoryLength' | 'onToggleHistory' | 'storage' > & { - editorContext: EditorContextType | null; + editorContext: EditorContextType; explorerContext: ExplorerContextType | null; historyContext: HistoryContextType | null; + schemaContext: SchemaContextType; storageContext: StorageContextType | null; }; @@ -464,9 +477,6 @@ class GraphiQLWithContext extends React.Component< > { // Ensure only the last executed editor query is rendered. _editorQueryID = 0; - _introspectionQuery: string; - _introspectionQueryName: string; - _introspectionQuerySansSubscriptions: string; // Ensure the component is mounted to execute async setState componentIsMounted: boolean; @@ -535,36 +545,6 @@ class GraphiQLWithContext extends React.Component< const headerEditorEnabled = props.headerEditorEnabled ?? true; const shouldPersistHeaders = props.shouldPersistHeaders ?? false; - let schema = props.schema; - let response = props.response; - let schemaErrors: readonly GraphQLError[] | undefined = undefined; - if (schema && !this.props.dangerouslyAssumeSchemaIsValid) { - const validationErrors = validateSchema(schema); - if (validationErrors && validationErrors.length > 0) { - // This is equivalent to handleSchemaErrors, but it's too early - // to call setState. - response = formatError(validationErrors); - schema = undefined; - schemaErrors = validationErrors; - } - } - - this._introspectionQuery = getIntrospectionQuery({ - schemaDescription: props.schemaDescription ?? undefined, - inputValueDeprecation: props.inputValueDeprecation ?? undefined, - }); - - this._introspectionQueryName = - props.introspectionQueryName ?? introspectionQueryName; - - // Some GraphQL services do not support subscriptions and fail an introspection - // query which includes the `subscriptionType` field as the stock introspection - // query does. This backup query removes that field. - this._introspectionQuerySansSubscriptions = this._introspectionQuery.replace( - 'subscriptionType { name }', - '', - ); - const initialTabHash = idFromTabContents({ query, variables: variables ?? undefined, @@ -634,12 +614,11 @@ class GraphiQLWithContext extends React.Component< // Initialize state this.state = { tabs: tabsState, - schema, + schema: props.schema, query: activeTab?.query, operationName: activeTab?.operationName, - response: activeTab?.response ?? response, + response: activeTab?.response, docExplorerOpen, - schemaErrors, editorFlex: Number(this.props.storageContext?.get('editorFlex')) || 1, secondaryEditorOpen, secondaryEditorHeight: @@ -669,12 +648,6 @@ class GraphiQLWithContext extends React.Component< // Allow async state changes this.componentIsMounted = true; - // Only fetch schema via introspection if a schema has not been - // provided, including if `null` was provided. - if (this.state.schema === undefined) { - this.fetchSchema(); - } - if (typeof window !== 'undefined') { window.g = this; } @@ -711,14 +684,6 @@ class GraphiQLWithContext extends React.Component< nextQuery !== this.state.query || nextOperationName !== this.state.operationName) ) { - if (!this.props.dangerouslyAssumeSchemaIsValid) { - const validationErrors = validateSchema(nextSchema); - if (validationErrors && validationErrors.length > 0) { - this.handleSchemaErrors(validationErrors); - nextSchema = undefined; - } - } - const updatedQueryAttributes = this._updateQueryFacts( nextQuery, nextOperationName, @@ -744,20 +709,12 @@ class GraphiQLWithContext extends React.Component< if (nextOperationName !== undefined) { this.props.storageContext?.set('operationName', nextOperationName); } - this.setState( - { - schema: nextSchema, - query: nextQuery, - operationName: nextOperationName, - response: nextResponse, - }, - () => { - if (this.state.schema === undefined) { - this.props.explorerContext?.reset(); - this.fetchSchema(); - } - }, - ); + this.setState({ + schema: nextSchema, + query: nextQuery, + operationName: nextOperationName, + response: nextResponse, + }); } // Use it when the state change is async @@ -949,7 +906,6 @@ class GraphiQLWithContext extends React.Component< onMouseDown={this.handleResizeStart}>
- +