diff --git a/.changeset/famous-eyes-watch.md b/.changeset/famous-eyes-watch.md new file mode 100644 index 00000000000..02f9c647261 --- /dev/null +++ b/.changeset/famous-eyes-watch.md @@ -0,0 +1,7 @@ +--- +'@graphiql/plugin-doc-explorer': minor +'graphiql': patch +--- + +feat(@graphiql/plugin-doc-explorer): migrate React context to zustand, replace `useExplorerContext` with `useDocExplorer` and `useDocExplorerActions` hooks + diff --git a/packages/graphiql-plugin-doc-explorer/package.json b/packages/graphiql-plugin-doc-explorer/package.json index fb353cd62d2..55ecd668dfe 100644 --- a/packages/graphiql-plugin-doc-explorer/package.json +++ b/packages/graphiql-plugin-doc-explorer/package.json @@ -43,7 +43,8 @@ "dependencies": { "react-compiler-runtime": "19.1.0-rc.1", "@graphiql/react": "^0.32.2", - "@headlessui/react": "^2.2" + "@headlessui/react": "^2.2", + "zustand": "^5" }, "devDependencies": { "@vitejs/plugin-react": "^4.4.1", diff --git a/packages/graphiql-plugin-doc-explorer/src/components/__tests__/doc-explorer.spec.tsx b/packages/graphiql-plugin-doc-explorer/src/components/__tests__/doc-explorer.spec.tsx index 490fb015a6b..9024c50091e 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/__tests__/doc-explorer.spec.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/__tests__/doc-explorer.spec.tsx @@ -1,8 +1,12 @@ import { render } from '@testing-library/react'; import { GraphQLInt, GraphQLObjectType, GraphQLSchema } from 'graphql'; -import { FC, useContext, useEffect } from 'react'; +import { FC, useEffect } from 'react'; import { SchemaContext, SchemaContextType } from '@graphiql/react'; -import { ExplorerContext, ExplorerContextProvider } from '../../context'; +import { + DocExplorerContextProvider, + useDocExplorer, + useDocExplorerActions, +} from '../../context'; import { DocExplorer } from '../doc-explorer'; function makeSchema(fieldName = 'field') { @@ -46,9 +50,9 @@ const withErrorSchemaContext: SchemaContextType = { const DocExplorerWithContext: FC = () => { return ( - + - + ); }; @@ -117,14 +121,14 @@ describe('DocExplorer', () => { // A hacky component to set the initial explorer nav stack const SetInitialStack: React.FC = () => { - const context = useContext(ExplorerContext)!; + const explorerNavStack = useDocExplorer(); + const { push } = useDocExplorerActions(); useEffect(() => { - if (context.explorerNavStack.length === 1) { - context.push({ name: 'Query', def: Query }); - // eslint-disable-next-line unicorn/no-array-push-push -- false positive, push here accept only 1 argument - context.push({ name: 'field', def: field }); + if (explorerNavStack.length === 1) { + push({ name: 'Query', def: Query }); + push({ name: 'field', def: field }); } - }, [context]); + }, [explorerNavStack.length, push]); return null; }; @@ -136,9 +140,9 @@ describe('DocExplorer', () => { schema: initialSchema, }} > - + - + , ); @@ -150,9 +154,9 @@ describe('DocExplorer', () => { schema: initialSchema, }} > - + - + , ); @@ -167,9 +171,9 @@ describe('DocExplorer', () => { schema: makeSchema(), // <<< New, but equivalent, schema }} > - + - + , ); const [title2] = container.querySelectorAll('.graphiql-doc-explorer-title'); @@ -184,14 +188,14 @@ describe('DocExplorer', () => { // A hacky component to set the initial explorer nav stack // eslint-disable-next-line sonarjs/no-identical-functions -- todo: could be refactored const SetInitialStack: React.FC = () => { - const context = useContext(ExplorerContext)!; + const explorerNavStack = useDocExplorer(); + const { push } = useDocExplorerActions(); useEffect(() => { - if (context.explorerNavStack.length === 1) { - context.push({ name: 'Query', def: Query }); - // eslint-disable-next-line unicorn/no-array-push-push -- false positive, push here accept only 1 argument - context.push({ name: 'field', def: field }); + if (explorerNavStack.length === 1) { + push({ name: 'Query', def: Query }); + push({ name: 'field', def: field }); } - }, [context]); + }, [explorerNavStack.length, push]); return null; }; @@ -203,9 +207,9 @@ describe('DocExplorer', () => { schema: initialSchema, }} > - + - + , ); @@ -217,9 +221,9 @@ describe('DocExplorer', () => { schema: initialSchema, }} > - + - + , ); @@ -231,16 +235,16 @@ describe('DocExplorer', () => { - + - + , ); const [title2] = container.querySelectorAll('.graphiql-doc-explorer-title'); - // Because `Query.field` doesn't exist any more, the top-most item we can render is `Query` + // Because `Query.field` doesn't exist anymore, the top-most item we can render is `Query` expect(title2.textContent).toEqual('Query'); }); }); diff --git a/packages/graphiql-plugin-doc-explorer/src/components/__tests__/field-documentation.spec.tsx b/packages/graphiql-plugin-doc-explorer/src/components/__tests__/field-documentation.spec.tsx index 43e941367ed..5b2489baa0b 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/__tests__/field-documentation.spec.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/__tests__/field-documentation.spec.tsx @@ -1,9 +1,9 @@ import { FC } from 'react'; import { fireEvent, render } from '@testing-library/react'; import { GraphQLString, GraphQLObjectType, Kind } from 'graphql'; -import { ExplorerContext, ExplorerFieldDef } from '../../context'; +import { DocExplorerContext, DocExplorerFieldDef } from '../../context'; import { FieldDocumentation } from '../field-documentation'; -import { mockExplorerContextValue } from './test-utils'; +import { useMockDocExplorerContextValue } from './test-utils'; const exampleObject = new GraphQLObjectType({ name: 'Query', @@ -54,17 +54,17 @@ const exampleObject = new GraphQLObjectType({ }); const FieldDocumentationWithContext: FC<{ - field: ExplorerFieldDef; + field: DocExplorerFieldDef; }> = props => { return ( - - + ); }; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/__tests__/test-utils.ts b/packages/graphiql-plugin-doc-explorer/src/components/__tests__/test-utils.ts index 7aa29f850b2..a88a767e9d8 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/__tests__/test-utils.ts +++ b/packages/graphiql-plugin-doc-explorer/src/components/__tests__/test-utils.ts @@ -1,18 +1,12 @@ 'use no memo'; - +import { useRef } from 'react'; import { GraphQLNamedType, GraphQLType } from 'graphql'; +import { createDocExplorerStore, DocExplorerNavStackItem } from '../../context'; -import { ExplorerContextType, ExplorerNavStackItem } from '../../context'; - -export function mockExplorerContextValue( - navStackItem: ExplorerNavStackItem, -): ExplorerContextType { - return { - explorerNavStack: [navStackItem], - pop() {}, - push() {}, - reset() {}, - }; +export function useMockDocExplorerContextValue( + navStackItem: DocExplorerNavStackItem, +) { + return useRef(createDocExplorerStore(navStackItem)); } export function unwrapType(type: GraphQLType): GraphQLNamedType { diff --git a/packages/graphiql-plugin-doc-explorer/src/components/__tests__/type-documentation.spec.tsx b/packages/graphiql-plugin-doc-explorer/src/components/__tests__/type-documentation.spec.tsx index 72127b9a4ff..eca554d476b 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/__tests__/type-documentation.spec.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/__tests__/type-documentation.spec.tsx @@ -11,9 +11,9 @@ import { GraphQLUnionType, } from 'graphql'; import { SchemaContext } from '@graphiql/react'; -import { ExplorerContext } from '../../context'; +import { DocExplorerContext } from '../../context'; import { TypeDocumentation } from '../type-documentation'; -import { mockExplorerContextValue, unwrapType } from './test-utils'; +import { useMockDocExplorerContextValue, unwrapType } from './test-utils'; const TypeDocumentationWithContext: FC<{ type: GraphQLNamedType }> = props => { return ( @@ -28,14 +28,14 @@ const TypeDocumentationWithContext: FC<{ type: GraphQLNamedType }> = props => { setSchemaReference: null!, }} > - - + ); }; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/__tests__/type-link.spec.tsx b/packages/graphiql-plugin-doc-explorer/src/components/__tests__/type-link.spec.tsx index f705d985a10..ca757648614 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/__tests__/type-link.spec.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/__tests__/type-link.spec.tsx @@ -1,32 +1,34 @@ +import { FC } from 'react'; import { fireEvent, render } from '@testing-library/react'; import { GraphQLNonNull, GraphQLList, GraphQLString } from 'graphql'; -import { ExplorerContext } from '../../context'; +import { DocExplorerContext, useDocExplorer } from '../../context'; import { TypeLink } from '../type-link'; -import { mockExplorerContextValue, unwrapType } from './test-utils'; +import { useMockDocExplorerContextValue, unwrapType } from './test-utils'; const nonNullType = new GraphQLNonNull(GraphQLString); const listType = new GraphQLList(GraphQLString); +const TypeLinkConsumer: FC = () => { + const explorerNavStack = useDocExplorer(); + return ( + + {JSON.stringify(explorerNavStack[explorerNavStack.length + 1])} + + ); +}; + const TypeLinkWithContext: typeof TypeLink = props => { return ( - {/* Print the top of the current nav stack for test assertions */} - - {context => ( - - {JSON.stringify( - context!.explorerNavStack[context!.explorerNavStack.length + 1], - )} - - )} - - + + ); }; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/default-value.tsx b/packages/graphiql-plugin-doc-explorer/src/components/default-value.tsx index 34725653721..53f544bd2b0 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/default-value.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/default-value.tsx @@ -1,6 +1,6 @@ import { FC } from 'react'; import { astFromValue, print, ValueNode } from 'graphql'; -import { ExplorerFieldDef } from '../context'; +import { DocExplorerFieldDef } from '../context'; import './default-value.css'; const printDefault = (ast?: ValueNode | null): string => { @@ -14,7 +14,7 @@ type DefaultValueProps = { /** * The field or argument for which to render the default value. */ - field: ExplorerFieldDef; + field: DocExplorerFieldDef; }; export const DefaultValue: FC = ({ field }) => { diff --git a/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.tsx b/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.tsx index 1c8c4c3053b..ced3acaff5c 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.tsx @@ -1,7 +1,7 @@ import { isType } from 'graphql'; import { FC, ReactNode } from 'react'; import { ChevronLeftIcon, Spinner, useSchemaContext } from '@graphiql/react'; -import { useExplorerContext } from '../context'; +import { useDocExplorer, useDocExplorerActions } from '../context'; import { FieldDocumentation } from './field-documentation'; import { SchemaDocumentation } from './schema-documentation'; import { Search } from './search'; @@ -12,11 +12,8 @@ export const DocExplorer: FC = () => { const { fetchError, isFetching, schema, validationErrors } = useSchemaContext( { nonNull: true, caller: DocExplorer }, ); - const { explorerNavStack, pop } = useExplorerContext({ - nonNull: true, - caller: DocExplorer, - }); - + const explorerNavStack = useDocExplorer(); + const { pop } = useDocExplorerActions(); const navItem = explorerNavStack.at(-1)!; let content: ReactNode = null; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/field-documentation.tsx b/packages/graphiql-plugin-doc-explorer/src/components/field-documentation.tsx index 8cf515a4804..c03d3b02a90 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/field-documentation.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/field-documentation.tsx @@ -1,7 +1,7 @@ import { GraphQLArgument } from 'graphql'; import { FC, useState } from 'react'; import { Button, MarkdownContent } from '@graphiql/react'; -import { ExplorerFieldDef } from '../context'; +import { DocExplorerFieldDef } from '../context'; import { Argument } from './argument'; import { DeprecationReason } from './deprecation-reason'; import { Directive } from './directive'; @@ -12,7 +12,7 @@ type FieldDocumentationProps = { /** * The field or argument that should be rendered. */ - field: ExplorerFieldDef; + field: DocExplorerFieldDef; }; export const FieldDocumentation: FC = ({ field }) => { @@ -35,7 +35,7 @@ export const FieldDocumentation: FC = ({ field }) => { ); }; -const Arguments: FC<{ field: ExplorerFieldDef }> = ({ field }) => { +const Arguments: FC<{ field: DocExplorerFieldDef }> = ({ field }) => { const [showDeprecated, setShowDeprecated] = useState(false); const handleShowDeprecated = () => { setShowDeprecated(true); @@ -81,7 +81,7 @@ const Arguments: FC<{ field: ExplorerFieldDef }> = ({ field }) => { ); }; -const Directives: FC<{ field: ExplorerFieldDef }> = ({ field }) => { +const Directives: FC<{ field: DocExplorerFieldDef }> = ({ field }) => { const directives = field.astNode?.directives; if (!directives?.length) { return null; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/field-link.tsx b/packages/graphiql-plugin-doc-explorer/src/components/field-link.tsx index 6079b6c2eed..2cc871fe11b 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/field-link.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/field-link.tsx @@ -1,16 +1,16 @@ import { FC } from 'react'; -import { ExplorerFieldDef, useExplorerContext } from '../context'; +import { DocExplorerFieldDef, useDocExplorerActions } from '../context'; import './field-link.css'; type FieldLinkProps = { /** * The field or argument that should be linked to. */ - field: ExplorerFieldDef; + field: DocExplorerFieldDef; }; export const FieldLink: FC = ({ field }) => { - const { push } = useExplorerContext({ nonNull: true }); + const { push } = useDocExplorerActions(); return ( { - const { explorerNavStack, push } = useExplorerContext({ - nonNull: true, - caller: Search, - }); + const explorerNavStack = useDocExplorer(); + const { push } = useDocExplorerActions(); const inputRef = useRef(null!); const getSearchResults = useSearchResults(); @@ -164,10 +160,7 @@ type FieldMatch = { const _useSearchResults = useSearchResults; export function useSearchResults(caller?: Function) { - const { explorerNavStack } = useExplorerContext({ - nonNull: true, - caller: caller || _useSearchResults, - }); + const explorerNavStack = useDocExplorer(); const { schema } = useSchemaContext({ nonNull: true, caller: caller || _useSearchResults, diff --git a/packages/graphiql-plugin-doc-explorer/src/components/type-documentation.tsx b/packages/graphiql-plugin-doc-explorer/src/components/type-documentation.tsx index 10a7ff7bf39..0d43acead4e 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/type-documentation.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/type-documentation.tsx @@ -10,7 +10,7 @@ import { isObjectType, } from 'graphql'; import { useSchemaContext, Button, MarkdownContent } from '@graphiql/react'; -import { ExplorerFieldDef } from '../context'; +import { DocExplorerFieldDef } from '../context'; import { Argument } from './argument'; import { DefaultValue } from './default-value'; import { DeprecationReason } from './deprecation-reason'; @@ -72,8 +72,8 @@ const Fields: FC<{ type: GraphQLNamedType }> = ({ type }) => { const fieldMap = type.getFields(); - const fields: ExplorerFieldDef[] = []; - const deprecatedFields: ExplorerFieldDef[] = []; + const fields: DocExplorerFieldDef[] = []; + const deprecatedFields: DocExplorerFieldDef[] = []; for (const field of Object.keys(fieldMap).map(name => fieldMap[name])) { if (field.deprecationReason) { @@ -109,7 +109,7 @@ const Fields: FC<{ type: GraphQLNamedType }> = ({ type }) => { ); }; -const Field: FC<{ field: ExplorerFieldDef }> = ({ field }) => { +const Field: FC<{ field: DocExplorerFieldDef }> = ({ field }) => { const args = 'args' in field ? field.args.filter(arg => !arg.deprecationReason) : []; return ( diff --git a/packages/graphiql-plugin-doc-explorer/src/components/type-link.tsx b/packages/graphiql-plugin-doc-explorer/src/components/type-link.tsx index b66bf0531e0..a4acdb99741 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/type-link.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/type-link.tsx @@ -1,6 +1,6 @@ import { FC } from 'react'; import { GraphQLType } from 'graphql'; -import { useExplorerContext } from '../context'; +import { useDocExplorerActions } from '../context'; import { renderType } from './utils'; import './type-link.css'; @@ -12,7 +12,7 @@ type TypeLinkProps = { }; export const TypeLink: FC = ({ type }) => { - const { push } = useExplorerContext({ nonNull: true, caller: TypeLink }); + const { push } = useDocExplorerActions(); if (!type) { return null; diff --git a/packages/graphiql-plugin-doc-explorer/src/context.tsx b/packages/graphiql-plugin-doc-explorer/src/context.tsx index e9b83510528..022181c344b 100644 --- a/packages/graphiql-plugin-doc-explorer/src/context.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/context.tsx @@ -3,6 +3,7 @@ import type { GraphQLField, GraphQLInputField, GraphQLNamedType, + GraphQLSchema, } from 'graphql'; import { isEnumType, @@ -13,19 +14,24 @@ import { isScalarType, isUnionType, } from 'graphql'; -import { FC, ReactNode, useEffect, useState } from 'react'; import { - useSchemaContext, - createContextHook, - createNullableContext, -} from '@graphiql/react'; + createContext, + FC, + ReactNode, + RefObject, + useContext, + useEffect, + useRef, +} from 'react'; +import { SchemaContextType, useSchemaContext } from '@graphiql/react'; +import { createStore, StoreApi, useStore } from 'zustand'; -export type ExplorerFieldDef = +export type DocExplorerFieldDef = | GraphQLField | GraphQLInputField | GraphQLArgument; -export type ExplorerNavStackItem = { +export type DocExplorerNavStackItem = { /** * The name of the item. */ @@ -34,208 +40,259 @@ export type ExplorerNavStackItem = { * The definition object of the item, this can be a named type, a field, an * input field or an argument. */ - def?: GraphQLNamedType | ExplorerFieldDef; + def?: GraphQLNamedType | DocExplorerFieldDef; }; // There's always at least one item in the nav stack -export type ExplorerNavStack = [ - ExplorerNavStackItem, - ...ExplorerNavStackItem[], +export type DocExplorerNavStack = [ + DocExplorerNavStackItem, + ...DocExplorerNavStackItem[], ]; -const initialNavStackItem: ExplorerNavStackItem = { name: 'Docs' }; - -export type ExplorerContextType = { +export type DocExplorerContextType = { /** * A stack of navigation items. The last item in the list is the current one. * This list always contains at least one item. */ - explorerNavStack: ExplorerNavStack; - /** - * Push an item to the navigation stack. - * @param item The item that should be pushed to the stack. - */ - push(item: ExplorerNavStackItem): void; - /** - * Pop the last item from the navigation stack. - */ - pop(): void; - /** - * Reset the navigation stack to its initial state, this will remove all but - * the initial stack item. - */ - reset(): void; + explorerNavStack: DocExplorerNavStack; + actions: { + /** + * Push an item to the navigation stack. + * @param item The item that should be pushed to the stack. + */ + push(item: DocExplorerNavStackItem): void; + /** + * Pop the last item from the navigation stack. + */ + pop(): void; + /** + * Reset the navigation stack to its initial state, this will remove all but + * the initial stack item. + */ + reset(): void; + resolveSchemaReferenceToNavItem( + schemaReference: SchemaContextType['schemaReference'], + ): void; + /** + * Replace the nav stack with an updated version using the new schema. + */ + rebuildNavStackWithSchema(schema: GraphQLSchema): void; + }; }; -export const ExplorerContext = - createNullableContext('ExplorerContext'); +export function createDocExplorerStore( + initialNavStackItem: DocExplorerNavStackItem = { name: 'Docs' }, +) { + return createStore((set, get) => ({ + explorerNavStack: [initialNavStackItem], + actions: { + push(item) { + set(state => { + const curr = state.explorerNavStack; + const lastItem = curr.at(-1)!; + const explorerNavStack: DocExplorerNavStack = + // Avoid pushing duplicate items + lastItem.def === item.def ? curr : [...curr, item]; + + return { explorerNavStack }; + }); + }, + pop() { + set(state => { + const curr = state.explorerNavStack; + + const explorerNavStack = + curr.length > 1 ? (curr.slice(0, -1) as DocExplorerNavStack) : curr; + + return { explorerNavStack }; + }); + }, + reset() { + set(state => { + const curr = state.explorerNavStack; + const explorerNavStack: DocExplorerNavStack = + curr.length === 1 ? curr : [initialNavStackItem]; + return { explorerNavStack }; + }); + }, + resolveSchemaReferenceToNavItem(schemaReference) { + if (!schemaReference) { + return; + } + const { push } = get().actions; + switch (schemaReference.kind) { + case 'Type': { + push({ + name: schemaReference.type.name, + def: schemaReference.type, + }); + break; + } + case 'Field': { + push({ + name: schemaReference.field.name, + def: schemaReference.field, + }); + break; + } + case 'Argument': { + if (schemaReference.field) { + push({ + name: schemaReference.field.name, + def: schemaReference.field, + }); + } + break; + } + case 'EnumValue': { + if (schemaReference.type) { + push({ + name: schemaReference.type.name, + def: schemaReference.type, + }); + } + break; + } + } + }, + rebuildNavStackWithSchema(schema: GraphQLSchema) { + set(state => { + const oldNavStack = state.explorerNavStack; + if (oldNavStack.length === 1) { + return oldNavStack; + } + const newNavStack: DocExplorerNavStack = [initialNavStackItem]; + let lastEntity: + | GraphQLNamedType + | GraphQLField + | null = null; + for (const item of oldNavStack) { + if (item === initialNavStackItem) { + // No need to copy the initial item + continue; + } + if (item.def) { + // If item.def isn't a named type, it must be a field, inputField, or argument + if (isNamedType(item.def)) { + // The type needs to be replaced with the new schema type of the same name + const newType = schema.getType(item.def.name); + if (newType) { + newNavStack.push({ + name: item.name, + def: newType, + }); + lastEntity = newType; + } else { + // This type no longer exists; the stack cannot be built beyond here + break; + } + } else if (lastEntity === null) { + // We can't have a sub-entity if we have no entity; stop rebuilding the nav stack + break; + } else if ( + isObjectType(lastEntity) || + isInputObjectType(lastEntity) + ) { + // item.def must be a Field / input field; replace with the new field of the same name + const field = lastEntity.getFields()[item.name]; + if (field) { + newNavStack.push({ + name: item.name, + def: field, + }); + } else { + // This field no longer exists; the stack cannot be built beyond here + break; + } + } else if ( + isScalarType(lastEntity) || + isEnumType(lastEntity) || + isInterfaceType(lastEntity) || + isUnionType(lastEntity) + ) { + // These don't (currently) have non-type sub-entries; something has gone wrong. + // Handle gracefully by discontinuing rebuilding the stack. + break; + } else { + // lastEntity must be a field (because it's not a named type) + const field: GraphQLField = lastEntity; + // Thus item.def must be an argument, so find the same named argument in the new schema + if (field.args.some(a => a.name === item.name)) { + newNavStack.push({ + name: item.name, + def: field, + }); + } else { + // This argument no longer exists; the stack cannot be built beyond here + break; + } + } + } else { + lastEntity = null; + newNavStack.push(item); + } + } + return { explorerNavStack: newNavStack }; + }); + }, + }, + })); +} + +export const DocExplorerContext = createContext +> | null>(null); -export const ExplorerContextProvider: FC<{ +export const DocExplorerContextProvider: FC<{ children: ReactNode; }> = props => { const { schema, validationErrors, schemaReference } = useSchemaContext({ nonNull: true, - caller: ExplorerContextProvider, + caller: DocExplorerContextProvider, }); - const [navStack, setNavStack] = useState([ - initialNavStackItem, - ]); - - const push = // eslint-disable-line react-hooks/exhaustive-deps -- false positive, variable is optimized by react-compiler, no need to wrap with useCallback - (item: ExplorerNavStackItem) => { - setNavStack(currentState => { - const lastItem = currentState.at(-1)!; - return lastItem.def === item.def - ? // Avoid pushing duplicate items - currentState - : [...currentState, item]; - }); - }; - - const pop = () => { - setNavStack(currentState => - currentState.length > 1 - ? (currentState.slice(0, -1) as ExplorerNavStack) - : currentState, - ); - }; + const storeRef = useRef>(null!); - const reset = () => { - setNavStack(currentState => - currentState.length === 1 ? currentState : [initialNavStackItem], - ); - }; + if (storeRef.current === null) { + storeRef.current = createDocExplorerStore(); + } useEffect(() => { - if (!schemaReference) { - return; - } - switch (schemaReference.kind) { - case 'Type': { - push({ name: schemaReference.type.name, def: schemaReference.type }); - break; - } - case 'Field': { - push({ name: schemaReference.field.name, def: schemaReference.field }); - break; - } - case 'Argument': { - if (schemaReference.field) { - push({ - name: schemaReference.field.name, - def: schemaReference.field, - }); - } - break; - } - case 'EnumValue': { - if (schemaReference.type) { - push({ name: schemaReference.type.name, def: schemaReference.type }); - } - break; - } - } - }, [schemaReference, push]); + const { resolveSchemaReferenceToNavItem } = + storeRef.current.getState().actions; + resolveSchemaReferenceToNavItem(schemaReference); + }, [schemaReference]); useEffect(() => { + const { reset, rebuildNavStackWithSchema } = + storeRef.current.getState().actions; + // Whenever the schema changes, we must revalidate/replace the nav stack. if (schema == null || validationErrors.length > 0) { reset(); } else { - // Replace the nav stack with an updated version using the new schema - setNavStack(oldNavStack => { - if (oldNavStack.length === 1) { - return oldNavStack; - } - const newNavStack: ExplorerNavStack = [initialNavStackItem]; - let lastEntity: - | GraphQLNamedType - | GraphQLField - | null = null; - for (const item of oldNavStack) { - if (item === initialNavStackItem) { - // No need to copy the initial item - continue; - } - if (item.def) { - // If item.def isn't a named type, it must be a field, inputField, or argument - if (isNamedType(item.def)) { - // The type needs to be replaced with the new schema type of the same name - const newType = schema.getType(item.def.name); - if (newType) { - newNavStack.push({ - name: item.name, - def: newType, - }); - lastEntity = newType; - } else { - // This type no longer exists; the stack cannot be built beyond here - break; - } - } else if (lastEntity === null) { - // We can't have a sub-entity if we have no entity; stop rebuilding the nav stack - break; - } else if ( - isObjectType(lastEntity) || - isInputObjectType(lastEntity) - ) { - // item.def must be a Field / input field; replace with the new field of the same name - const field = lastEntity.getFields()[item.name]; - if (field) { - newNavStack.push({ - name: item.name, - def: field, - }); - } else { - // This field no longer exists; the stack cannot be built beyond here - break; - } - } else if ( - isScalarType(lastEntity) || - isEnumType(lastEntity) || - isInterfaceType(lastEntity) || - isUnionType(lastEntity) - ) { - // These don't (currently) have non-type sub-entries; something has gone wrong. - // Handle gracefully by discontinuing rebuilding the stack. - break; - } else { - // lastEntity must be a field (because it's not a named type) - const field: GraphQLField = lastEntity; - // Thus item.def must be an argument, so find the same named argument in the new schema - if (field.args.some(a => a.name === item.name)) { - newNavStack.push({ - name: item.name, - def: field, - }); - } else { - // This argument no longer exists; the stack cannot be built beyond here - break; - } - } - } else { - lastEntity = null; - newNavStack.push(item); - } - } - return newNavStack; - }); + rebuildNavStackWithSchema(schema); } }, [schema, validationErrors]); - const value: ExplorerContextType = { - explorerNavStack: navStack, - push, - pop, - reset, - }; - return ( - + {props.children} - + ); }; -export const useExplorerContext = createContextHook(ExplorerContext); +function useDocExplorerStore( + selector: (state: DocExplorerContextType) => T, +): T { + const store = useContext(DocExplorerContext); + if (!store) { + throw new Error('Missing `DocExplorerContextProvider` in the tree'); + } + return useStore(store.current, selector); +} + +export const useDocExplorer = () => + useDocExplorerStore(state => state.explorerNavStack); +export const useDocExplorerActions = () => + useDocExplorerStore(state => state.actions); diff --git a/packages/graphiql-plugin-doc-explorer/src/index.tsx b/packages/graphiql-plugin-doc-explorer/src/index.tsx index a3913e1e648..2304ea2e672 100644 --- a/packages/graphiql-plugin-doc-explorer/src/index.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/index.tsx @@ -9,16 +9,17 @@ import { DocExplorer } from './components'; export * from './components'; export { - ExplorerContext, - ExplorerContextProvider, - useExplorerContext, + DocExplorerContext, + DocExplorerContextProvider, + useDocExplorer, + useDocExplorerActions, } from './context'; export type { - ExplorerContextType, - ExplorerFieldDef, - ExplorerNavStack, - ExplorerNavStackItem, + DocExplorerContextType, + DocExplorerFieldDef, + DocExplorerNavStack, + DocExplorerNavStackItem, } from './context'; export const DOC_EXPLORER_PLUGIN: GraphiQLPlugin = { diff --git a/packages/graphiql/src/GraphiQL.tsx b/packages/graphiql/src/GraphiQL.tsx index bc94754fdb6..ae57914eef9 100644 --- a/packages/graphiql/src/GraphiQL.tsx +++ b/packages/graphiql/src/GraphiQL.tsx @@ -60,7 +60,7 @@ import { HISTORY_PLUGIN, } from '@graphiql/plugin-history'; import { - ExplorerContextProvider, + DocExplorerContextProvider, DOC_EXPLORER_PLUGIN, } from '@graphiql/plugin-doc-explorer'; @@ -156,14 +156,14 @@ const GraphiQL_: FC = ({ return ( - + - + );