From b5002f8a232ad67b2c419ee442436b25a2e74a57 Mon Sep 17 00:00:00 2001 From: Dimitri POSTOLOV Date: Fri, 9 May 2025 23:02:14 +0200 Subject: [PATCH 01/13] upd --- .changeset/famous-eyes-watch.md | 7 + .../src/context.tsx | 407 ++++++++++-------- 2 files changed, 239 insertions(+), 175 deletions(-) create mode 100644 .changeset/famous-eyes-watch.md 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/src/context.tsx b/packages/graphiql-plugin-doc-explorer/src/context.tsx index e9b83510528..b9ce5972569 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 = | GraphQLField | GraphQLInputField | GraphQLArgument; -export type ExplorerNavStackItem = { +export type DocExplorerNavStackItem = { /** * The name of the item. */ @@ -38,204 +44,255 @@ export type ExplorerNavStackItem = { }; // 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'); +function createDocExplorerStore() { + const 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; -export const ExplorerContextProvider: FC<{ + 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 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); From 48959f762e3ed0631cf1d515861fd5b224013c0a Mon Sep 17 00:00:00 2001 From: Dimitri POSTOLOV Date: Fri, 9 May 2025 23:05:57 +0200 Subject: [PATCH 02/13] upd --- .../__tests__/doc-explorer.spec.tsx | 64 ++++++++++--------- 1 file changed, 34 insertions(+), 30 deletions(-) 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..d8af5f1f167 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]); + }, []); 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]); + }, []); 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'); }); }); From 4be0fc33b4946ac7bca2bc6d5564860e27a7d1aa Mon Sep 17 00:00:00 2001 From: Dimitri POSTOLOV Date: Fri, 9 May 2025 23:07:24 +0200 Subject: [PATCH 03/13] upd --- .../graphiql-plugin-doc-explorer/package.json | 3 ++- .../src/components/default-value.tsx | 4 ++-- .../src/components/field-documentation.tsx | 8 ++++---- .../graphiql-plugin-doc-explorer/src/context.tsx | 4 ++-- .../graphiql-plugin-doc-explorer/src/index.tsx | 15 ++++++++------- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/graphiql-plugin-doc-explorer/package.json b/packages/graphiql-plugin-doc-explorer/package.json index 8d2c1f80f04..b1ebf82598c 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.0", - "@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/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/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/context.tsx b/packages/graphiql-plugin-doc-explorer/src/context.tsx index b9ce5972569..35bd34fca63 100644 --- a/packages/graphiql-plugin-doc-explorer/src/context.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/context.tsx @@ -26,7 +26,7 @@ import { import { SchemaContextType, useSchemaContext } from '@graphiql/react'; import { createStore, StoreApi, useStore } from 'zustand'; -export type ExplorerFieldDef = +export type DocExplorerFieldDef = | GraphQLField | GraphQLInputField | GraphQLArgument; @@ -40,7 +40,7 @@ export type DocExplorerNavStackItem = { * 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 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 = { From 821174762ff532cb77d8fe912c750f50aa2b527e Mon Sep 17 00:00:00 2001 From: Dimitri POSTOLOV Date: Fri, 9 May 2025 23:09:52 +0200 Subject: [PATCH 04/13] upd --- .../src/components/doc-explorer.tsx | 9 +++------ .../src/components/field-link.tsx | 6 +++--- 2 files changed, 6 insertions(+), 9 deletions(-) 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-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 ( Date: Fri, 9 May 2025 23:10:16 +0200 Subject: [PATCH 05/13] upd --- .../src/components/type-documentation.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 ( From 60bf617b9abc6623cc597b331660c3c5407be46b Mon Sep 17 00:00:00 2001 From: Dimitri POSTOLOV Date: Fri, 9 May 2025 23:15:31 +0200 Subject: [PATCH 06/13] upd --- .../__tests__/field-documentation.spec.tsx | 12 ++++++------ .../src/components/__tests__/test-utils.ts | 18 ++++++------------ .../__tests__/type-documentation.spec.tsx | 10 +++++----- .../src/context.tsx | 6 +++--- 4 files changed, 20 insertions(+), 26 deletions(-) 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/context.tsx b/packages/graphiql-plugin-doc-explorer/src/context.tsx index 35bd34fca63..022181c344b 100644 --- a/packages/graphiql-plugin-doc-explorer/src/context.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/context.tsx @@ -80,9 +80,9 @@ export type DocExplorerContextType = { }; }; -function createDocExplorerStore() { - const initialNavStackItem: DocExplorerNavStackItem = { name: 'Docs' }; - +export function createDocExplorerStore( + initialNavStackItem: DocExplorerNavStackItem = { name: 'Docs' }, +) { return createStore((set, get) => ({ explorerNavStack: [initialNavStackItem], actions: { From 304092ca7e494082fc3d70df5ba9de7c71ff3b97 Mon Sep 17 00:00:00 2001 From: Dimitri POSTOLOV Date: Fri, 9 May 2025 23:24:15 +0200 Subject: [PATCH 07/13] upd --- .../components/__tests__/type-link.spec.tsx | 30 ++++++++++--------- .../src/components/search.tsx | 17 ++++------- .../src/components/type-link.tsx | 4 +-- 3 files changed, 23 insertions(+), 28 deletions(-) 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/search.tsx b/packages/graphiql-plugin-doc-explorer/src/components/search.tsx index 36c43149e73..8caa8de8567 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/search.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/search.tsx @@ -20,17 +20,13 @@ import { MagnifyingGlassIcon, debounce, } from '@graphiql/react'; - -import { useExplorerContext } from '../context'; - -import './search.css'; +import { useDocExplorer, useDocExplorerActions } from '../context'; import { renderType } from './utils'; +import './search.css'; export const Search: FC = () => { - 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-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; From 0a1af4bf99b880e3be9ae68c495d4fc5e83bc6bd Mon Sep 17 00:00:00 2001 From: Dimitri POSTOLOV Date: Fri, 9 May 2025 23:26:01 +0200 Subject: [PATCH 08/13] upd --- .../src/components/__tests__/doc-explorer.spec.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 d8af5f1f167..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 @@ -128,7 +128,7 @@ describe('DocExplorer', () => { push({ name: 'Query', def: Query }); push({ name: 'field', def: field }); } - }, []); + }, [explorerNavStack.length, push]); return null; }; @@ -195,7 +195,7 @@ describe('DocExplorer', () => { push({ name: 'Query', def: Query }); push({ name: 'field', def: field }); } - }, []); + }, [explorerNavStack.length, push]); return null; }; From 8a550626278905435d6ff5104607142c74fbd6e5 Mon Sep 17 00:00:00 2001 From: Dimitri POSTOLOV Date: Fri, 9 May 2025 23:31:47 +0200 Subject: [PATCH 09/13] upd --- packages/graphiql/src/GraphiQL.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 ( - + - + ); From 424cb5af3b951b768bac6f29a8e4de49a5518251 Mon Sep 17 00:00:00 2001 From: Dimitri POSTOLOV Date: Sat, 10 May 2025 01:34:20 +0200 Subject: [PATCH 10/13] upd --- packages/graphiql-react/package.json | 3 +- .../graphiql-react/src/editor/context.tsx | 4 +- packages/graphiql-react/src/editor/hooks.ts | 4 +- .../graphiql-react/src/editor/query-editor.ts | 4 +- packages/graphiql-react/src/index.ts | 6 +- packages/graphiql-react/src/plugin.tsx | 4 +- packages/graphiql-react/src/storage.tsx | 54 +++++++++++--- packages/graphiql-react/src/theme.ts | 12 ++-- packages/graphiql-react/src/utility/resize.ts | 4 +- packages/graphiql/src/GraphiQL.tsx | 70 +++++++++---------- 10 files changed, 95 insertions(+), 70 deletions(-) diff --git a/packages/graphiql-react/package.json b/packages/graphiql-react/package.json index 569659c7013..c2e4b04cb13 100644 --- a/packages/graphiql-react/package.json +++ b/packages/graphiql-react/package.json @@ -56,7 +56,8 @@ "get-value": "^3.0.1", "graphql-language-service": "^5.3.1", "markdown-it": "^14.1.0", - "set-value": "^4.1.0" + "set-value": "^4.1.0", + "zustand": "^5" }, "devDependencies": { "babel-plugin-react-compiler": "19.1.0-rc.1", diff --git a/packages/graphiql-react/src/editor/context.tsx b/packages/graphiql-react/src/editor/context.tsx index 7be9d491295..f300ad962dd 100644 --- a/packages/graphiql-react/src/editor/context.tsx +++ b/packages/graphiql-react/src/editor/context.tsx @@ -9,7 +9,7 @@ import { import { VariableToType } from 'graphql-language-service'; import { FC, ReactNode, useEffect, useRef, useState } from 'react'; -import { useStorageContext } from '../storage'; +import { useStorage } from '../storage'; import { createContextHook, createNullableContext } from '../utility/context'; import { STORAGE_KEY as STORAGE_KEY_HEADERS } from './header-editor'; import { useSynchronizeValue } from './hooks'; @@ -255,7 +255,7 @@ type EditorContextProviderProps = { }; export const EditorContextProvider: FC = props => { - const storage = useStorageContext(); + const storage = useStorage(); const [headerEditor, setHeaderEditor] = useState( null, ); diff --git a/packages/graphiql-react/src/editor/hooks.ts b/packages/graphiql-react/src/editor/hooks.ts index 979d8722e0d..44f77669cdb 100644 --- a/packages/graphiql-react/src/editor/hooks.ts +++ b/packages/graphiql-react/src/editor/hooks.ts @@ -12,7 +12,7 @@ import { parse, print } from 'graphql'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { usePluginContext } from '../plugin'; import { useSchemaContext } from '../schema'; -import { useStorageContext } from '../storage'; +import { useStorage } from '../storage'; import { debounce } from '../utility'; import { onHasCompletion } from './completion'; import { useEditorContext } from './context'; @@ -47,7 +47,7 @@ export function useChangeHandler( caller: Function, ) { const { updateActiveTabValues } = useEditorContext({ nonNull: true, caller }); - const storage = useStorageContext(); + const storage = useStorage(); useEffect(() => { if (!editor) { diff --git a/packages/graphiql-react/src/editor/query-editor.ts b/packages/graphiql-react/src/editor/query-editor.ts index 61f7d55574e..222951a5237 100644 --- a/packages/graphiql-react/src/editor/query-editor.ts +++ b/packages/graphiql-react/src/editor/query-editor.ts @@ -16,7 +16,7 @@ import { useExecutionContext } from '../execution'; import { markdown } from '../markdown'; import { usePluginContext } from '../plugin'; import { useSchemaContext } from '../schema'; -import { useStorageContext } from '../storage'; +import { useStorage } from '../storage'; import { debounce } from '../utility/debounce'; import { commonKeys, @@ -147,7 +147,7 @@ export function useQueryEditor( caller: caller || _useQueryEditor, }); const executionContext = useExecutionContext(); - const storage = useStorageContext(); + const storage = useStorage(); const plugin = usePluginContext(); const copy = useCopyQuery({ caller: caller || _useQueryEditor, onCopyQuery }); const merge = useMergeQuery({ caller: caller || _useQueryEditor }); diff --git a/packages/graphiql-react/src/index.ts b/packages/graphiql-react/src/index.ts index 3993359d87c..1543b1efe33 100644 --- a/packages/graphiql-react/src/index.ts +++ b/packages/graphiql-react/src/index.ts @@ -39,11 +39,7 @@ export { SchemaContextProvider, useSchemaContext, } from './schema'; -export { - StorageContext, - StorageContextProvider, - useStorageContext, -} from './storage'; +export { StorageContextProvider, useStorage } from './storage'; export { useTheme } from './theme'; export * from './utility'; diff --git a/packages/graphiql-react/src/plugin.tsx b/packages/graphiql-react/src/plugin.tsx index 228b492d27c..21a092b7c2f 100644 --- a/packages/graphiql-react/src/plugin.tsx +++ b/packages/graphiql-react/src/plugin.tsx @@ -1,5 +1,5 @@ import { ComponentType, FC, ReactNode, useEffect, useState } from 'react'; -import { useStorageContext } from './storage'; +import { useStorage } from './storage'; import { createContextHook, createNullableContext } from './utility'; export type GraphiQLPlugin = { @@ -75,7 +75,7 @@ export const PluginContextProvider: FC = ({ plugins: $plugins, referencePlugin, }) => { - const storage = useStorageContext(); + const storage = useStorage(); const plugins = (() => { const pluginList: GraphiQLPlugin[] = []; const pluginTitles: Record = {}; diff --git a/packages/graphiql-react/src/storage.tsx b/packages/graphiql-react/src/storage.tsx index ed6d947490a..98f1ded034a 100644 --- a/packages/graphiql-react/src/storage.tsx +++ b/packages/graphiql-react/src/storage.tsx @@ -1,11 +1,22 @@ import { Storage, StorageAPI } from '@graphiql/toolkit'; -import { FC, ReactNode, useEffect, useRef, useState } from 'react'; -import { createContextHook, createNullableContext } from './utility/context'; +import { + createContext, + FC, + ReactNode, + RefObject, + useContext, + useEffect, + useRef, +} from 'react'; +import { create, StoreApi, useStore } from 'zustand/index'; -export type StorageContextType = StorageAPI; +export type StorageContextType = { + storage: StorageAPI | null; +}; -export const StorageContext = - createNullableContext('StorageContext'); +const StorageContext = createContext +> | null>(null); type StorageContextProviderProps = { children: ReactNode; @@ -23,21 +34,44 @@ export const StorageContextProvider: FC = ({ children, }) => { const isInitialRender = useRef(true); - const [$storage, setStorage] = useState(() => new StorageAPI(storage)); + const storeRef = useRef>(null!); + + if (storeRef.current === null) { + storeRef.current = create()(() => ({ + storage: new StorageAPI(storage), + })); + } useEffect(() => { if (isInitialRender.current) { isInitialRender.current = false; - } else { - setStorage(new StorageAPI(storage)); + return; } + storeRef.current.setState({ storage: new StorageAPI(storage) }); }, [storage]); return ( - + {children} ); }; -export const useStorageContext = createContextHook(StorageContext); +const defaultStore = create()(() => ({ + storage: null, +})); + +function useStorage(): StorageAPI | null; +function useStorage(options: { nonNull: true }): StorageAPI; +function useStorage(options: { nonNull: boolean }): StorageAPI | null; +function useStorage(options?: { nonNull?: boolean }): StorageAPI | null { + const store = useContext(StorageContext); + if (options?.nonNull && !store) { + throw new Error( + 'Tried to use `useStorage` without the necessary context. Make sure to render the `StorageContextProvider` component higher up the tree.', + ); + } + return useStore(store ? store.current : defaultStore, state => state.storage); +} + +export { useStorage }; diff --git a/packages/graphiql-react/src/theme.ts b/packages/graphiql-react/src/theme.ts index 94460cc8464..cb884ee9e52 100644 --- a/packages/graphiql-react/src/theme.ts +++ b/packages/graphiql-react/src/theme.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { useStorageContext } from './storage'; +import { useStorage } from './storage'; /** * The value `null` semantically means that the user does not explicitly choose @@ -8,14 +8,14 @@ import { useStorageContext } from './storage'; export type Theme = 'light' | 'dark' | null; export function useTheme(defaultTheme: Theme = null) { - const storageContext = useStorageContext(); + const storage = useStorage(); const [theme, setThemeInternal] = useState(() => { - if (!storageContext) { + if (!storage) { return null; } - const stored = storageContext.get(STORAGE_KEY); + const stored = storage.get(STORAGE_KEY); switch (stored) { case 'light': return 'light'; @@ -24,7 +24,7 @@ export function useTheme(defaultTheme: Theme = null) { default: if (typeof stored === 'string') { // Remove the invalid stored value - storageContext.set(STORAGE_KEY, ''); + storage.set(STORAGE_KEY, ''); } return defaultTheme; } @@ -38,7 +38,7 @@ export function useTheme(defaultTheme: Theme = null) { }, [theme]); const setTheme = (newTheme: Theme) => { - storageContext?.set(STORAGE_KEY, newTheme || ''); + storage?.set(STORAGE_KEY, newTheme || ''); setThemeInternal(newTheme); }; diff --git a/packages/graphiql-react/src/utility/resize.ts b/packages/graphiql-react/src/utility/resize.ts index b97e34c93fb..321a1ea414b 100644 --- a/packages/graphiql-react/src/utility/resize.ts +++ b/packages/graphiql-react/src/utility/resize.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { useStorageContext } from '../storage'; +import { useStorage } from '../storage'; import { debounce } from './debounce'; type ResizableElement = 'first' | 'second'; @@ -53,7 +53,7 @@ export function useDragResize({ sizeThresholdSecond = 100, storageKey, }: UseDragResizeArgs) { - const storage = useStorageContext(); + const storage = useStorage(); const store = debounce(500, (value: string) => { if (storageKey) { diff --git a/packages/graphiql/src/GraphiQL.tsx b/packages/graphiql/src/GraphiQL.tsx index ae57914eef9..baebfbac546 100644 --- a/packages/graphiql/src/GraphiQL.tsx +++ b/packages/graphiql/src/GraphiQL.tsx @@ -47,7 +47,7 @@ import { UseQueryEditorArgs, UseResponseEditorArgs, useSchemaContext, - useStorageContext, + useStorage, useTheme, UseVariableEditorArgs, VariableEditor, @@ -230,8 +230,8 @@ export const GraphiQLInterface: FC = props => { const editorContext = useEditorContext({ nonNull: true }); const executionContext = useExecutionContext({ nonNull: true }); const schemaContext = useSchemaContext({ nonNull: true }); - const storageContext = useStorageContext(); - const pluginContext = usePluginContext(); + const storageContext = useStorage({ nonNull: true }); + const pluginContext = usePluginContext({ nonNull: true }); const forcedTheme = props.forcedTheme && THEMES.includes(props.forcedTheme) ? props.forcedTheme @@ -246,15 +246,15 @@ export const GraphiQLInterface: FC = props => { } }, [forcedTheme, setTheme]); - const PluginContent = pluginContext?.visiblePlugin?.content; + const PluginContent = pluginContext.visiblePlugin?.content; const pluginResize = useDragResize({ defaultSizeRelation: 1 / 3, direction: 'horizontal', - initiallyHidden: pluginContext?.visiblePlugin ? undefined : 'first', + initiallyHidden: pluginContext.visiblePlugin ? undefined : 'first', onHiddenElementChange(resizableElement) { if (resizableElement === 'first') { - pluginContext?.setVisiblePlugin(null); + pluginContext.setVisiblePlugin(null); } }, sizeThresholdSecond: 200, @@ -352,10 +352,7 @@ export const GraphiQLInterface: FC = props => { const handleClearData = () => { try { - // Optional chaining inside try-catch isn't supported yet by react-compiler - if (storageContext) { - storageContext.clear(); - } + storageContext.clear(); setClearStorageStatus('success'); } catch { setClearStorageStatus('error'); @@ -387,15 +384,16 @@ export const GraphiQLInterface: FC = props => { }; const handlePluginClick: MouseEventHandler = event => { - const context = pluginContext!; const pluginIndex = Number(event.currentTarget.dataset.index!); - const plugin = context.plugins.find((_, index) => pluginIndex === index)!; - const isVisible = plugin === context.visiblePlugin; + const plugin = pluginContext.plugins.find( + (_, index) => pluginIndex === index, + )!; + const isVisible = plugin === pluginContext.visiblePlugin; if (isVisible) { - context.setVisiblePlugin(null); + pluginContext.setVisiblePlugin(null); pluginResize.setHiddenElement('first'); } else { - context.setVisiblePlugin(plugin); + pluginContext.setVisiblePlugin(plugin); pluginResize.setHiddenElement(null); } }; @@ -465,7 +463,7 @@ export const GraphiQLInterface: FC = props => {
- {pluginContext?.plugins.map((plugin, index) => { + {pluginContext.plugins.map((plugin, index) => { const isVisible = plugin === pluginContext.visiblePlugin; const label = `${isVisible ? 'Hide' : 'Show'} ${plugin.title}`; return ( @@ -529,7 +527,7 @@ export const GraphiQLInterface: FC = props => { > {PluginContent ? : null}
- {pluginContext?.visiblePlugin && ( + {pluginContext.visiblePlugin && (
= props => {
)} - {storageContext ? ( -
-
-
- Clear storage -
-
- Remove all locally stored data and start fresh. -
+
+
+
Clear storage
+
+ Remove all locally stored data and start fresh.
-
- ) : null} + +
From c4f9342faa47c7592dd648aabdfcfbbee578eb0b Mon Sep 17 00:00:00 2001 From: Dimitri POSTOLOV Date: Sat, 10 May 2025 02:01:58 +0200 Subject: [PATCH 11/13] upd --- packages/graphiql-plugin-history/src/context.tsx | 4 ++-- packages/graphiql-react/src/storage.tsx | 2 +- packages/graphiql-react/src/ui/tabs.css | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/graphiql-plugin-history/src/context.tsx b/packages/graphiql-plugin-history/src/context.tsx index d62727f12a0..b7469d0159d 100644 --- a/packages/graphiql-plugin-history/src/context.tsx +++ b/packages/graphiql-plugin-history/src/context.tsx @@ -10,7 +10,7 @@ import { import { createStore, StoreApi, useStore } from 'zustand'; import { HistoryStore, QueryStoreItem, StorageAPI } from '@graphiql/toolkit'; import { - useStorageContext, + useStorage, useExecutionContext, useEditorContext, } from '@graphiql/react'; @@ -141,7 +141,7 @@ export const HistoryContextProvider: FC = ({ maxHistoryLength = 20, children, }) => { - const storage = useStorageContext(); + const storage = useStorage(); const { isFetching } = useExecutionContext({ nonNull: true }); const { tabs, activeTabIndex } = useEditorContext({ nonNull: true }); const activeTab = tabs[activeTabIndex]; diff --git a/packages/graphiql-react/src/storage.tsx b/packages/graphiql-react/src/storage.tsx index 98f1ded034a..72676f16783 100644 --- a/packages/graphiql-react/src/storage.tsx +++ b/packages/graphiql-react/src/storage.tsx @@ -8,7 +8,7 @@ import { useEffect, useRef, } from 'react'; -import { create, StoreApi, useStore } from 'zustand/index'; +import { create, StoreApi, useStore } from 'zustand'; export type StorageContextType = { storage: StorageAPI | null; diff --git a/packages/graphiql-react/src/ui/tabs.css b/packages/graphiql-react/src/ui/tabs.css index 26278234790..b36fbcca545 100644 --- a/packages/graphiql-react/src/ui/tabs.css +++ b/packages/graphiql-react/src/ui/tabs.css @@ -18,7 +18,7 @@ -ms-overflow-style: none; /* IE and Edge */ &::-webkit-scrollbar { - @apply x:hidden; /* Chrome, Safari and Opera */ + display: none; /* Chrome, Safari and Opera */ } } From 37021fb95c2e0d237b0ec72b6631d4dfcdcdf5b7 Mon Sep 17 00:00:00 2001 From: Dimitri POSTOLOV Date: Sun, 11 May 2025 23:44:09 +0200 Subject: [PATCH 12/13] changeset --- .changeset/tame-swans-act.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/tame-swans-act.md diff --git a/.changeset/tame-swans-act.md b/.changeset/tame-swans-act.md new file mode 100644 index 00000000000..b4b36ce3a98 --- /dev/null +++ b/.changeset/tame-swans-act.md @@ -0,0 +1,8 @@ +--- +'@graphiql/plugin-history': patch +'@graphiql/react': minor +'graphiql': patch +--- + +feat(@graphiql/react): migrate React context to zustand, replace `useStorageContext` with `useStorage` hook + From 975e47a3aded5bbb011382227aac2a930ebd5e5f Mon Sep 17 00:00:00 2001 From: Dimitri POSTOLOV Date: Sun, 11 May 2025 23:45:29 +0200 Subject: [PATCH 13/13] upd --- examples/graphiql-webpack/src/index.jsx | 6 +++--- examples/graphiql-webpack/src/select-server-plugin.jsx | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/graphiql-webpack/src/index.jsx b/examples/graphiql-webpack/src/index.jsx index 7b6f678fa04..84922971f2a 100644 --- a/examples/graphiql-webpack/src/index.jsx +++ b/examples/graphiql-webpack/src/index.jsx @@ -9,7 +9,7 @@ import 'graphiql/style.css'; import '@graphiql/plugin-explorer/style.css'; import '@graphiql/plugin-code-exporter/style.css'; import { createGraphiQLFetcher } from '@graphiql/toolkit'; -import { useStorageContext } from '@graphiql/react'; +import { useStorage } from '@graphiql/react'; export const STARTING_URL = 'https://swapi-graphql.netlify.app/.netlify/functions/index'; @@ -60,9 +60,9 @@ const style = { height: '100vh' }; const explorer = explorerPlugin(); const App = () => { - const storage = useStorageContext(); + const storage = useStorage({ nonNull: true }); - const lastUrl = storage?.get(LAST_URL_KEY); + const lastUrl = storage.get(LAST_URL_KEY); const [currentUrl, setUrl] = React.useState(lastUrl ?? STARTING_URL); // TODO: a breaking change where we make URL an internal state concern, and then expose hooks // so that you can handle/set URL state internally from a plugin diff --git a/examples/graphiql-webpack/src/select-server-plugin.jsx b/examples/graphiql-webpack/src/select-server-plugin.jsx index 5fcc186ad00..25105a3ae41 100644 --- a/examples/graphiql-webpack/src/select-server-plugin.jsx +++ b/examples/graphiql-webpack/src/select-server-plugin.jsx @@ -1,7 +1,7 @@ import * as React from 'react'; import './select-server-plugin.css'; -import { useStorageContext, useSchemaContext } from '@graphiql/react'; +import { useStorage, useSchemaContext } from '@graphiql/react'; export const LAST_URL_KEY = 'lastURL'; @@ -9,12 +9,12 @@ export const PREV_URLS_KEY = 'previousURLs'; const SelectServer = ({ url, setUrl }) => { const inputRef = React.useRef(null); - const storage = useStorageContext(); - const lastUrl = storage?.get(LAST_URL_KEY); + const storage = useStorage({ nonNull: true }); + const lastUrl = storage.get(LAST_URL_KEY); const currentUrl = lastUrl ?? url; const [inputValue, setInputValue] = React.useState(currentUrl); const [previousUrls, setPreviousUrls] = React.useState( - JSON.parse(storage?.get(PREV_URLS_KEY)) ?? [currentUrl], + JSON.parse(storage.get(PREV_URLS_KEY)) ?? [currentUrl], ); const [error, setError] = React.useState(null);