diff --git a/.changeset/tiny-adults-call.md b/.changeset/tiny-adults-call.md new file mode 100644 index 00000000000..593111a4940 --- /dev/null +++ b/.changeset/tiny-adults-call.md @@ -0,0 +1,10 @@ +--- +'@graphiql/plugin-doc-explorer': patch +'@graphiql/plugin-explorer': patch +'@graphiql/plugin-history': patch +'@graphiql/toolkit': patch +'@graphiql/react': patch +'graphiql': patch +--- + +revert https://github.com/graphql/graphiql/pull/3946 to have support multiple embedded graphiql instances on the same page diff --git a/packages/graphiql-plugin-doc-explorer/src/context.ts b/packages/graphiql-plugin-doc-explorer/src/context.ts index f00ace927a8..380cec0e8a2 100644 --- a/packages/graphiql-plugin-doc-explorer/src/context.ts +++ b/packages/graphiql-plugin-doc-explorer/src/context.ts @@ -16,9 +16,9 @@ import { } from 'graphql'; import { FC, ReactElement, ReactNode, useEffect } from 'react'; import { + createBoundedUseStore, SchemaContextType, useSchemaStore, - createBoundedUseStore, } from '@graphiql/react'; import { createStore } from 'zustand'; diff --git a/packages/graphiql-plugin-explorer/src/index.tsx b/packages/graphiql-plugin-explorer/src/index.tsx index 8e35d6cd870..2a1704be668 100644 --- a/packages/graphiql-plugin-explorer/src/index.tsx +++ b/packages/graphiql-plugin-explorer/src/index.tsx @@ -1,8 +1,8 @@ import React, { CSSProperties, FC, useCallback } from 'react'; import { GraphiQLPlugin, - useEditorStore, - useExecutionStore, + useEditorContext, + useExecutionContext, useSchemaStore, useOperationsEditorState, useOptimisticState, @@ -62,9 +62,9 @@ export type GraphiQLExplorerPluginProps = Omit< >; const ExplorerPlugin: FC = props => { - const { setOperationName } = useEditorStore(); + const { setOperationName } = useEditorContext({ nonNull: true }); const { schema } = useSchemaStore(); - const { run } = useExecutionStore(); + const { run } = useExecutionContext({ nonNull: true }); // handle running the current operation from the plugin const handleRunOperation = useCallback( diff --git a/packages/graphiql-plugin-history/src/__tests__/components.spec.tsx b/packages/graphiql-plugin-history/src/__tests__/components.spec.tsx index d0d913bb2ed..749ccdcb3ae 100644 --- a/packages/graphiql-plugin-history/src/__tests__/components.spec.tsx +++ b/packages/graphiql-plugin-history/src/__tests__/components.spec.tsx @@ -4,7 +4,7 @@ import type { ComponentProps } from 'react'; import { formatQuery, HistoryItem } from '../components'; import { HistoryContextProvider } from '../context'; import { - useEditorStore, + useEditorContext, Tooltip, StorageContextProvider, } from '@graphiql/react'; @@ -16,7 +16,7 @@ vi.mock('@graphiql/react', async () => { const mockedSetHeaderEditor = vi.fn(); return { ...originalModule, - useEditorStore() { + useEditorContext() { return { queryEditor: { setValue: mockedSetQueryEditor }, variableEditor: { setValue: mockedSetVariableEditor }, @@ -24,7 +24,7 @@ vi.mock('@graphiql/react', async () => { tabs: [], }; }, - useExecutionStore() { + useExecutionContext() { return {}; }, }; @@ -78,10 +78,12 @@ function getMockProps( } describe('QueryHistoryItem', () => { - const store = useEditorStore(); - const mockedSetQueryEditor = store.queryEditor!.setValue as Mock; - const mockedSetVariableEditor = store.variableEditor!.setValue as Mock; - const mockedSetHeaderEditor = store.headerEditor!.setValue as Mock; + const mockedSetQueryEditor = useEditorContext()!.queryEditor! + .setValue as Mock; + const mockedSetVariableEditor = useEditorContext()!.variableEditor! + .setValue as Mock; + const mockedSetHeaderEditor = useEditorContext()!.headerEditor! + .setValue as Mock; beforeEach(() => { mockedSetQueryEditor.mockClear(); mockedSetVariableEditor.mockClear(); diff --git a/packages/graphiql-plugin-history/src/components.tsx b/packages/graphiql-plugin-history/src/components.tsx index bd379ca62ee..e2e54d86e9a 100644 --- a/packages/graphiql-plugin-history/src/components.tsx +++ b/packages/graphiql-plugin-history/src/components.tsx @@ -7,7 +7,7 @@ import { StarFilledIcon, StarIcon, TrashIcon, - useEditorStore, + useEditorContext, Button, Tooltip, UnStyledButton, @@ -112,7 +112,10 @@ type QueryHistoryItemProps = { export const HistoryItem: FC = props => { const { editLabel, toggleFavorite, deleteFromHistory, setActive } = useHistoryActions(); - const { headerEditor, queryEditor, variableEditor } = useEditorStore(); + const { headerEditor, queryEditor, variableEditor } = useEditorContext({ + nonNull: true, + caller: HistoryItem, + }); const inputRef = useRef(null); const buttonRef = useRef(null); const [isEditable, setIsEditable] = useState(false); diff --git a/packages/graphiql-plugin-history/src/context.tsx b/packages/graphiql-plugin-history/src/context.tsx index c8580f1c697..14bc898e00c 100644 --- a/packages/graphiql-plugin-history/src/context.tsx +++ b/packages/graphiql-plugin-history/src/context.tsx @@ -3,8 +3,8 @@ import { FC, ReactElement, ReactNode, useEffect } from 'react'; import { createStore } from 'zustand'; import { HistoryStore, QueryStoreItem } from '@graphiql/toolkit'; import { - useExecutionStore, - useEditorStore, + useExecutionContext, + useEditorContext, useStorage, createBoundedUseStore, } from '@graphiql/react'; @@ -119,8 +119,8 @@ export const HistoryContextProvider: FC = ({ maxHistoryLength = 20, children, }) => { - const { isFetching } = useExecutionStore(); - const { tabs, activeTabIndex } = useEditorStore(); + const { isFetching } = useExecutionContext({ nonNull: true }); + const { tabs, activeTabIndex } = useEditorContext({ nonNull: true }); const activeTab = tabs[activeTabIndex]; const storage = useStorage(); diff --git a/packages/graphiql-react/src/editor/components/header-editor.tsx b/packages/graphiql-react/src/editor/components/header-editor.tsx index 3652b96f7e2..31c9779d748 100644 --- a/packages/graphiql-react/src/editor/components/header-editor.tsx +++ b/packages/graphiql-react/src/editor/components/header-editor.tsx @@ -1,6 +1,6 @@ import { FC, useEffect } from 'react'; import { clsx } from 'clsx'; -import { useEditorStore } from '../context'; +import { useEditorContext } from '../context'; import { useHeaderEditor, UseHeaderEditorArgs } from '../header-editor'; import '../style/codemirror.css'; import '../style/fold.css'; @@ -18,8 +18,11 @@ export const HeaderEditor: FC = ({ isHidden, ...hookArgs }) => { - const headerEditor = useEditorStore(store => store.headerEditor); - const ref = useHeaderEditor(hookArgs); + const { headerEditor } = useEditorContext({ + nonNull: true, + caller: HeaderEditor, + }); + const ref = useHeaderEditor(hookArgs, HeaderEditor); useEffect(() => { if (!isHidden) { diff --git a/packages/graphiql-react/src/editor/components/response-editor.tsx b/packages/graphiql-react/src/editor/components/response-editor.tsx index fb7c98ecc53..894dcc6f70a 100644 --- a/packages/graphiql-react/src/editor/components/response-editor.tsx +++ b/packages/graphiql-react/src/editor/components/response-editor.tsx @@ -6,7 +6,7 @@ import '../style/info.css'; import '../style/editor.css'; export const ResponseEditor: FC = props => { - const ref = useResponseEditor(props); + const ref = useResponseEditor(props, ResponseEditor); return (
= ({ isHidden, ...hookArgs }) => { - const variableEditor = useEditorStore(store => store.variableEditor); - const ref = useVariableEditor(hookArgs); + const { variableEditor } = useEditorContext({ + nonNull: true, + caller: VariableEditor, + }); + const ref = useVariableEditor(hookArgs, VariableEditor); useEffect(() => { if (!isHidden) { diff --git a/packages/graphiql-react/src/editor/context.tsx b/packages/graphiql-react/src/editor/context.tsx index d275756220b..c660e5fcb33 100644 --- a/packages/graphiql-react/src/editor/context.tsx +++ b/packages/graphiql-react/src/editor/context.tsx @@ -1,4 +1,3 @@ -// eslint-disable-next-line react/jsx-filename-extension -- TODO import { DocumentNode, FragmentDefinitionNode, @@ -8,9 +7,10 @@ import { visit, } from 'graphql'; import { VariableToType } from 'graphql-language-service'; -import { FC, ReactElement, ReactNode, useEffect, useRef } from 'react'; +import { FC, ReactNode, useEffect, useRef, useState } from 'react'; -import { storageStore, useStorage } 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'; import { STORAGE_KEY_QUERY } from './query-editor'; @@ -21,9 +21,9 @@ import { TabDefinition, TabsState, TabState, - setEditorValues, - storeTabs, - synchronizeActiveTabValues, + useSetEditorValues, + useStoreTabs, + useSynchronizeActiveTabValues, clearHeadersFromTabs, serializeTabState, STORAGE_KEY as STORAGE_KEY_TABS, @@ -31,8 +31,6 @@ import { import { CodeMirrorEditor } from './types'; import { STORAGE_KEY as STORAGE_KEY_VARIABLES } from './variable-editor'; import { DEFAULT_QUERY } from '../constants'; -import { createStore } from 'zustand'; -import { createBoundedUseStore } from '../utility'; export type CodeMirrorEditorWithOperationFacts = CodeMirrorEditor & { documentAST: DocumentNode | null; @@ -41,24 +39,21 @@ export type CodeMirrorEditorWithOperationFacts = CodeMirrorEditor & { variableToType: VariableToType | null; }; -interface EditorStore extends TabsState { +export type EditorContextType = TabsState & { /** * Add a new tab. */ addTab(): void; - /** * Switch to a different tab. * @param index The index of the tab that should be switched to. */ changeTab(index: number): void; - /** * Move a tab to a new spot. * @param newOrder The new order for the tabs. */ moveTab(newOrder: TabState[]): void; - /** * Close a tab. If the currently active tab is closed, the tab before it will * become active. If there is no tab before the closed one, the tab after it @@ -66,7 +61,6 @@ interface EditorStore extends TabsState { * @param index The index of the tab that should be closed. */ closeTab(index: number): void; - /** * Update the state for the tab that is currently active. This will be * reflected in the `tabs` object and the state will be persisted in storage @@ -96,22 +90,18 @@ interface EditorStore extends TabsState { * The CodeMirror editor instance for the variables editor. */ variableEditor: CodeMirrorEditor | null; - /** * Set the CodeMirror editor instance for the headers editor. */ setHeaderEditor(newEditor: CodeMirrorEditor): void; - /** * Set the CodeMirror editor instance for the query editor. */ setQueryEditor(newEditor: CodeMirrorEditorWithOperationFacts): void; - /** * Set the CodeMirror editor instance for the response editor. */ setResponseEditor(newEditor: CodeMirrorEditor): void; - /** * Set the CodeMirror editor instance for the variables editor. */ @@ -126,25 +116,21 @@ interface EditorStore extends TabsState { * The contents of the headers editor when initially rendering the provider * component. */ - initialHeaders: string; /** * The contents of the query editor when initially rendering the provider * component. */ - initialQuery: string; /** * The contents of the response editor when initially rendering the provider * component. */ - initialResponse: string; /** * The contents of the variables editor when initially rendering the provider * component. */ - initialVariables: string; /** @@ -152,17 +138,27 @@ interface EditorStore extends TabsState { * made available to include in the query. */ externalFragments: Map; + /** + * A list of custom validation rules that are run in addition to the rules + * provided by the GraphQL spec. + */ + validationRules: ValidationRule[]; /** * If the contents of the headers editor are persisted in storage. */ shouldPersistHeaders: boolean; - /** * Changes if headers should be persisted. */ setShouldPersistHeaders(persist: boolean): void; +}; + +export const EditorContext = + createNullableContext('EditorContext'); +type EditorContextProviderProps = { + children: ReactNode; /** * The initial contents of the query editor when loading GraphiQL and there * is no other source for the editor state. Other sources can be: @@ -172,43 +168,6 @@ interface EditorStore extends TabsState { * more tabs the query editor will start out empty. */ defaultQuery?: string; - - /** - * Invoked when the operation name changes. Possible triggers are: - * - Editing the contents of the query editor - * - Selecting an operation for execution in a document that contains multiple - * operation definitions - * @param operationName The operation name after it has been changed. - */ - onEditOperationName?(operationName: string): void; - - /** - * Invoked when the state of the tabs changes. Possible triggers are: - * - Updating any editor contents inside the currently active tab - * - Adding a tab - * - Switching to a different tab - * - Closing a tab - * @param tabState The tab state after it has been updated. - */ - onTabChange?(tabState: TabsState): void; - - /** - * A list of custom validation rules that are run in addition to the rules - * provided by the GraphQL spec. - */ - validationRules: ValidationRule[]; - - /** - * Headers to be set when opening a new tab - */ - defaultHeaders?: string; -} - -type EditorContextProviderProps = Pick< - EditorStore, - 'onTabChange' | 'onEditOperationName' | 'defaultHeaders' | 'defaultQuery' -> & { - children: ReactNode; /** * With this prop you can pass so-called "external" fragments that will be * included in the query document (depending on usage). You can either pass @@ -239,6 +198,23 @@ type EditorContextProviderProps = Pick< *``` */ defaultTabs?: TabDefinition[]; + /** + * Invoked when the operation name changes. Possible triggers are: + * - Editing the contents of the query editor + * - Selecting a operation for execution in a document that contains multiple + * operation definitions + * @param operationName The operation name after it has been changed. + */ + onEditOperationName?(operationName: string): void; + /** + * Invoked when the state of the tabs changes. Possible triggers are: + * - Updating any editor contents inside the currently active tab + * - Adding a tab + * - Switching to a different tab + * - Closing a tab + * @param tabState The tab state after it has been updated. + */ + onTabChange?(tabState: TabsState): void; /** * This prop can be used to set the contents of the query editor. Every time * this prop changes, the contents of the query editor are replaced. Note @@ -272,15 +248,119 @@ type EditorContextProviderProps = Pick< * typing in the editor. */ variables?: string; + + /** + * Headers to be set when opening a new tab + */ + defaultHeaders?: string; }; -export const editorStore = createStore((set, get) => ({ - tabs: null!, - activeTabIndex: null!, - addTab() { - set(current => { - const { defaultQuery, defaultHeaders, onTabChange } = get(); +export const EditorContextProvider: FC = props => { + const storage = useStorage(); + const [headerEditor, setHeaderEditor] = useState( + null, + ); + const [queryEditor, setQueryEditor] = + useState(null); + const [responseEditor, setResponseEditor] = useState( + null, + ); + const [variableEditor, setVariableEditor] = useState( + null, + ); + + const [shouldPersistHeaders, setShouldPersistHeadersInternal] = useState( + () => { + const isStored = storage.get(PERSIST_HEADERS_STORAGE_KEY) !== null; + return props.shouldPersistHeaders !== false && isStored + ? storage.get(PERSIST_HEADERS_STORAGE_KEY) === 'true' + : Boolean(props.shouldPersistHeaders); + }, + ); + + useSynchronizeValue(headerEditor, props.headers); + useSynchronizeValue(queryEditor, props.query); + useSynchronizeValue(responseEditor, props.response); + useSynchronizeValue(variableEditor, props.variables); + + const storeTabs = useStoreTabs({ + shouldPersistHeaders, + }); + + // We store this in state but never update it. By passing a function we only + // need to compute it lazily during the initial render. + const [initialState] = useState(() => { + const query = props.query ?? storage.get(STORAGE_KEY_QUERY) ?? null; + const variables = + props.variables ?? storage.get(STORAGE_KEY_VARIABLES) ?? null; + const headers = props.headers ?? storage.get(STORAGE_KEY_HEADERS) ?? null; + const response = props.response ?? ''; + + const tabState = getDefaultTabState({ + query, + variables, + headers, + defaultTabs: props.defaultTabs, + defaultQuery: props.defaultQuery || DEFAULT_QUERY, + defaultHeaders: props.defaultHeaders, + shouldPersistHeaders, + }); + storeTabs(tabState); + return { + query: + query ?? + (tabState.activeTabIndex === 0 ? tabState.tabs[0].query : null) ?? + '', + variables: variables ?? '', + headers: headers ?? props.defaultHeaders ?? '', + response, + tabState, + }; + }); + + const [tabState, setTabState] = useState(initialState.tabState); + + const setShouldPersistHeaders = // eslint-disable-line react-hooks/exhaustive-deps -- false positive, function is optimized by react-compiler, no need to wrap with useCallback + (persist: boolean) => { + if (persist) { + storage.set(STORAGE_KEY_HEADERS, headerEditor?.getValue() ?? ''); + const serializedTabs = serializeTabState(tabState, true); + storage.set(STORAGE_KEY_TABS, serializedTabs); + } else { + storage.set(STORAGE_KEY_HEADERS, ''); + clearHeadersFromTabs(); + } + setShouldPersistHeadersInternal(persist); + storage.set(PERSIST_HEADERS_STORAGE_KEY, persist.toString()); + }; + + const lastShouldPersistHeadersProp = useRef(undefined); + useEffect(() => { + const propValue = Boolean(props.shouldPersistHeaders); + if (lastShouldPersistHeadersProp?.current !== propValue) { + setShouldPersistHeaders(propValue); + lastShouldPersistHeadersProp.current = propValue; + } + }, [props.shouldPersistHeaders, setShouldPersistHeaders]); + + const synchronizeActiveTabValues = useSynchronizeActiveTabValues({ + queryEditor, + variableEditor, + headerEditor, + responseEditor, + }); + const { onTabChange, defaultHeaders, defaultQuery, children } = props; + const setEditorValues = useSetEditorValues({ + queryEditor, + variableEditor, + headerEditor, + responseEditor, + defaultHeaders, + }); + + const addTab: EditorContextType['addTab'] = () => { + setTabState(current => { // Make sure the current tab stores the latest values const updatedValues = synchronizeActiveTabValues(current); const updated = { @@ -288,7 +368,7 @@ export const editorStore = createStore((set, get) => ({ ...updatedValues.tabs, createTab({ headers: defaultHeaders, - query: defaultQuery, + query: defaultQuery ?? DEFAULT_QUERY, }), ], activeTabIndex: updatedValues.tabs.length, @@ -298,10 +378,10 @@ export const editorStore = createStore((set, get) => ({ onTabChange?.(updated); return updated; }); - }, - changeTab(index) { - set(current => { - const { onTabChange } = get(); + }; + + const changeTab: EditorContextType['changeTab'] = index => { + setTabState(current => { const updated = { ...current, activeTabIndex: index, @@ -311,10 +391,10 @@ export const editorStore = createStore((set, get) => ({ onTabChange?.(updated); return updated; }); - }, - moveTab(newOrder) { - set(current => { - const { onTabChange } = get(); + }; + + const moveTab: EditorContextType['moveTab'] = newOrder => { + setTabState(current => { const activeTab = current.tabs[current.activeTabIndex]; const updated = { tabs: newOrder, @@ -325,10 +405,10 @@ export const editorStore = createStore((set, get) => ({ onTabChange?.(updated); return updated; }); - }, - closeTab(index) { - set(current => { - const { onTabChange } = get(); + }; + + const closeTab: EditorContextType['closeTab'] = index => { + setTabState(current => { const updated = { tabs: current.tabs.filter((_tab, i) => index !== i), activeTabIndex: Math.max(current.activeTabIndex - 1, 0), @@ -338,120 +418,43 @@ export const editorStore = createStore((set, get) => ({ onTabChange?.(updated); return updated; }); - }, - updateActiveTabValues(partialTab) { - set(current => { - if (!current.tabs) { - // Vitest fails with TypeError: Cannot read properties of null (reading 'map') - // in `setPropertiesInActiveTab` when `tabs` is `null` - return current; - } - const { onTabChange } = get(); - const updated = setPropertiesInActiveTab(current, partialTab); - storeTabs(updated); - onTabChange?.(updated); - return updated; - }); - }, - headerEditor: null!, - queryEditor: null!, - responseEditor: null!, - variableEditor: null!, - setHeaderEditor(headerEditor) { - set({ headerEditor }); - }, - setQueryEditor(queryEditor) { - set({ queryEditor }); - }, - setResponseEditor(responseEditor) { - set({ responseEditor }); - }, - setVariableEditor(variableEditor) { - set({ variableEditor }); - }, - setOperationName(operationName) { - const { queryEditor, onEditOperationName, updateActiveTabValues } = get(); - if (!queryEditor) { - return; - } - queryEditor.operationName = operationName; - updateActiveTabValues({ operationName }); - onEditOperationName?.(operationName); - }, - shouldPersistHeaders: false, - setShouldPersistHeaders(persist) { - const { headerEditor, tabs, activeTabIndex } = get(); - const { storage } = storageStore.getState(); - if (persist) { - storage.set(STORAGE_KEY_HEADERS, headerEditor?.getValue() ?? ''); - const serializedTabs = serializeTabState({ tabs, activeTabIndex }, true); - storage.set(STORAGE_KEY_TABS, serializedTabs); - } else { - storage.set(STORAGE_KEY_HEADERS, ''); - clearHeadersFromTabs(); - } - set({ shouldPersistHeaders: persist }); - storage.set(PERSIST_HEADERS_STORAGE_KEY, persist.toString()); - }, - onEditOperationName: undefined, - externalFragments: null!, - onTabChange: undefined, - defaultQuery: undefined, - defaultHeaders: undefined, - validationRules: null!, - initialHeaders: null!, - initialQuery: null!, - initialResponse: null!, - initialVariables: null!, -})); - -export const EditorContextProvider: FC = ({ - externalFragments, - onEditOperationName, - defaultHeaders, - onTabChange, - defaultQuery, - children, - shouldPersistHeaders = false, - validationRules = [], - ...props -}) => { - const storage = useStorage(); - const isMounted = useEditorStore(store => Boolean(store.tabs)); + }; + + const updateActiveTabValues: EditorContextType['updateActiveTabValues'] = + partialTab => { + setTabState(current => { + const updated = setPropertiesInActiveTab(current, partialTab); + storeTabs(updated); + onTabChange?.(updated); + return updated; + }); + }; - const headerEditor = useEditorStore(store => store.headerEditor); - const queryEditor = useEditorStore(store => store.queryEditor); - const responseEditor = useEditorStore(store => store.responseEditor); - const variableEditor = useEditorStore(store => store.variableEditor); + const { onEditOperationName } = props; + const setOperationName: EditorContextType['setOperationName'] = + operationName => { + if (!queryEditor) { + return; + } - useSynchronizeValue(headerEditor, props.headers); - useSynchronizeValue(queryEditor, props.query); - useSynchronizeValue(responseEditor, props.response); - useSynchronizeValue(variableEditor, props.variables); + updateQueryEditor(queryEditor, operationName); + updateActiveTabValues({ operationName }); + onEditOperationName?.(operationName); + }; - // TODO: - // const lastShouldPersistHeadersProp = useRef(undefined); - // useEffect(() => { - // const propValue = shouldPersistHeaders; - // if (lastShouldPersistHeadersProp.current !== propValue) { - // editorStore.getState().setShouldPersistHeaders(propValue); - // lastShouldPersistHeadersProp.current = propValue; - // } - // }, [shouldPersistHeaders]); - - const $externalFragments = (() => { + const externalFragments = (() => { const map = new Map(); - if (Array.isArray(externalFragments)) { - for (const fragment of externalFragments) { + if (Array.isArray(props.externalFragments)) { + for (const fragment of props.externalFragments) { map.set(fragment.name.value, fragment); } - } else if (typeof externalFragments === 'string') { - visit(parse(externalFragments, {}), { + } else if (typeof props.externalFragments === 'string') { + visit(parse(props.externalFragments, {}), { FragmentDefinition(fragment) { map.set(fragment.name.value, fragment); }, }); - } else if (externalFragments) { + } else if (props.externalFragments) { throw new Error( 'The `externalFragments` prop must either be a string that contains the fragment definitions in SDL or a list of FragmentDefinitionNode objects.', ); @@ -459,77 +462,52 @@ export const EditorContextProvider: FC = ({ return map; })(); - const initialRendered = useRef(false); - - useEffect(() => { - if (initialRendered.current) { - return; - } - initialRendered.current = true; - - // We only need to compute it lazily during the initial render. - const query = props.query ?? storage.get(STORAGE_KEY_QUERY) ?? null; - const variables = - props.variables ?? storage.get(STORAGE_KEY_VARIABLES) ?? null; - const headers = props.headers ?? storage.get(STORAGE_KEY_HEADERS) ?? null; - const response = props.response ?? ''; - - const tabState = getDefaultTabState({ - query, - variables, - headers, - defaultTabs: props.defaultTabs, - defaultQuery: defaultQuery || DEFAULT_QUERY, - defaultHeaders, - shouldPersistHeaders, - }); - storeTabs(tabState); - - const isStored = storage.get(PERSIST_HEADERS_STORAGE_KEY) !== null; - - const $shouldPersistHeaders = - shouldPersistHeaders !== false && isStored - ? storage.get(PERSIST_HEADERS_STORAGE_KEY) === 'true' - : shouldPersistHeaders; - - editorStore.setState({ - shouldPersistHeaders: $shouldPersistHeaders, - ...tabState, - initialQuery: - query ?? - (tabState.activeTabIndex === 0 ? tabState.tabs[0].query : null) ?? - '', - initialVariables: variables ?? '', - initialHeaders: headers ?? defaultHeaders ?? '', - initialResponse: response, - }); - }, []); // eslint-disable-line react-hooks/exhaustive-deps -- only on mount - - useEffect(() => { - editorStore.setState({ - externalFragments: $externalFragments, - onTabChange, - onEditOperationName, - defaultQuery, - defaultHeaders, - validationRules, - }); - }, [ - $externalFragments, - onTabChange, - onEditOperationName, - defaultQuery, - defaultHeaders, + const validationRules = props.validationRules || []; + + const value: EditorContextType = { + ...tabState, + addTab, + changeTab, + moveTab, + closeTab, + updateActiveTabValues, + + headerEditor, + queryEditor, + responseEditor, + variableEditor, + setHeaderEditor, + setQueryEditor, + setResponseEditor, + setVariableEditor, + + setOperationName, + + initialQuery: initialState.query, + initialVariables: initialState.variables, + initialHeaders: initialState.headers, + initialResponse: initialState.response, + + externalFragments, validationRules, - ]); - if (!isMounted) { - // Ensure store was initialized - return null; - } - return children as ReactElement; + shouldPersistHeaders, + setShouldPersistHeaders, + }; + + return ( + {children} + ); }; -export const useEditorStore = createBoundedUseStore(editorStore); +// To make react-compiler happy, otherwise it fails due to mutating props +function updateQueryEditor( + queryEditor: CodeMirrorEditorWithOperationFacts, + operationName: string, +) { + queryEditor.operationName = operationName; +} + +export const useEditorContext = createContextHook(EditorContext); const PERSIST_HEADERS_STORAGE_KEY = 'shouldPersistHeaders'; diff --git a/packages/graphiql-react/src/editor/header-editor.ts b/packages/graphiql-react/src/editor/header-editor.ts index 5a84eabe9ed..e9e19a74bee 100644 --- a/packages/graphiql-react/src/editor/header-editor.ts +++ b/packages/graphiql-react/src/editor/header-editor.ts @@ -1,12 +1,13 @@ import { useEffect, useRef } from 'react'; +import { useExecutionContext } from '../execution'; import { commonKeys, DEFAULT_EDITOR_THEME, DEFAULT_KEY_MAP, importCodeMirror, } from './common'; -import { useEditorStore } from './context'; +import { useEditorContext } from './context'; import { useChangeHandler, useKeyMap, @@ -15,7 +16,6 @@ import { useSynchronizeOption, } from './hooks'; import { WriteableEditorProps } from './types'; -import { useExecutionStore } from '../execution'; export type UseHeaderEditorArgs = WriteableEditorProps & { /** @@ -32,22 +32,29 @@ function importCodeMirrorImports() { import('codemirror/mode/javascript/javascript.js'), ]); } +const _useHeaderEditor = useHeaderEditor; -export function useHeaderEditor({ - editorTheme = DEFAULT_EDITOR_THEME, - keyMap = DEFAULT_KEY_MAP, - onEdit, - readOnly = false, -}: UseHeaderEditorArgs = {}) { +export function useHeaderEditor( + { + editorTheme = DEFAULT_EDITOR_THEME, + keyMap = DEFAULT_KEY_MAP, + onEdit, + readOnly = false, + }: UseHeaderEditorArgs = {}, + caller?: Function, +) { const { initialHeaders, headerEditor, setHeaderEditor, shouldPersistHeaders, - } = useEditorStore(); - const { run } = useExecutionStore(); - const merge = useMergeQuery(); - const prettify = usePrettifyEditors(); + } = useEditorContext({ + nonNull: true, + caller: caller || _useHeaderEditor, + }); + const executionContext = useExecutionContext(); + const merge = useMergeQuery({ caller: caller || _useHeaderEditor }); + const prettify = usePrettifyEditors({ caller: caller || _useHeaderEditor }); const ref = useRef(null); useEffect(() => { @@ -118,9 +125,10 @@ export function useHeaderEditor({ onEdit, shouldPersistHeaders ? STORAGE_KEY : null, 'headers', + _useHeaderEditor, ); - useKeyMap(headerEditor, ['Cmd-Enter', 'Ctrl-Enter'], run); + useKeyMap(headerEditor, ['Cmd-Enter', 'Ctrl-Enter'], executionContext?.run); useKeyMap(headerEditor, ['Shift-Ctrl-P'], prettify); useKeyMap(headerEditor, ['Shift-Ctrl-M'], merge); diff --git a/packages/graphiql-react/src/editor/hooks.ts b/packages/graphiql-react/src/editor/hooks.ts index cacc01340a0..079bcd5c272 100644 --- a/packages/graphiql-react/src/editor/hooks.ts +++ b/packages/graphiql-react/src/editor/hooks.ts @@ -1,4 +1,9 @@ -import { fillLeafs, mergeAst, MaybePromise } from '@graphiql/toolkit'; +import { + fillLeafs, + GetDefaultFieldNamesFn, + mergeAst, + MaybePromise, +} from '@graphiql/toolkit'; import type { EditorChange, EditorConfiguration } from 'codemirror'; import type { SchemaReference } from 'codemirror-graphql/utils/SchemaReference'; import copyToClipboard from 'copy-to-clipboard'; @@ -6,13 +11,12 @@ import { parse, print } from 'graphql'; // eslint-disable-next-line @typescript-eslint/no-restricted-imports -- TODO: check why query builder update only 1st field https://github.com/graphql/graphiql/issues/3836 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { usePluginStore } from '../plugin'; -import { schemaStore, useSchemaStore } from '../schema'; +import { useSchemaStore } from '../schema'; import { storageStore } from '../storage'; import { debounce } from '../utility'; import { onHasCompletion } from './completion'; -import { editorStore, useEditorStore } from './context'; +import { useEditorContext } from './context'; import { CodeMirrorEditor } from './types'; -import { executionStore } from '../execution'; export function useSynchronizeValue( editor: CodeMirrorEditor | null, @@ -40,7 +44,9 @@ export function useChangeHandler( callback: ((value: string) => void) | undefined, storageKey: string | null, tabProperty: 'variables' | 'headers', + caller: Function, ) { + const { updateActiveTabValues } = useEditorContext({ nonNull: true, caller }); useEffect(() => { if (!editor) { return; @@ -54,7 +60,6 @@ export function useChangeHandler( storage.set(storageKey, value); }); - const { updateActiveTabValues } = editorStore.getState(); const updateTab = debounce(100, (value: string) => { updateActiveTabValues({ [tabProperty]: value }); }); @@ -76,7 +81,7 @@ export function useChangeHandler( }; editor.on('change', handleChange); return () => editor.off('change', handleChange); - }, [callback, editor, storageKey, tabProperty]); + }, [callback, editor, storageKey, tabProperty, updateActiveTabValues]); } export function useCompletion( @@ -155,9 +160,18 @@ export type UseCopyQueryArgs = { onCopyQuery?: (query: string) => void; }; -export function useCopyQuery({ onCopyQuery }: UseCopyQueryArgs = {}) { +// To make react-compiler happy, otherwise complains about - Hooks may not be referenced as normal values +const _useCopyQuery = useCopyQuery; +const _useMergeQuery = useMergeQuery; +const _usePrettifyEditors = usePrettifyEditors; +const _useAutoCompleteLeafs = useAutoCompleteLeafs; + +export function useCopyQuery({ caller, onCopyQuery }: UseCopyQueryArgs = {}) { + const { queryEditor } = useEditorContext({ + nonNull: true, + caller: caller || _useCopyQuery, + }); return () => { - const { queryEditor } = editorStore.getState(); if (!queryEditor) { return; } @@ -169,21 +183,35 @@ export function useCopyQuery({ onCopyQuery }: UseCopyQueryArgs = {}) { }; } -export function useMergeQuery() { +type UseMergeQueryArgs = { + /** + * This is only meant to be used internally in `@graphiql/react`. + */ + caller?: Function; +}; + +export function useMergeQuery({ caller }: UseMergeQueryArgs = {}) { + const { queryEditor } = useEditorContext({ + nonNull: true, + caller: caller || _useMergeQuery, + }); + const { schema } = useSchemaStore(); return () => { - const { queryEditor } = editorStore.getState(); const documentAST = queryEditor?.documentAST; const query = queryEditor?.getValue(); if (!documentAST || !query) { return; } - const { schema } = schemaStore.getState(); queryEditor.setValue(print(mergeAst(documentAST, schema))); }; } export type UsePrettifyEditorsArgs = { + /** + * This is only meant to be used internally in `@graphiql/react`. + */ + caller?: Function; /** * Invoked when the prettify callback is invoked. * @param query The current value of the query editor. @@ -201,11 +229,14 @@ function DEFAULT_PRETTIFY_QUERY(query: string): string { } export function usePrettifyEditors({ + caller, onPrettifyQuery = DEFAULT_PRETTIFY_QUERY, }: UsePrettifyEditorsArgs = {}) { + const { queryEditor, headerEditor, variableEditor } = useEditorContext({ + nonNull: true, + caller: caller || _usePrettifyEditors, + }); return async () => { - const { queryEditor, headerEditor, variableEditor } = - editorStore.getState(); if (variableEditor) { const variableEditorContent = variableEditor.getValue(); try { @@ -253,56 +284,82 @@ export function usePrettifyEditors({ }; } -export function getAutoCompleteLeafs() { - const { queryEditor } = editorStore.getState(); - if (!queryEditor) { - return; - } - const { schema } = schemaStore.getState(); - const query = queryEditor.getValue(); - const { getDefaultFieldNames } = executionStore.getState(); - const { insertions, result } = fillLeafs(schema, query, 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: 'auto-inserted-leaf', - clearOnEnter: true, - title: 'Automatically added leaf fields', - }, - ), - ); - setTimeout(() => { - for (const marker of markers) { - marker.clear(); - } - }, 7000); - let newCursorIndex = cursorIndex; - for (const { index, string } of insertions) { - if (index < cursorIndex) { - newCursorIndex += string.length; +export type UseAutoCompleteLeafsArgs = { + /** + * A function to determine which field leafs are automatically added when + * trying to execute a query with missing selection sets. It will be called + * with the `GraphQLType` for which fields need to be added. + */ + getDefaultFieldNames?: GetDefaultFieldNamesFn; + /** + * This is only meant to be used internally in `@graphiql/react`. + */ + caller?: Function; +}; + +export function useAutoCompleteLeafs({ + getDefaultFieldNames, + caller, +}: UseAutoCompleteLeafsArgs = {}) { + const { schema } = useSchemaStore(); + const { queryEditor } = useEditorContext({ + nonNull: true, + caller: caller || _useAutoCompleteLeafs, + }); + return () => { + if (!queryEditor) { + return; + } + + const query = queryEditor.getValue(); + const { insertions, result } = fillLeafs( + schema, + query, + 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: 'auto-inserted-leaf', + clearOnEnter: true, + title: 'Automatically added leaf fields', + }, + ), + ); + setTimeout(() => { + for (const marker of markers) { + marker.clear(); + } + }, 7000); + let newCursorIndex = cursorIndex; + for (const { index, string } of insertions) { + if (index < cursorIndex) { + newCursorIndex += string.length; + } } - } - queryEditor.setCursor(queryEditor.posFromIndex(newCursorIndex)); - }); - } + queryEditor.setCursor(queryEditor.posFromIndex(newCursorIndex)); + }); + } - return result; + return result; + }; } // https://react.dev/learn/you-might-not-need-an-effect export const useEditorState = (editor: 'query' | 'variable' | 'header') => { // eslint-disable-next-line react-hooks/react-compiler -- TODO: check why query builder update only 1st field https://github.com/graphql/graphiql/issues/3836 'use no memo'; - const editorInstance = useEditorStore(store => store[`${editor}Editor`]); + const context = useEditorContext({ nonNull: true }); + + const editorInstance = context[`${editor}Editor` as const]; let valueString = ''; const editorValue = editorInstance?.getValue() ?? false; if (editorValue && editorValue.length > 0) { diff --git a/packages/graphiql-react/src/editor/index.ts b/packages/graphiql-react/src/editor/index.ts index 9e2213eb88c..dfeb1a18ff2 100644 --- a/packages/graphiql-react/src/editor/index.ts +++ b/packages/graphiql-react/src/editor/index.ts @@ -5,10 +5,14 @@ export { ResponseEditor, VariableEditor, } from './components'; -export { EditorContextProvider, useEditorStore } from './context'; +export { + EditorContext, + EditorContextProvider, + useEditorContext, +} from './context'; export { useHeaderEditor } from './header-editor'; export { - getAutoCompleteLeafs, + useAutoCompleteLeafs, useCopyQuery, useMergeQuery, usePrettifyEditors, @@ -22,6 +26,7 @@ export { useQueryEditor } from './query-editor'; export { useResponseEditor } from './response-editor'; export { useVariableEditor } from './variable-editor'; +export type { EditorContextType } from './context'; export type { UseHeaderEditorArgs } from './header-editor'; export type { UseQueryEditorArgs } from './query-editor'; export type { diff --git a/packages/graphiql-react/src/editor/query-editor.ts b/packages/graphiql-react/src/editor/query-editor.ts index e591e387f70..e828842d84d 100644 --- a/packages/graphiql-react/src/editor/query-editor.ts +++ b/packages/graphiql-react/src/editor/query-editor.ts @@ -12,7 +12,7 @@ import { OperationFacts, } from 'graphql-language-service'; import { RefObject, useEffect, useRef } from 'react'; -import { executionStore } from '../execution'; +import { useExecutionContext } from '../execution'; import { markdown } from '../markdown'; import { usePluginStore } from '../plugin'; import { useSchemaStore } from '../schema'; @@ -24,7 +24,10 @@ import { DEFAULT_KEY_MAP, importCodeMirror, } from './common'; -import { CodeMirrorEditorWithOperationFacts, useEditorStore } from './context'; +import { + CodeMirrorEditorWithOperationFacts, + useEditorContext, +} from './context'; import { useCompletion, useCopyQuery, @@ -136,12 +139,19 @@ export function useQueryEditor( validationRules, variableEditor, updateActiveTabValues, - } = useEditorStore(); + } = useEditorContext({ + nonNull: true, + caller: caller || _useQueryEditor, + }); + const executionContext = useExecutionContext(); const storage = useStorage(); const plugin = usePluginStore(); const copy = useCopyQuery({ caller: caller || _useQueryEditor, onCopyQuery }); - const merge = useMergeQuery(); - const prettify = usePrettifyEditors({ onPrettifyQuery }); + const merge = useMergeQuery({ caller: caller || _useQueryEditor }); + const prettify = usePrettifyEditors({ + caller: caller || _useQueryEditor, + onPrettifyQuery, + }); const ref = useRef(null); const codeMirrorRef = useRef(undefined); @@ -388,10 +398,15 @@ export function useQueryEditor( useCompletion(queryEditor, onClickReference); + const run = executionContext?.run; const runAtCursor = () => { - const { run } = executionStore.getState(); - - if (!queryEditor || !queryEditor.operations || !queryEditor.hasFocus()) { + if ( + !run || + !queryEditor || + !queryEditor.operations || + !queryEditor.hasFocus() + ) { + run?.(); return; } diff --git a/packages/graphiql-react/src/editor/response-editor.tsx b/packages/graphiql-react/src/editor/response-editor.tsx index c21bf96c643..5c5e8b25512 100644 --- a/packages/graphiql-react/src/editor/response-editor.tsx +++ b/packages/graphiql-react/src/editor/response-editor.tsx @@ -11,7 +11,7 @@ import { importCodeMirror, } from './common'; import { ImagePreview } from './components'; -import { useEditorStore } from './context'; +import { useEditorContext } from './context'; import { useSynchronizeOption } from './hooks'; import { CodeMirrorEditor, CommonEditorProps } from './types'; @@ -52,14 +52,23 @@ function importCodeMirrorImports() { ); } -export function useResponseEditor({ - responseTooltip, - editorTheme = DEFAULT_EDITOR_THEME, - keyMap = DEFAULT_KEY_MAP, -}: UseResponseEditorArgs = {}) { +// To make react-compiler happy, otherwise complains about - Hooks may not be referenced as normal values +const _useResponseEditor = useResponseEditor; + +export function useResponseEditor( + { + responseTooltip, + editorTheme = DEFAULT_EDITOR_THEME, + keyMap = DEFAULT_KEY_MAP, + }: UseResponseEditorArgs = {}, + caller?: Function, +) { const { fetchError, validationErrors } = useSchemaStore(); const { initialResponse, responseEditor, setResponseEditor } = - useEditorStore(); + useEditorContext({ + nonNull: true, + caller: caller || _useResponseEditor, + }); const ref = useRef(null); const responseTooltipRef = useRef( diff --git a/packages/graphiql-react/src/editor/tabs.ts b/packages/graphiql-react/src/editor/tabs.ts index e8e58f129b9..14caa4b9cd8 100644 --- a/packages/graphiql-react/src/editor/tabs.ts +++ b/packages/graphiql-react/src/editor/tabs.ts @@ -1,8 +1,12 @@ 'use no memo'; // can't figure why it isn't optimized import { storageStore } from '../storage'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports -- fixme +import { useCallback } from 'react'; + import { debounce } from '../utility/debounce'; -import { editorStore } from './context'; +import { CodeMirrorEditorWithOperationFacts } from './context'; +import { CodeMirrorEditor } from './types'; export type TabDefinition = { /** @@ -186,16 +190,34 @@ function hasStringOrNullKey(obj: Record, key: string) { return key in obj && (typeof obj[key] === 'string' || obj[key] === null); } -export function synchronizeActiveTabValues(state: TabsState): TabsState { - const { queryEditor, variableEditor, headerEditor, responseEditor } = - editorStore.getState(); - return setPropertiesInActiveTab(state, { - query: queryEditor?.getValue() ?? null, - variables: variableEditor?.getValue() ?? null, - headers: headerEditor?.getValue() ?? null, - response: responseEditor?.getValue() ?? null, - operationName: queryEditor?.operationName ?? null, - }); +export function useSynchronizeActiveTabValues({ + queryEditor, + variableEditor, + headerEditor, + responseEditor, +}: { + queryEditor: CodeMirrorEditorWithOperationFacts | null; + variableEditor: CodeMirrorEditor | null; + headerEditor: CodeMirrorEditor | null; + responseEditor: CodeMirrorEditor | null; +}) { + return useCallback<(state: TabsState) => TabsState>( + state => { + const query = queryEditor?.getValue() ?? null; + const variables = variableEditor?.getValue() ?? null; + const headers = headerEditor?.getValue() ?? null; + const operationName = queryEditor?.operationName ?? null; + const response = responseEditor?.getValue() ?? null; + return setPropertiesInActiveTab(state, { + query, + variables, + headers, + response, + operationName, + }); + }, + [queryEditor, variableEditor, headerEditor, responseEditor], + ); } export function serializeTabState( @@ -211,38 +233,55 @@ export function serializeTabState( ); } -export function storeTabs({ tabs, activeTabIndex }: TabsState) { - const { storage } = storageStore.getState(); - const { shouldPersistHeaders } = editorStore.getState(); - const store = debounce(500, (value: string) => { - storage.set(STORAGE_KEY, value); - }); - store(serializeTabState({ tabs, activeTabIndex }, shouldPersistHeaders)); +export function useStoreTabs({ + shouldPersistHeaders, +}: { + shouldPersistHeaders?: boolean; +}) { + return useCallback( + (currentState: TabsState) => { + const { storage } = storageStore.getState(); + const store = debounce(500, (value: string) => { + storage.set(STORAGE_KEY, value); + }); + store(serializeTabState(currentState, shouldPersistHeaders)); + }, + [shouldPersistHeaders], + ); } -export function setEditorValues({ - query, - variables, - headers, - response, +export function useSetEditorValues({ + queryEditor, + variableEditor, + headerEditor, + responseEditor, + defaultHeaders, }: { - query: string | null; - variables?: string | null; - headers?: string | null; - response: string | null; + queryEditor: CodeMirrorEditorWithOperationFacts | null; + variableEditor: CodeMirrorEditor | null; + headerEditor: CodeMirrorEditor | null; + responseEditor: CodeMirrorEditor | null; + defaultHeaders?: string; }) { - const { - queryEditor, - variableEditor, - headerEditor, - responseEditor, - defaultHeaders, - } = editorStore.getState(); - - queryEditor?.setValue(query ?? ''); - variableEditor?.setValue(variables ?? ''); - headerEditor?.setValue(headers ?? defaultHeaders ?? ''); - responseEditor?.setValue(response ?? ''); + return useCallback( + ({ + query, + variables, + headers, + response, + }: { + query: string | null; + variables?: string | null; + headers?: string | null; + response: string | null; + }) => { + queryEditor?.setValue(query ?? ''); + variableEditor?.setValue(variables ?? ''); + headerEditor?.setValue(headers ?? defaultHeaders ?? ''); + responseEditor?.setValue(response ?? ''); + }, + [headerEditor, queryEditor, responseEditor, variableEditor, defaultHeaders], + ); } export function createTab({ diff --git a/packages/graphiql-react/src/editor/variable-editor.ts b/packages/graphiql-react/src/editor/variable-editor.ts index b78f6b6ac89..1ecdf0bf7e2 100644 --- a/packages/graphiql-react/src/editor/variable-editor.ts +++ b/packages/graphiql-react/src/editor/variable-editor.ts @@ -1,14 +1,14 @@ import type { SchemaReference } from 'codemirror-graphql/utils/SchemaReference'; import { useEffect, useRef } from 'react'; -import { useExecutionStore } from '../execution'; +import { useExecutionContext } from '../execution'; import { commonKeys, DEFAULT_EDITOR_THEME, DEFAULT_KEY_MAP, importCodeMirror, } from './common'; -import { useEditorStore } from './context'; +import { useEditorContext } from './context'; import { useChangeHandler, useCompletion, @@ -42,18 +42,27 @@ function importCodeMirrorImports() { ]); } -export function useVariableEditor({ - editorTheme = DEFAULT_EDITOR_THEME, - keyMap = DEFAULT_KEY_MAP, - onClickReference, - onEdit, - readOnly = false, -}: UseVariableEditorArgs = {}) { +// To make react-compiler happy, otherwise complains about - Hooks may not be referenced as normal values +const _useVariableEditor = useVariableEditor; + +export function useVariableEditor( + { + editorTheme = DEFAULT_EDITOR_THEME, + keyMap = DEFAULT_KEY_MAP, + onClickReference, + onEdit, + readOnly = false, + }: UseVariableEditorArgs = {}, + caller?: Function, +) { const { initialVariables, variableEditor, setVariableEditor } = - useEditorStore(); - const { run } = useExecutionStore(); - const merge = useMergeQuery(); - const prettify = usePrettifyEditors(); + useEditorContext({ + nonNull: true, + caller: caller || _useVariableEditor, + }); + const executionContext = useExecutionContext(); + const merge = useMergeQuery({ caller: caller || _useVariableEditor }); + const prettify = usePrettifyEditors({ caller: caller || _useVariableEditor }); const ref = useRef(null); useEffect(() => { let isActive = true; @@ -128,11 +137,17 @@ export function useVariableEditor({ useSynchronizeOption(variableEditor, 'keyMap', keyMap); - useChangeHandler(variableEditor, onEdit, STORAGE_KEY, 'variables'); + useChangeHandler( + variableEditor, + onEdit, + STORAGE_KEY, + 'variables', + _useVariableEditor, + ); useCompletion(variableEditor, onClickReference); - useKeyMap(variableEditor, ['Cmd-Enter', 'Ctrl-Enter'], run); + useKeyMap(variableEditor, ['Cmd-Enter', 'Ctrl-Enter'], executionContext?.run); useKeyMap(variableEditor, ['Shift-Ctrl-P'], prettify); useKeyMap(variableEditor, ['Shift-Ctrl-M'], merge); diff --git a/packages/graphiql-react/src/execution.tsx b/packages/graphiql-react/src/execution.tsx index 90e171572d5..605790b9266 100644 --- a/packages/graphiql-react/src/execution.tsx +++ b/packages/graphiql-react/src/execution.tsx @@ -1,9 +1,7 @@ -// eslint-disable-next-line react/jsx-filename-extension -- TODO import { Fetcher, formatError, formatResult, - GetDefaultFieldNamesFn, isAsyncIterable, isObservable, Unsubscribable, @@ -15,38 +13,29 @@ import { print, } from 'graphql'; import { getFragmentDependenciesForAST } from 'graphql-language-service'; -import { FC, ReactElement, ReactNode, useEffect } from 'react'; +import { FC, ReactNode, useRef, useState } from 'react'; import setValue from 'set-value'; import getValue from 'get-value'; -import { getAutoCompleteLeafs } from './editor'; -import { createStore } from 'zustand'; -import { editorStore } from './editor/context'; -import { schemaStore } from './schema'; -import { createBoundedUseStore } from './utility'; +import { useAutoCompleteLeafs, useEditorContext } from './editor'; +import { UseAutoCompleteLeafsArgs } from './editor/hooks'; +import { createContextHook, createNullableContext } from './utility/context'; export type ExecutionContextType = { /** * If there is currently a GraphQL request in-flight. For multipart * requests like subscriptions, this will be `true` while fetching the * first partial response and `false` while fetching subsequent batches. - * @default false */ isFetching: boolean; /** - * Represents an active GraphQL subscription. - * - * For multipart operations such as subscriptions, this - * will hold an `Unsubscribable` object while the request is in-flight. It - * remains non-null until the operation completes or is manually unsubscribed. - * - * @remarks Use `subscription?.unsubscribe()` to cancel the request. - * @default null + * If there is currently a GraphQL request in-flight. For multipart + * requests like subscriptions, this will be `true` until the last batch + * has been fetched or the connection is closed from the client. */ - subscription: Unsubscribable | null; + isSubscribed: boolean; /** * The operation name that will be sent with all GraphQL requests. - * @default null */ operationName: string | null; /** @@ -57,20 +46,13 @@ export type ExecutionContextType = { * Stop the GraphQL request that is currently in-flight. */ stop(): void; - /** - * A function to determine which field leafs are automatically added when - * trying to execute a query with missing selection sets. It will be called - * with the `GraphQLType` for which fields need to be added. - */ - getDefaultFieldNames?: GetDefaultFieldNamesFn; - /** - * @default 0 - */ - queryId: number; }; +export const ExecutionContext = + createNullableContext('ExecutionContext'); + type ExecutionContextProviderProps = Pick< - ExecutionContextType, + UseAutoCompleteLeafsArgs, 'getDefaultFieldNames' > & { children: ReactNode; @@ -91,33 +73,44 @@ type ExecutionContextProviderProps = Pick< operationName?: string; }; -export const executionStore = createStore< - ExecutionContextType & - Pick ->((set, get) => ({ - isFetching: false, - subscription: null, - operationName: null, - getDefaultFieldNames: undefined, - queryId: 0, - stop() { - const { subscription } = get(); +export const ExecutionContextProvider: FC = ({ + fetcher, + getDefaultFieldNames, + children, + operationName, +}) => { + if (typeof fetcher !== 'function') { + throw new TypeError( + 'The `ExecutionContextProvider` component requires a `fetcher` function to be passed as prop.', + ); + } + + const { + externalFragments, + headerEditor, + queryEditor, + responseEditor, + variableEditor, + updateActiveTabValues, + } = useEditorContext({ nonNull: true, caller: ExecutionContextProvider }); + const autoCompleteLeafs = useAutoCompleteLeafs({ + getDefaultFieldNames, + caller: ExecutionContextProvider, + }); + const [isFetching, setIsFetching] = useState(false); + const [subscription, setSubscription] = useState(null); + const queryIdRef = useRef(0); + + const stop = () => { subscription?.unsubscribe(); - set({ isFetching: false, subscription: null }); - }, - async run() { - const { - externalFragments, - headerEditor, - queryEditor, - responseEditor, - variableEditor, - updateActiveTabValues, - } = editorStore.getState(); + setIsFetching(false); + setSubscription(null); + }; + + const run: ExecutionContextType['run'] = async () => { if (!queryEditor || !responseEditor) { return; } - const { subscription, operationName, queryId } = get(); // If there's an active subscription, unsubscribe it and return if (subscription) { @@ -130,13 +123,13 @@ export const executionStore = createStore< updateActiveTabValues({ response: value }); }; - const newQueryId = queryId + 1; - set({ queryId: newQueryId }); + queryIdRef.current += 1; + const queryId = queryIdRef.current; // Use the edited query after autoCompleteLeafs() runs or, // in case autoCompletion fails (the function returns undefined), // the current query from the editor. - let query = getAutoCompleteLeafs() || queryEditor.getValue(); + let query = autoCompleteLeafs() || queryEditor.getValue(); const variablesString = variableEditor?.getValue(); let variables: Record | undefined; @@ -181,13 +174,17 @@ export const executionStore = createStore< } setResponse(''); - set({ isFetching: true }); + setIsFetching(true); + // Can't be moved in try-catch since react-compiler throw `Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement` + const opName = operationName ?? queryEditor.operationName ?? undefined; + const _headers = headers ?? undefined; + const documentAST = queryEditor.documentAST ?? undefined; try { const fullResponse: ExecutionResult = {}; const handleResponse = (result: ExecutionResult) => { // A different query was dispatched in the meantime, so don't // show the results of this one. - if (newQueryId !== get().queryId) { + if (queryId !== queryIdRef.current) { return; } @@ -206,24 +203,24 @@ export const executionStore = createStore< mergeIncrementalResult(fullResponse, part); } - set({ isFetching: false }); + setIsFetching(false); setResponse(formatResult(fullResponse)); } else { - set({ isFetching: false }); - setResponse(formatResult(result)); + const response = formatResult(result); + setIsFetching(false); + setResponse(response); } }; - const { fetcher } = schemaStore.getState(); + const fetch = fetcher( { query, variables, - operationName: - operationName ?? queryEditor.operationName ?? undefined, + operationName: opName, }, { - headers: headers ?? undefined, - documentAST: queryEditor.documentAST ?? undefined, + headers: _headers, + documentAST, }, ); @@ -232,71 +229,73 @@ export const executionStore = createStore< // If the fetcher returned an Observable, then subscribe to it, calling // the callback on each next value and handling both errors and the // completion of the Observable. - const newSubscription = value.subscribe({ - next(result) { - handleResponse(result); - }, - error(error: Error) { - set({ isFetching: false }); - if (error) { - setResponse(formatError(error)); - } - set({ subscription: null }); - }, - complete() { - set({ isFetching: false, subscription: null }); - }, - }); - set({ subscription: newSubscription }); + setSubscription( + value.subscribe({ + next(result) { + handleResponse(result); + }, + error(error: Error) { + setIsFetching(false); + if (error) { + setResponse(formatError(error)); + } + setSubscription(null); + }, + complete() { + setIsFetching(false); + setSubscription(null); + }, + }), + ); } else if (isAsyncIterable(value)) { - const newSubscription = { + setSubscription({ unsubscribe: () => value[Symbol.asyncIterator]().return?.(), - }; - set({ subscription: newSubscription }); - for await (const result of value) { - handleResponse(result); - } - set({ isFetching: false, subscription: null }); + }); + await handleAsyncResults(handleResponse, value); + setIsFetching(false); + setSubscription(null); } else { handleResponse(value); } } catch (error) { - set({ isFetching: false }); + setIsFetching(false); setResponse(formatError(error)); - set({ subscription: null }); + setSubscription(null); } - }, -})); + }; + const value: ExecutionContextType = { + isFetching, + isSubscribed: Boolean(subscription), + operationName: operationName ?? null, + run, + stop, + }; + + return ( + + {children} + + ); +}; -export const ExecutionContextProvider: FC = ({ - fetcher, - getDefaultFieldNames, - children, - operationName = null, -}) => { - if (!fetcher) { - throw new TypeError( - 'The `ExecutionContextProvider` component requires a `fetcher` function to be passed as prop.', - ); +// Extract function because react-compiler doesn't support `for await` yet +async function handleAsyncResults( + onResponse: (result: ExecutionResult) => void, + value: any, +): Promise { + for await (const result of value) { + onResponse(result); } - useEffect(() => { - executionStore.setState({ - operationName, - getDefaultFieldNames, - }); - }, [getDefaultFieldNames, operationName]); - - return children as ReactElement; -}; +} -export const useExecutionStore = createBoundedUseStore(executionStore); +export const useExecutionContext = createContextHook(ExecutionContext); function tryParseJsonObject({ json, errorMessageParse, errorMessageType, }: { - json?: string; + json: string | undefined; errorMessageParse: string; errorMessageType: string; }) { diff --git a/packages/graphiql-react/src/index.ts b/packages/graphiql-react/src/index.ts index b1e8ee02073..afc7edefce2 100644 --- a/packages/graphiql-react/src/index.ts +++ b/packages/graphiql-react/src/index.ts @@ -1,14 +1,15 @@ import './style/root.css'; export { + EditorContext, EditorContextProvider, HeaderEditor, ImagePreview, QueryEditor, ResponseEditor, - getAutoCompleteLeafs, + useAutoCompleteLeafs, useCopyQuery, - useEditorStore, + useEditorContext, useHeaderEditor, useMergeQuery, usePrettifyEditors, @@ -22,7 +23,11 @@ export { useHeadersEditorState, VariableEditor, } from './editor'; -export { ExecutionContextProvider, useExecutionStore } from './execution'; +export { + ExecutionContext, + ExecutionContextProvider, + useExecutionContext, +} from './execution'; export { PluginContextProvider, usePluginStore } from './plugin'; export { GraphiQLProvider } from './provider'; export { SchemaContextProvider, useSchemaStore } from './schema'; @@ -36,6 +41,7 @@ export * from './toolbar'; export type { CommonEditorProps, + EditorContextType, KeyMap, ResponseTooltipType, TabsState, diff --git a/packages/graphiql-react/src/schema.ts b/packages/graphiql-react/src/schema.ts index 14c943c4350..18c6dbf979e 100644 --- a/packages/graphiql-react/src/schema.ts +++ b/packages/graphiql-react/src/schema.ts @@ -17,7 +17,7 @@ import { } from 'graphql'; import { Dispatch, FC, ReactElement, ReactNode, useEffect } from 'react'; import { createStore } from 'zustand'; -import { editorStore } from './editor/context'; +import { useEditorContext } from './editor'; import type { SchemaReference } from 'codemirror-graphql/utils/SchemaReference'; import { createBoundedUseStore } from './utility'; @@ -62,6 +62,8 @@ export const schemaStore = createStore((set, get) => ({ fetcher, onSchemaChange, shouldIntrospect, + // @ts-expect-error -- temporally until v 5 + headerEditor, ...rest } = get(); @@ -77,7 +79,6 @@ export const schemaStore = createStore((set, get) => ({ set({ requestCounter: counter }); try { - const { headerEditor } = editorStore.getState(); const currentHeaders = headerEditor?.getValue(); const parsedHeaders = parseHeaderString(currentHeaders); if (!parsedHeaders.isValidJSON) { @@ -290,6 +291,18 @@ export const SchemaContextProvider: FC = ({ 'The `SchemaContextProvider` component requires a `fetcher` function to be passed as prop.', ); } + const { headerEditor } = useEditorContext({ + nonNull: true, + caller: SchemaContextProvider, + }); + + useEffect(() => { + if (headerEditor) { + // @ts-expect-error -- temporally until v5, to fix https://github.com/graphql/graphiql/issues/3969 + schemaStore.setState({ headerEditor }); + } + }, [headerEditor]); + /** * Synchronize prop changes with state */ diff --git a/packages/graphiql-react/src/toolbar/execute.tsx b/packages/graphiql-react/src/toolbar/execute.tsx index 9addafbb2a9..6d292062019 100644 --- a/packages/graphiql-react/src/toolbar/execute.tsx +++ b/packages/graphiql-react/src/toolbar/execute.tsx @@ -1,18 +1,24 @@ import { FC } from 'react'; -import { useEditorStore } from '../editor'; -import { useExecutionStore } from '../execution'; +import { useEditorContext } from '../editor'; +import { useExecutionContext } from '../execution'; import { PlayIcon, StopIcon } from '../icons'; import { DropdownMenu, Tooltip } from '../ui'; import './execute.css'; export const ExecuteButton: FC = () => { - const { queryEditor, setOperationName } = useEditorStore(); - const { isFetching, subscription, operationName, run, stop } = - useExecutionStore(); + const { queryEditor, setOperationName } = useEditorContext({ + nonNull: true, + caller: ExecuteButton, + }); + const { isFetching, isSubscribed, operationName, run, stop } = + useExecutionContext({ + nonNull: true, + caller: ExecuteButton, + }); const operations = queryEditor?.operations || []; const hasOptions = operations.length > 1 && typeof operationName !== 'string'; - const isRunning = isFetching || Boolean(subscription); + const isRunning = isFetching || isSubscribed; const label = `${isRunning ? 'Stop' : 'Execute'} query (Ctrl-Enter)`; const buttonProps = { diff --git a/packages/graphiql-react/src/utility/context.ts b/packages/graphiql-react/src/utility/context.ts new file mode 100644 index 00000000000..5d4403234fe --- /dev/null +++ b/packages/graphiql-react/src/utility/context.ts @@ -0,0 +1,38 @@ +'use no memo'; + +import { Context, createContext, useContext } from 'react'; + +export function createNullableContext(name: string): Context { + const context = createContext(null); + context.displayName = name; + return context; +} + +export function createContextHook(context: Context) { + function useGivenContext(options: { nonNull: true; caller?: Function }): T; + function useGivenContext(options: { + nonNull?: boolean; + caller?: Function; + }): T | null; + function useGivenContext(): T | null; + function useGivenContext(options?: { + nonNull?: boolean; + caller?: Function; + }): T | null { + const value = useContext(context); + if (value === null && options?.nonNull) { + throw new Error( + `Tried to use \`${ + options.caller?.name || 'a component' + }\` without the necessary context. Make sure to render the \`${ + context.displayName + }Provider\` component higher up the tree.`, + ); + } + return value; + } + Object.defineProperty(useGivenContext, 'name', { + value: `use${context.displayName}`, + }); + return useGivenContext; +} diff --git a/packages/graphiql-react/src/utility/index.ts b/packages/graphiql-react/src/utility/index.ts index 9f73b06bc03..6970bf2b61c 100644 --- a/packages/graphiql-react/src/utility/index.ts +++ b/packages/graphiql-react/src/utility/index.ts @@ -1,4 +1,5 @@ export { createBoundedUseStore } from './create-bounded-use-store'; +export { createNullableContext, createContextHook } from './context'; export { debounce } from './debounce'; export { isMacOs } from './is-macos'; export { useDragResize } from './resize'; diff --git a/packages/graphiql-toolkit/src/create-fetcher/types.ts b/packages/graphiql-toolkit/src/create-fetcher/types.ts index f44d0b6a315..9ae06a67beb 100644 --- a/packages/graphiql-toolkit/src/create-fetcher/types.ts +++ b/packages/graphiql-toolkit/src/create-fetcher/types.ts @@ -19,7 +19,7 @@ export type Observable = { ): Unsubscribable; }; -// This type just taken from https://github.com/ReactiveX/rxjs/blob/master/src/internal/types.ts#L41 +// These type just taken from https://github.com/ReactiveX/rxjs/blob/master/src/internal/types.ts#L41 export type Unsubscribable = { unsubscribe: () => void; }; diff --git a/packages/graphiql/src/GraphiQL.tsx b/packages/graphiql/src/GraphiQL.tsx index b705ac33138..1bd1d32b3ad 100644 --- a/packages/graphiql/src/GraphiQL.tsx +++ b/packages/graphiql/src/GraphiQL.tsx @@ -38,8 +38,8 @@ import { UnStyledButton, useCopyQuery, useDragResize, - useEditorStore, - useExecutionStore, + useEditorContext, + useExecutionContext, UseHeaderEditorArgs, useMergeQuery, usePluginStore, @@ -238,8 +238,8 @@ export const GraphiQLInterface: FC = props => { shouldPersistHeaders, tabs, activeTabIndex, - } = useEditorStore(); - const executionContext = useExecutionStore(); + } = useEditorContext({ nonNull: true }); + const executionContext = useExecutionContext({ nonNull: true }); const { isFetching: isSchemaFetching, introspect } = useSchemaStore(); const storageContext = useStorage(); const { visiblePlugin, setVisiblePlugin, plugins } = usePluginStore();