From 8cdad2db0052e2679e207b47d0a915bd68b5a269 Mon Sep 17 00:00:00 2001 From: Dimitri POSTOLOV Date: Thu, 29 May 2025 15:14:55 +0200 Subject: [PATCH] =?UTF-8?q?Revert=20"revert=20https://github.com/graphql/g?= =?UTF-8?q?raphiql/pull/3946=20to=20have=20support=20=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 70545912d1b3bb9e0c45e766a5c89896a9c4dfb7. --- .../src/context.ts | 2 +- .../graphiql-plugin-explorer/src/index.tsx | 8 +- .../src/__tests__/components.spec.tsx | 16 +- .../src/components.tsx | 7 +- .../graphiql-plugin-history/src/context.tsx | 8 +- .../src/editor/components/header-editor.tsx | 9 +- .../src/editor/components/response-editor.tsx | 2 +- .../src/editor/components/variable-editor.tsx | 9 +- .../graphiql-react/src/editor/context.tsx | 478 +++++++++--------- .../src/editor/header-editor.ts | 34 +- packages/graphiql-react/src/editor/hooks.ts | 167 ++---- packages/graphiql-react/src/editor/index.ts | 9 +- .../graphiql-react/src/editor/query-editor.ts | 31 +- .../src/editor/response-editor.tsx | 23 +- packages/graphiql-react/src/editor/tabs.ts | 117 ++--- .../src/editor/variable-editor.ts | 45 +- packages/graphiql-react/src/execution.tsx | 227 ++++----- packages/graphiql-react/src/index.ts | 12 +- packages/graphiql-react/src/schema.ts | 17 +- .../graphiql-react/src/toolbar/execute.tsx | 18 +- .../graphiql-react/src/utility/context.ts | 38 -- packages/graphiql-react/src/utility/index.ts | 1 - .../src/create-fetcher/types.ts | 2 +- packages/graphiql/src/GraphiQL.tsx | 8 +- 24 files changed, 544 insertions(+), 744 deletions(-) delete mode 100644 packages/graphiql-react/src/utility/context.ts diff --git a/packages/graphiql-plugin-doc-explorer/src/context.ts b/packages/graphiql-plugin-doc-explorer/src/context.ts index 380cec0e8a2..f00ace927a8 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 2a1704be668..8e35d6cd870 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, - useEditorContext, - useExecutionContext, + useEditorStore, + useExecutionStore, useSchemaStore, useOperationsEditorState, useOptimisticState, @@ -62,9 +62,9 @@ export type GraphiQLExplorerPluginProps = Omit< >; const ExplorerPlugin: FC = props => { - const { setOperationName } = useEditorContext({ nonNull: true }); + const { setOperationName } = useEditorStore(); const { schema } = useSchemaStore(); - const { run } = useExecutionContext({ nonNull: true }); + const { run } = useExecutionStore(); // 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 749ccdcb3ae..d0d913bb2ed 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 { - useEditorContext, + useEditorStore, Tooltip, StorageContextProvider, } from '@graphiql/react'; @@ -16,7 +16,7 @@ vi.mock('@graphiql/react', async () => { const mockedSetHeaderEditor = vi.fn(); return { ...originalModule, - useEditorContext() { + useEditorStore() { return { queryEditor: { setValue: mockedSetQueryEditor }, variableEditor: { setValue: mockedSetVariableEditor }, @@ -24,7 +24,7 @@ vi.mock('@graphiql/react', async () => { tabs: [], }; }, - useExecutionContext() { + useExecutionStore() { return {}; }, }; @@ -78,12 +78,10 @@ function getMockProps( } describe('QueryHistoryItem', () => { - const mockedSetQueryEditor = useEditorContext()!.queryEditor! - .setValue as Mock; - const mockedSetVariableEditor = useEditorContext()!.variableEditor! - .setValue as Mock; - const mockedSetHeaderEditor = useEditorContext()!.headerEditor! - .setValue as Mock; + const store = useEditorStore(); + const mockedSetQueryEditor = store.queryEditor!.setValue as Mock; + const mockedSetVariableEditor = store.variableEditor!.setValue as Mock; + const mockedSetHeaderEditor = store.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 e2e54d86e9a..bd379ca62ee 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, - useEditorContext, + useEditorStore, Button, Tooltip, UnStyledButton, @@ -112,10 +112,7 @@ type QueryHistoryItemProps = { export const HistoryItem: FC = props => { const { editLabel, toggleFavorite, deleteFromHistory, setActive } = useHistoryActions(); - const { headerEditor, queryEditor, variableEditor } = useEditorContext({ - nonNull: true, - caller: HistoryItem, - }); + const { headerEditor, queryEditor, variableEditor } = useEditorStore(); 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 14bc898e00c..c8580f1c697 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 { - useExecutionContext, - useEditorContext, + useExecutionStore, + useEditorStore, useStorage, createBoundedUseStore, } from '@graphiql/react'; @@ -119,8 +119,8 @@ export const HistoryContextProvider: FC = ({ maxHistoryLength = 20, children, }) => { - const { isFetching } = useExecutionContext({ nonNull: true }); - const { tabs, activeTabIndex } = useEditorContext({ nonNull: true }); + const { isFetching } = useExecutionStore(); + const { tabs, activeTabIndex } = useEditorStore(); 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 31c9779d748..3652b96f7e2 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 { useEditorContext } from '../context'; +import { useEditorStore } from '../context'; import { useHeaderEditor, UseHeaderEditorArgs } from '../header-editor'; import '../style/codemirror.css'; import '../style/fold.css'; @@ -18,11 +18,8 @@ export const HeaderEditor: FC = ({ isHidden, ...hookArgs }) => { - const { headerEditor } = useEditorContext({ - nonNull: true, - caller: HeaderEditor, - }); - const ref = useHeaderEditor(hookArgs, HeaderEditor); + const headerEditor = useEditorStore(store => store.headerEditor); + const ref = useHeaderEditor(hookArgs); 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 894dcc6f70a..fb7c98ecc53 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, ResponseEditor); + const ref = useResponseEditor(props); return (
= ({ isHidden, ...hookArgs }) => { - const { variableEditor } = useEditorContext({ - nonNull: true, - caller: VariableEditor, - }); - const ref = useVariableEditor(hookArgs, VariableEditor); + const variableEditor = useEditorStore(store => store.variableEditor); + const ref = useVariableEditor(hookArgs); useEffect(() => { if (!isHidden) { diff --git a/packages/graphiql-react/src/editor/context.tsx b/packages/graphiql-react/src/editor/context.tsx index c660e5fcb33..d275756220b 100644 --- a/packages/graphiql-react/src/editor/context.tsx +++ b/packages/graphiql-react/src/editor/context.tsx @@ -1,3 +1,4 @@ +// eslint-disable-next-line react/jsx-filename-extension -- TODO import { DocumentNode, FragmentDefinitionNode, @@ -7,10 +8,9 @@ import { visit, } from 'graphql'; import { VariableToType } from 'graphql-language-service'; -import { FC, ReactNode, useEffect, useRef, useState } from 'react'; +import { FC, ReactElement, ReactNode, useEffect, useRef } from 'react'; -import { useStorage } from '../storage'; -import { createContextHook, createNullableContext } from '../utility/context'; +import { storageStore, useStorage } from '../storage'; 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, - useSetEditorValues, - useStoreTabs, - useSynchronizeActiveTabValues, + setEditorValues, + storeTabs, + synchronizeActiveTabValues, clearHeadersFromTabs, serializeTabState, STORAGE_KEY as STORAGE_KEY_TABS, @@ -31,6 +31,8 @@ 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; @@ -39,21 +41,24 @@ export type CodeMirrorEditorWithOperationFacts = CodeMirrorEditor & { variableToType: VariableToType | null; }; -export type EditorContextType = TabsState & { +interface EditorStore extends 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 @@ -61,6 +66,7 @@ export type EditorContextType = 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 @@ -90,18 +96,22 @@ export type EditorContextType = 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. */ @@ -116,21 +126,25 @@ export type EditorContextType = 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; /** @@ -138,27 +152,17 @@ export type EditorContextType = 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: @@ -168,6 +172,43 @@ type EditorContextProviderProps = { * 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 @@ -198,23 +239,6 @@ type EditorContextProviderProps = { *``` */ 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 @@ -248,119 +272,15 @@ type EditorContextProviderProps = { * typing in the editor. */ variables?: string; - - /** - * Headers to be set when opening a new tab - */ - defaultHeaders?: string; }; -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); +export const editorStore = createStore((set, get) => ({ + tabs: null!, + activeTabIndex: null!, + addTab() { + set(current => { + const { defaultQuery, defaultHeaders, onTabChange } = get(); - 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 = { @@ -368,7 +288,7 @@ export const EditorContextProvider: FC = props => { ...updatedValues.tabs, createTab({ headers: defaultHeaders, - query: defaultQuery ?? DEFAULT_QUERY, + query: defaultQuery, }), ], activeTabIndex: updatedValues.tabs.length, @@ -378,10 +298,10 @@ export const EditorContextProvider: FC = props => { onTabChange?.(updated); return updated; }); - }; - - const changeTab: EditorContextType['changeTab'] = index => { - setTabState(current => { + }, + changeTab(index) { + set(current => { + const { onTabChange } = get(); const updated = { ...current, activeTabIndex: index, @@ -391,10 +311,10 @@ export const EditorContextProvider: FC = props => { onTabChange?.(updated); return updated; }); - }; - - const moveTab: EditorContextType['moveTab'] = newOrder => { - setTabState(current => { + }, + moveTab(newOrder) { + set(current => { + const { onTabChange } = get(); const activeTab = current.tabs[current.activeTabIndex]; const updated = { tabs: newOrder, @@ -405,10 +325,10 @@ export const EditorContextProvider: FC = props => { onTabChange?.(updated); return updated; }); - }; - - const closeTab: EditorContextType['closeTab'] = index => { - setTabState(current => { + }, + closeTab(index) { + set(current => { + const { onTabChange } = get(); const updated = { tabs: current.tabs.filter((_tab, i) => index !== i), activeTabIndex: Math.max(current.activeTabIndex - 1, 0), @@ -418,43 +338,120 @@ export const EditorContextProvider: FC = props => { onTabChange?.(updated); return updated; }); - }; - - const updateActiveTabValues: EditorContextType['updateActiveTabValues'] = - partialTab => { - setTabState(current => { - const updated = setPropertiesInActiveTab(current, partialTab); - storeTabs(updated); - onTabChange?.(updated); - return updated; - }); - }; - - const { onEditOperationName } = props; - const setOperationName: EditorContextType['setOperationName'] = - operationName => { - if (!queryEditor) { - return; + }, + 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 headerEditor = useEditorStore(store => store.headerEditor); + const queryEditor = useEditorStore(store => store.queryEditor); + const responseEditor = useEditorStore(store => store.responseEditor); + const variableEditor = useEditorStore(store => store.variableEditor); - updateQueryEditor(queryEditor, operationName); - updateActiveTabValues({ operationName }); - onEditOperationName?.(operationName); - }; + useSynchronizeValue(headerEditor, props.headers); + useSynchronizeValue(queryEditor, props.query); + useSynchronizeValue(responseEditor, props.response); + useSynchronizeValue(variableEditor, props.variables); - const externalFragments = (() => { + // TODO: + // const lastShouldPersistHeadersProp = useRef(undefined); + // useEffect(() => { + // const propValue = shouldPersistHeaders; + // if (lastShouldPersistHeadersProp.current !== propValue) { + // editorStore.getState().setShouldPersistHeaders(propValue); + // lastShouldPersistHeadersProp.current = propValue; + // } + // }, [shouldPersistHeaders]); + + const $externalFragments = (() => { const map = new Map(); - if (Array.isArray(props.externalFragments)) { - for (const fragment of props.externalFragments) { + if (Array.isArray(externalFragments)) { + for (const fragment of externalFragments) { map.set(fragment.name.value, fragment); } - } else if (typeof props.externalFragments === 'string') { - visit(parse(props.externalFragments, {}), { + } else if (typeof externalFragments === 'string') { + visit(parse(externalFragments, {}), { FragmentDefinition(fragment) { map.set(fragment.name.value, fragment); }, }); - } else if (props.externalFragments) { + } else if (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.', ); @@ -462,52 +459,77 @@ export const EditorContextProvider: FC = props => { return map; })(); - 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, + const initialRendered = useRef(false); - shouldPersistHeaders, - setShouldPersistHeaders, - }; + useEffect(() => { + if (initialRendered.current) { + return; + } + initialRendered.current = true; - return ( - {children} - ); -}; + // 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 ?? ''; -// To make react-compiler happy, otherwise it fails due to mutating props -function updateQueryEditor( - queryEditor: CodeMirrorEditorWithOperationFacts, - operationName: string, -) { - queryEditor.operationName = operationName; -} + 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, + validationRules, + ]); + + if (!isMounted) { + // Ensure store was initialized + return null; + } + return children as ReactElement; +}; -export const useEditorContext = createContextHook(EditorContext); +export const useEditorStore = createBoundedUseStore(editorStore); 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 e9e19a74bee..5a84eabe9ed 100644 --- a/packages/graphiql-react/src/editor/header-editor.ts +++ b/packages/graphiql-react/src/editor/header-editor.ts @@ -1,13 +1,12 @@ import { useEffect, useRef } from 'react'; -import { useExecutionContext } from '../execution'; import { commonKeys, DEFAULT_EDITOR_THEME, DEFAULT_KEY_MAP, importCodeMirror, } from './common'; -import { useEditorContext } from './context'; +import { useEditorStore } from './context'; import { useChangeHandler, useKeyMap, @@ -16,6 +15,7 @@ import { useSynchronizeOption, } from './hooks'; import { WriteableEditorProps } from './types'; +import { useExecutionStore } from '../execution'; export type UseHeaderEditorArgs = WriteableEditorProps & { /** @@ -32,29 +32,22 @@ 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 = {}, - caller?: Function, -) { +export function useHeaderEditor({ + editorTheme = DEFAULT_EDITOR_THEME, + keyMap = DEFAULT_KEY_MAP, + onEdit, + readOnly = false, +}: UseHeaderEditorArgs = {}) { const { initialHeaders, headerEditor, setHeaderEditor, shouldPersistHeaders, - } = useEditorContext({ - nonNull: true, - caller: caller || _useHeaderEditor, - }); - const executionContext = useExecutionContext(); - const merge = useMergeQuery({ caller: caller || _useHeaderEditor }); - const prettify = usePrettifyEditors({ caller: caller || _useHeaderEditor }); + } = useEditorStore(); + const { run } = useExecutionStore(); + const merge = useMergeQuery(); + const prettify = usePrettifyEditors(); const ref = useRef(null); useEffect(() => { @@ -125,10 +118,9 @@ export function useHeaderEditor( onEdit, shouldPersistHeaders ? STORAGE_KEY : null, 'headers', - _useHeaderEditor, ); - useKeyMap(headerEditor, ['Cmd-Enter', 'Ctrl-Enter'], executionContext?.run); + useKeyMap(headerEditor, ['Cmd-Enter', 'Ctrl-Enter'], 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 079bcd5c272..cacc01340a0 100644 --- a/packages/graphiql-react/src/editor/hooks.ts +++ b/packages/graphiql-react/src/editor/hooks.ts @@ -1,9 +1,4 @@ -import { - fillLeafs, - GetDefaultFieldNamesFn, - mergeAst, - MaybePromise, -} from '@graphiql/toolkit'; +import { fillLeafs, 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'; @@ -11,12 +6,13 @@ 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 { useSchemaStore } from '../schema'; +import { schemaStore, useSchemaStore } from '../schema'; import { storageStore } from '../storage'; import { debounce } from '../utility'; import { onHasCompletion } from './completion'; -import { useEditorContext } from './context'; +import { editorStore, useEditorStore } from './context'; import { CodeMirrorEditor } from './types'; +import { executionStore } from '../execution'; export function useSynchronizeValue( editor: CodeMirrorEditor | null, @@ -44,9 +40,7 @@ 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; @@ -60,6 +54,7 @@ export function useChangeHandler( storage.set(storageKey, value); }); + const { updateActiveTabValues } = editorStore.getState(); const updateTab = debounce(100, (value: string) => { updateActiveTabValues({ [tabProperty]: value }); }); @@ -81,7 +76,7 @@ export function useChangeHandler( }; editor.on('change', handleChange); return () => editor.off('change', handleChange); - }, [callback, editor, storageKey, tabProperty, updateActiveTabValues]); + }, [callback, editor, storageKey, tabProperty]); } export function useCompletion( @@ -160,18 +155,9 @@ export type UseCopyQueryArgs = { onCopyQuery?: (query: string) => void; }; -// 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, - }); +export function useCopyQuery({ onCopyQuery }: UseCopyQueryArgs = {}) { return () => { + const { queryEditor } = editorStore.getState(); if (!queryEditor) { return; } @@ -183,35 +169,21 @@ export function useCopyQuery({ caller, onCopyQuery }: UseCopyQueryArgs = {}) { }; } -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(); +export function useMergeQuery() { 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. @@ -229,14 +201,11 @@ 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 { @@ -284,82 +253,56 @@ export function usePrettifyEditors({ }; } -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; - } +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(); } - queryEditor.setCursor(queryEditor.posFromIndex(newCursorIndex)); - }); - } + }, 7000); + let newCursorIndex = cursorIndex; + for (const { index, string } of insertions) { + if (index < cursorIndex) { + newCursorIndex += string.length; + } + } + 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 context = useEditorContext({ nonNull: true }); - - const editorInstance = context[`${editor}Editor` as const]; + const editorInstance = useEditorStore(store => store[`${editor}Editor`]); 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 dfeb1a18ff2..9e2213eb88c 100644 --- a/packages/graphiql-react/src/editor/index.ts +++ b/packages/graphiql-react/src/editor/index.ts @@ -5,14 +5,10 @@ export { ResponseEditor, VariableEditor, } from './components'; -export { - EditorContext, - EditorContextProvider, - useEditorContext, -} from './context'; +export { EditorContextProvider, useEditorStore } from './context'; export { useHeaderEditor } from './header-editor'; export { - useAutoCompleteLeafs, + getAutoCompleteLeafs, useCopyQuery, useMergeQuery, usePrettifyEditors, @@ -26,7 +22,6 @@ 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 e828842d84d..e591e387f70 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 { useExecutionContext } from '../execution'; +import { executionStore } from '../execution'; import { markdown } from '../markdown'; import { usePluginStore } from '../plugin'; import { useSchemaStore } from '../schema'; @@ -24,10 +24,7 @@ import { DEFAULT_KEY_MAP, importCodeMirror, } from './common'; -import { - CodeMirrorEditorWithOperationFacts, - useEditorContext, -} from './context'; +import { CodeMirrorEditorWithOperationFacts, useEditorStore } from './context'; import { useCompletion, useCopyQuery, @@ -139,19 +136,12 @@ export function useQueryEditor( validationRules, variableEditor, updateActiveTabValues, - } = useEditorContext({ - nonNull: true, - caller: caller || _useQueryEditor, - }); - const executionContext = useExecutionContext(); + } = useEditorStore(); const storage = useStorage(); const plugin = usePluginStore(); const copy = useCopyQuery({ caller: caller || _useQueryEditor, onCopyQuery }); - const merge = useMergeQuery({ caller: caller || _useQueryEditor }); - const prettify = usePrettifyEditors({ - caller: caller || _useQueryEditor, - onPrettifyQuery, - }); + const merge = useMergeQuery(); + const prettify = usePrettifyEditors({ onPrettifyQuery }); const ref = useRef(null); const codeMirrorRef = useRef(undefined); @@ -398,15 +388,10 @@ export function useQueryEditor( useCompletion(queryEditor, onClickReference); - const run = executionContext?.run; const runAtCursor = () => { - if ( - !run || - !queryEditor || - !queryEditor.operations || - !queryEditor.hasFocus() - ) { - run?.(); + const { run } = executionStore.getState(); + + if (!queryEditor || !queryEditor.operations || !queryEditor.hasFocus()) { return; } diff --git a/packages/graphiql-react/src/editor/response-editor.tsx b/packages/graphiql-react/src/editor/response-editor.tsx index 5c5e8b25512..c21bf96c643 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 { useEditorContext } from './context'; +import { useEditorStore } from './context'; import { useSynchronizeOption } from './hooks'; import { CodeMirrorEditor, CommonEditorProps } from './types'; @@ -52,23 +52,14 @@ function importCodeMirrorImports() { ); } -// 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, -) { +export function useResponseEditor({ + responseTooltip, + editorTheme = DEFAULT_EDITOR_THEME, + keyMap = DEFAULT_KEY_MAP, +}: UseResponseEditorArgs = {}) { const { fetchError, validationErrors } = useSchemaStore(); const { initialResponse, responseEditor, setResponseEditor } = - useEditorContext({ - nonNull: true, - caller: caller || _useResponseEditor, - }); + useEditorStore(); 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 14caa4b9cd8..e8e58f129b9 100644 --- a/packages/graphiql-react/src/editor/tabs.ts +++ b/packages/graphiql-react/src/editor/tabs.ts @@ -1,12 +1,8 @@ '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 { CodeMirrorEditorWithOperationFacts } from './context'; -import { CodeMirrorEditor } from './types'; +import { editorStore } from './context'; export type TabDefinition = { /** @@ -190,34 +186,16 @@ function hasStringOrNullKey(obj: Record, key: string) { return key in obj && (typeof obj[key] === 'string' || obj[key] === 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 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 serializeTabState( @@ -233,55 +211,38 @@ export function serializeTabState( ); } -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 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 useSetEditorValues({ - queryEditor, - variableEditor, - headerEditor, - responseEditor, - defaultHeaders, +export function setEditorValues({ + query, + variables, + headers, + response, }: { - queryEditor: CodeMirrorEditorWithOperationFacts | null; - variableEditor: CodeMirrorEditor | null; - headerEditor: CodeMirrorEditor | null; - responseEditor: CodeMirrorEditor | null; - defaultHeaders?: string; + query: string | null; + variables?: string | null; + headers?: string | null; + response: string | null; }) { - 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], - ); + const { + queryEditor, + variableEditor, + headerEditor, + responseEditor, + defaultHeaders, + } = editorStore.getState(); + + queryEditor?.setValue(query ?? ''); + variableEditor?.setValue(variables ?? ''); + headerEditor?.setValue(headers ?? defaultHeaders ?? ''); + responseEditor?.setValue(response ?? ''); } export function createTab({ diff --git a/packages/graphiql-react/src/editor/variable-editor.ts b/packages/graphiql-react/src/editor/variable-editor.ts index 1ecdf0bf7e2..b78f6b6ac89 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 { useExecutionContext } from '../execution'; +import { useExecutionStore } from '../execution'; import { commonKeys, DEFAULT_EDITOR_THEME, DEFAULT_KEY_MAP, importCodeMirror, } from './common'; -import { useEditorContext } from './context'; +import { useEditorStore } from './context'; import { useChangeHandler, useCompletion, @@ -42,27 +42,18 @@ function importCodeMirrorImports() { ]); } -// 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, -) { +export function useVariableEditor({ + editorTheme = DEFAULT_EDITOR_THEME, + keyMap = DEFAULT_KEY_MAP, + onClickReference, + onEdit, + readOnly = false, +}: UseVariableEditorArgs = {}) { const { initialVariables, variableEditor, setVariableEditor } = - useEditorContext({ - nonNull: true, - caller: caller || _useVariableEditor, - }); - const executionContext = useExecutionContext(); - const merge = useMergeQuery({ caller: caller || _useVariableEditor }); - const prettify = usePrettifyEditors({ caller: caller || _useVariableEditor }); + useEditorStore(); + const { run } = useExecutionStore(); + const merge = useMergeQuery(); + const prettify = usePrettifyEditors(); const ref = useRef(null); useEffect(() => { let isActive = true; @@ -137,17 +128,11 @@ export function useVariableEditor( useSynchronizeOption(variableEditor, 'keyMap', keyMap); - useChangeHandler( - variableEditor, - onEdit, - STORAGE_KEY, - 'variables', - _useVariableEditor, - ); + useChangeHandler(variableEditor, onEdit, STORAGE_KEY, 'variables'); useCompletion(variableEditor, onClickReference); - useKeyMap(variableEditor, ['Cmd-Enter', 'Ctrl-Enter'], executionContext?.run); + useKeyMap(variableEditor, ['Cmd-Enter', 'Ctrl-Enter'], 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 605790b9266..90e171572d5 100644 --- a/packages/graphiql-react/src/execution.tsx +++ b/packages/graphiql-react/src/execution.tsx @@ -1,7 +1,9 @@ +// eslint-disable-next-line react/jsx-filename-extension -- TODO import { Fetcher, formatError, formatResult, + GetDefaultFieldNamesFn, isAsyncIterable, isObservable, Unsubscribable, @@ -13,29 +15,38 @@ import { print, } from 'graphql'; import { getFragmentDependenciesForAST } from 'graphql-language-service'; -import { FC, ReactNode, useRef, useState } from 'react'; +import { FC, ReactElement, ReactNode, useEffect } from 'react'; import setValue from 'set-value'; import getValue from 'get-value'; -import { useAutoCompleteLeafs, useEditorContext } from './editor'; -import { UseAutoCompleteLeafsArgs } from './editor/hooks'; -import { createContextHook, createNullableContext } from './utility/context'; +import { getAutoCompleteLeafs } from './editor'; +import { createStore } from 'zustand'; +import { editorStore } from './editor/context'; +import { schemaStore } from './schema'; +import { createBoundedUseStore } from './utility'; 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; /** - * 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. + * 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 */ - isSubscribed: boolean; + subscription: Unsubscribable | null; /** * The operation name that will be sent with all GraphQL requests. + * @default null */ operationName: string | null; /** @@ -46,13 +57,20 @@ 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< - UseAutoCompleteLeafsArgs, + ExecutionContextType, 'getDefaultFieldNames' > & { children: ReactNode; @@ -73,44 +91,33 @@ type ExecutionContextProviderProps = Pick< operationName?: string; }; -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 = () => { +export const executionStore = createStore< + ExecutionContextType & + Pick +>((set, get) => ({ + isFetching: false, + subscription: null, + operationName: null, + getDefaultFieldNames: undefined, + queryId: 0, + stop() { + const { subscription } = get(); subscription?.unsubscribe(); - setIsFetching(false); - setSubscription(null); - }; - - const run: ExecutionContextType['run'] = async () => { + set({ isFetching: false, subscription: null }); + }, + async run() { + const { + externalFragments, + headerEditor, + queryEditor, + responseEditor, + variableEditor, + updateActiveTabValues, + } = editorStore.getState(); if (!queryEditor || !responseEditor) { return; } + const { subscription, operationName, queryId } = get(); // If there's an active subscription, unsubscribe it and return if (subscription) { @@ -123,13 +130,13 @@ export const ExecutionContextProvider: FC = ({ updateActiveTabValues({ response: value }); }; - queryIdRef.current += 1; - const queryId = queryIdRef.current; + const newQueryId = queryId + 1; + set({ queryId: newQueryId }); // Use the edited query after autoCompleteLeafs() runs or, // in case autoCompletion fails (the function returns undefined), // the current query from the editor. - let query = autoCompleteLeafs() || queryEditor.getValue(); + let query = getAutoCompleteLeafs() || queryEditor.getValue(); const variablesString = variableEditor?.getValue(); let variables: Record | undefined; @@ -174,17 +181,13 @@ export const ExecutionContextProvider: FC = ({ } setResponse(''); - 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; + set({ isFetching: true }); 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 (queryId !== queryIdRef.current) { + if (newQueryId !== get().queryId) { return; } @@ -203,24 +206,24 @@ export const ExecutionContextProvider: FC = ({ mergeIncrementalResult(fullResponse, part); } - setIsFetching(false); + set({ isFetching: false }); setResponse(formatResult(fullResponse)); } else { - const response = formatResult(result); - setIsFetching(false); - setResponse(response); + set({ isFetching: false }); + setResponse(formatResult(result)); } }; - + const { fetcher } = schemaStore.getState(); const fetch = fetcher( { query, variables, - operationName: opName, + operationName: + operationName ?? queryEditor.operationName ?? undefined, }, { - headers: _headers, - documentAST, + headers: headers ?? undefined, + documentAST: queryEditor.documentAST ?? undefined, }, ); @@ -229,73 +232,71 @@ export const ExecutionContextProvider: FC = ({ // 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. - setSubscription( - value.subscribe({ - next(result) { - handleResponse(result); - }, - error(error: Error) { - setIsFetching(false); - if (error) { - setResponse(formatError(error)); - } - setSubscription(null); - }, - complete() { - setIsFetching(false); - setSubscription(null); - }, - }), - ); + 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 }); } else if (isAsyncIterable(value)) { - setSubscription({ + const newSubscription = { unsubscribe: () => value[Symbol.asyncIterator]().return?.(), - }); - await handleAsyncResults(handleResponse, value); - setIsFetching(false); - setSubscription(null); + }; + set({ subscription: newSubscription }); + for await (const result of value) { + handleResponse(result); + } + set({ isFetching: false, subscription: null }); } else { handleResponse(value); } } catch (error) { - setIsFetching(false); + set({ isFetching: false }); setResponse(formatError(error)); - setSubscription(null); + set({ subscription: null }); } - }; - const value: ExecutionContextType = { - isFetching, - isSubscribed: Boolean(subscription), - operationName: operationName ?? null, - run, - stop, - }; - - return ( - - {children} - - ); -}; + }, +})); -// 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); +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.', + ); } -} + useEffect(() => { + executionStore.setState({ + operationName, + getDefaultFieldNames, + }); + }, [getDefaultFieldNames, operationName]); + + return children as ReactElement; +}; -export const useExecutionContext = createContextHook(ExecutionContext); +export const useExecutionStore = createBoundedUseStore(executionStore); function tryParseJsonObject({ json, errorMessageParse, errorMessageType, }: { - json: string | undefined; + json?: string; errorMessageParse: string; errorMessageType: string; }) { diff --git a/packages/graphiql-react/src/index.ts b/packages/graphiql-react/src/index.ts index afc7edefce2..b1e8ee02073 100644 --- a/packages/graphiql-react/src/index.ts +++ b/packages/graphiql-react/src/index.ts @@ -1,15 +1,14 @@ import './style/root.css'; export { - EditorContext, EditorContextProvider, HeaderEditor, ImagePreview, QueryEditor, ResponseEditor, - useAutoCompleteLeafs, + getAutoCompleteLeafs, useCopyQuery, - useEditorContext, + useEditorStore, useHeaderEditor, useMergeQuery, usePrettifyEditors, @@ -23,11 +22,7 @@ export { useHeadersEditorState, VariableEditor, } from './editor'; -export { - ExecutionContext, - ExecutionContextProvider, - useExecutionContext, -} from './execution'; +export { ExecutionContextProvider, useExecutionStore } from './execution'; export { PluginContextProvider, usePluginStore } from './plugin'; export { GraphiQLProvider } from './provider'; export { SchemaContextProvider, useSchemaStore } from './schema'; @@ -41,7 +36,6 @@ 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 18c6dbf979e..14c943c4350 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 { useEditorContext } from './editor'; +import { editorStore } from './editor/context'; import type { SchemaReference } from 'codemirror-graphql/utils/SchemaReference'; import { createBoundedUseStore } from './utility'; @@ -62,8 +62,6 @@ export const schemaStore = createStore((set, get) => ({ fetcher, onSchemaChange, shouldIntrospect, - // @ts-expect-error -- temporally until v 5 - headerEditor, ...rest } = get(); @@ -79,6 +77,7 @@ 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) { @@ -291,18 +290,6 @@ 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 6d292062019..9addafbb2a9 100644 --- a/packages/graphiql-react/src/toolbar/execute.tsx +++ b/packages/graphiql-react/src/toolbar/execute.tsx @@ -1,24 +1,18 @@ import { FC } from 'react'; -import { useEditorContext } from '../editor'; -import { useExecutionContext } from '../execution'; +import { useEditorStore } from '../editor'; +import { useExecutionStore } from '../execution'; import { PlayIcon, StopIcon } from '../icons'; import { DropdownMenu, Tooltip } from '../ui'; import './execute.css'; export const ExecuteButton: FC = () => { - const { queryEditor, setOperationName } = useEditorContext({ - nonNull: true, - caller: ExecuteButton, - }); - const { isFetching, isSubscribed, operationName, run, stop } = - useExecutionContext({ - nonNull: true, - caller: ExecuteButton, - }); + const { queryEditor, setOperationName } = useEditorStore(); + const { isFetching, subscription, operationName, run, stop } = + useExecutionStore(); const operations = queryEditor?.operations || []; const hasOptions = operations.length > 1 && typeof operationName !== 'string'; - const isRunning = isFetching || isSubscribed; + const isRunning = isFetching || Boolean(subscription); 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 deleted file mode 100644 index 5d4403234fe..00000000000 --- a/packages/graphiql-react/src/utility/context.ts +++ /dev/null @@ -1,38 +0,0 @@ -'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 6970bf2b61c..9f73b06bc03 100644 --- a/packages/graphiql-react/src/utility/index.ts +++ b/packages/graphiql-react/src/utility/index.ts @@ -1,5 +1,4 @@ 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 9ae06a67beb..f44d0b6a315 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; }; -// These type just taken from https://github.com/ReactiveX/rxjs/blob/master/src/internal/types.ts#L41 +// This 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 1bd1d32b3ad..b705ac33138 100644 --- a/packages/graphiql/src/GraphiQL.tsx +++ b/packages/graphiql/src/GraphiQL.tsx @@ -38,8 +38,8 @@ import { UnStyledButton, useCopyQuery, useDragResize, - useEditorContext, - useExecutionContext, + useEditorStore, + useExecutionStore, UseHeaderEditorArgs, useMergeQuery, usePluginStore, @@ -238,8 +238,8 @@ export const GraphiQLInterface: FC = props => { shouldPersistHeaders, tabs, activeTabIndex, - } = useEditorContext({ nonNull: true }); - const executionContext = useExecutionContext({ nonNull: true }); + } = useEditorStore(); + const executionContext = useExecutionStore(); const { isFetching: isSchemaFetching, introspect } = useSchemaStore(); const storageContext = useStorage(); const { visiblePlugin, setVisiblePlugin, plugins } = usePluginStore();