diff --git a/.changeset/tame-swans-act.md b/.changeset/tame-swans-act.md new file mode 100644 index 00000000000..b4b36ce3a98 --- /dev/null +++ b/.changeset/tame-swans-act.md @@ -0,0 +1,8 @@ +--- +'@graphiql/plugin-history': patch +'@graphiql/react': minor +'graphiql': patch +--- + +feat(@graphiql/react): migrate React context to zustand, replace `useStorageContext` with `useStorage` hook + diff --git a/examples/graphiql-webpack/src/index.jsx b/examples/graphiql-webpack/src/index.jsx index 7b6f678fa04..84922971f2a 100644 --- a/examples/graphiql-webpack/src/index.jsx +++ b/examples/graphiql-webpack/src/index.jsx @@ -9,7 +9,7 @@ import 'graphiql/style.css'; import '@graphiql/plugin-explorer/style.css'; import '@graphiql/plugin-code-exporter/style.css'; import { createGraphiQLFetcher } from '@graphiql/toolkit'; -import { useStorageContext } from '@graphiql/react'; +import { useStorage } from '@graphiql/react'; export const STARTING_URL = 'https://swapi-graphql.netlify.app/.netlify/functions/index'; @@ -60,9 +60,9 @@ const style = { height: '100vh' }; const explorer = explorerPlugin(); const App = () => { - const storage = useStorageContext(); + const storage = useStorage({ nonNull: true }); - const lastUrl = storage?.get(LAST_URL_KEY); + const lastUrl = storage.get(LAST_URL_KEY); const [currentUrl, setUrl] = React.useState(lastUrl ?? STARTING_URL); // TODO: a breaking change where we make URL an internal state concern, and then expose hooks // so that you can handle/set URL state internally from a plugin diff --git a/examples/graphiql-webpack/src/select-server-plugin.jsx b/examples/graphiql-webpack/src/select-server-plugin.jsx index 5fcc186ad00..25105a3ae41 100644 --- a/examples/graphiql-webpack/src/select-server-plugin.jsx +++ b/examples/graphiql-webpack/src/select-server-plugin.jsx @@ -1,7 +1,7 @@ import * as React from 'react'; import './select-server-plugin.css'; -import { useStorageContext, useSchemaContext } from '@graphiql/react'; +import { useStorage, useSchemaContext } from '@graphiql/react'; export const LAST_URL_KEY = 'lastURL'; @@ -9,12 +9,12 @@ export const PREV_URLS_KEY = 'previousURLs'; const SelectServer = ({ url, setUrl }) => { const inputRef = React.useRef(null); - const storage = useStorageContext(); - const lastUrl = storage?.get(LAST_URL_KEY); + const storage = useStorage({ nonNull: true }); + const lastUrl = storage.get(LAST_URL_KEY); const currentUrl = lastUrl ?? url; const [inputValue, setInputValue] = React.useState(currentUrl); const [previousUrls, setPreviousUrls] = React.useState( - JSON.parse(storage?.get(PREV_URLS_KEY)) ?? [currentUrl], + JSON.parse(storage.get(PREV_URLS_KEY)) ?? [currentUrl], ); const [error, setError] = React.useState(null); diff --git a/packages/graphiql-plugin-history/src/context.tsx b/packages/graphiql-plugin-history/src/context.tsx index d62727f12a0..b7469d0159d 100644 --- a/packages/graphiql-plugin-history/src/context.tsx +++ b/packages/graphiql-plugin-history/src/context.tsx @@ -10,7 +10,7 @@ import { import { createStore, StoreApi, useStore } from 'zustand'; import { HistoryStore, QueryStoreItem, StorageAPI } from '@graphiql/toolkit'; import { - useStorageContext, + useStorage, useExecutionContext, useEditorContext, } from '@graphiql/react'; @@ -141,7 +141,7 @@ export const HistoryContextProvider: FC = ({ maxHistoryLength = 20, children, }) => { - const storage = useStorageContext(); + const storage = useStorage(); const { isFetching } = useExecutionContext({ nonNull: true }); const { tabs, activeTabIndex } = useEditorContext({ nonNull: true }); const activeTab = tabs[activeTabIndex]; diff --git a/packages/graphiql-react/package.json b/packages/graphiql-react/package.json index 569659c7013..c2e4b04cb13 100644 --- a/packages/graphiql-react/package.json +++ b/packages/graphiql-react/package.json @@ -56,7 +56,8 @@ "get-value": "^3.0.1", "graphql-language-service": "^5.3.1", "markdown-it": "^14.1.0", - "set-value": "^4.1.0" + "set-value": "^4.1.0", + "zustand": "^5" }, "devDependencies": { "babel-plugin-react-compiler": "19.1.0-rc.1", diff --git a/packages/graphiql-react/src/editor/context.tsx b/packages/graphiql-react/src/editor/context.tsx index 7be9d491295..f300ad962dd 100644 --- a/packages/graphiql-react/src/editor/context.tsx +++ b/packages/graphiql-react/src/editor/context.tsx @@ -9,7 +9,7 @@ import { import { VariableToType } from 'graphql-language-service'; import { FC, ReactNode, useEffect, useRef, useState } from 'react'; -import { useStorageContext } from '../storage'; +import { useStorage } from '../storage'; import { createContextHook, createNullableContext } from '../utility/context'; import { STORAGE_KEY as STORAGE_KEY_HEADERS } from './header-editor'; import { useSynchronizeValue } from './hooks'; @@ -255,7 +255,7 @@ type EditorContextProviderProps = { }; export const EditorContextProvider: FC = props => { - const storage = useStorageContext(); + const storage = useStorage(); const [headerEditor, setHeaderEditor] = useState( null, ); diff --git a/packages/graphiql-react/src/editor/hooks.ts b/packages/graphiql-react/src/editor/hooks.ts index 979d8722e0d..44f77669cdb 100644 --- a/packages/graphiql-react/src/editor/hooks.ts +++ b/packages/graphiql-react/src/editor/hooks.ts @@ -12,7 +12,7 @@ import { parse, print } from 'graphql'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { usePluginContext } from '../plugin'; import { useSchemaContext } from '../schema'; -import { useStorageContext } from '../storage'; +import { useStorage } from '../storage'; import { debounce } from '../utility'; import { onHasCompletion } from './completion'; import { useEditorContext } from './context'; @@ -47,7 +47,7 @@ export function useChangeHandler( caller: Function, ) { const { updateActiveTabValues } = useEditorContext({ nonNull: true, caller }); - const storage = useStorageContext(); + const storage = useStorage(); useEffect(() => { if (!editor) { diff --git a/packages/graphiql-react/src/editor/query-editor.ts b/packages/graphiql-react/src/editor/query-editor.ts index 61f7d55574e..222951a5237 100644 --- a/packages/graphiql-react/src/editor/query-editor.ts +++ b/packages/graphiql-react/src/editor/query-editor.ts @@ -16,7 +16,7 @@ import { useExecutionContext } from '../execution'; import { markdown } from '../markdown'; import { usePluginContext } from '../plugin'; import { useSchemaContext } from '../schema'; -import { useStorageContext } from '../storage'; +import { useStorage } from '../storage'; import { debounce } from '../utility/debounce'; import { commonKeys, @@ -147,7 +147,7 @@ export function useQueryEditor( caller: caller || _useQueryEditor, }); const executionContext = useExecutionContext(); - const storage = useStorageContext(); + const storage = useStorage(); const plugin = usePluginContext(); const copy = useCopyQuery({ caller: caller || _useQueryEditor, onCopyQuery }); const merge = useMergeQuery({ caller: caller || _useQueryEditor }); diff --git a/packages/graphiql-react/src/index.ts b/packages/graphiql-react/src/index.ts index 3993359d87c..1543b1efe33 100644 --- a/packages/graphiql-react/src/index.ts +++ b/packages/graphiql-react/src/index.ts @@ -39,11 +39,7 @@ export { SchemaContextProvider, useSchemaContext, } from './schema'; -export { - StorageContext, - StorageContextProvider, - useStorageContext, -} from './storage'; +export { StorageContextProvider, useStorage } from './storage'; export { useTheme } from './theme'; export * from './utility'; diff --git a/packages/graphiql-react/src/plugin.tsx b/packages/graphiql-react/src/plugin.tsx index 228b492d27c..21a092b7c2f 100644 --- a/packages/graphiql-react/src/plugin.tsx +++ b/packages/graphiql-react/src/plugin.tsx @@ -1,5 +1,5 @@ import { ComponentType, FC, ReactNode, useEffect, useState } from 'react'; -import { useStorageContext } from './storage'; +import { useStorage } from './storage'; import { createContextHook, createNullableContext } from './utility'; export type GraphiQLPlugin = { @@ -75,7 +75,7 @@ export const PluginContextProvider: FC = ({ plugins: $plugins, referencePlugin, }) => { - const storage = useStorageContext(); + const storage = useStorage(); const plugins = (() => { const pluginList: GraphiQLPlugin[] = []; const pluginTitles: Record = {}; diff --git a/packages/graphiql-react/src/storage.tsx b/packages/graphiql-react/src/storage.tsx index ed6d947490a..72676f16783 100644 --- a/packages/graphiql-react/src/storage.tsx +++ b/packages/graphiql-react/src/storage.tsx @@ -1,11 +1,22 @@ import { Storage, StorageAPI } from '@graphiql/toolkit'; -import { FC, ReactNode, useEffect, useRef, useState } from 'react'; -import { createContextHook, createNullableContext } from './utility/context'; +import { + createContext, + FC, + ReactNode, + RefObject, + useContext, + useEffect, + useRef, +} from 'react'; +import { create, StoreApi, useStore } from 'zustand'; -export type StorageContextType = StorageAPI; +export type StorageContextType = { + storage: StorageAPI | null; +}; -export const StorageContext = - createNullableContext('StorageContext'); +const StorageContext = createContext +> | null>(null); type StorageContextProviderProps = { children: ReactNode; @@ -23,21 +34,44 @@ export const StorageContextProvider: FC = ({ children, }) => { const isInitialRender = useRef(true); - const [$storage, setStorage] = useState(() => new StorageAPI(storage)); + const storeRef = useRef>(null!); + + if (storeRef.current === null) { + storeRef.current = create()(() => ({ + storage: new StorageAPI(storage), + })); + } useEffect(() => { if (isInitialRender.current) { isInitialRender.current = false; - } else { - setStorage(new StorageAPI(storage)); + return; } + storeRef.current.setState({ storage: new StorageAPI(storage) }); }, [storage]); return ( - + {children} ); }; -export const useStorageContext = createContextHook(StorageContext); +const defaultStore = create()(() => ({ + storage: null, +})); + +function useStorage(): StorageAPI | null; +function useStorage(options: { nonNull: true }): StorageAPI; +function useStorage(options: { nonNull: boolean }): StorageAPI | null; +function useStorage(options?: { nonNull?: boolean }): StorageAPI | null { + const store = useContext(StorageContext); + if (options?.nonNull && !store) { + throw new Error( + 'Tried to use `useStorage` without the necessary context. Make sure to render the `StorageContextProvider` component higher up the tree.', + ); + } + return useStore(store ? store.current : defaultStore, state => state.storage); +} + +export { useStorage }; diff --git a/packages/graphiql-react/src/theme.ts b/packages/graphiql-react/src/theme.ts index 94460cc8464..cb884ee9e52 100644 --- a/packages/graphiql-react/src/theme.ts +++ b/packages/graphiql-react/src/theme.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { useStorageContext } from './storage'; +import { useStorage } from './storage'; /** * The value `null` semantically means that the user does not explicitly choose @@ -8,14 +8,14 @@ import { useStorageContext } from './storage'; export type Theme = 'light' | 'dark' | null; export function useTheme(defaultTheme: Theme = null) { - const storageContext = useStorageContext(); + const storage = useStorage(); const [theme, setThemeInternal] = useState(() => { - if (!storageContext) { + if (!storage) { return null; } - const stored = storageContext.get(STORAGE_KEY); + const stored = storage.get(STORAGE_KEY); switch (stored) { case 'light': return 'light'; @@ -24,7 +24,7 @@ export function useTheme(defaultTheme: Theme = null) { default: if (typeof stored === 'string') { // Remove the invalid stored value - storageContext.set(STORAGE_KEY, ''); + storage.set(STORAGE_KEY, ''); } return defaultTheme; } @@ -38,7 +38,7 @@ export function useTheme(defaultTheme: Theme = null) { }, [theme]); const setTheme = (newTheme: Theme) => { - storageContext?.set(STORAGE_KEY, newTheme || ''); + storage?.set(STORAGE_KEY, newTheme || ''); setThemeInternal(newTheme); }; diff --git a/packages/graphiql-react/src/ui/tabs.css b/packages/graphiql-react/src/ui/tabs.css index 26278234790..b36fbcca545 100644 --- a/packages/graphiql-react/src/ui/tabs.css +++ b/packages/graphiql-react/src/ui/tabs.css @@ -18,7 +18,7 @@ -ms-overflow-style: none; /* IE and Edge */ &::-webkit-scrollbar { - @apply x:hidden; /* Chrome, Safari and Opera */ + display: none; /* Chrome, Safari and Opera */ } } diff --git a/packages/graphiql-react/src/utility/resize.ts b/packages/graphiql-react/src/utility/resize.ts index b97e34c93fb..321a1ea414b 100644 --- a/packages/graphiql-react/src/utility/resize.ts +++ b/packages/graphiql-react/src/utility/resize.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { useStorageContext } from '../storage'; +import { useStorage } from '../storage'; import { debounce } from './debounce'; type ResizableElement = 'first' | 'second'; @@ -53,7 +53,7 @@ export function useDragResize({ sizeThresholdSecond = 100, storageKey, }: UseDragResizeArgs) { - const storage = useStorageContext(); + const storage = useStorage(); const store = debounce(500, (value: string) => { if (storageKey) { diff --git a/packages/graphiql/src/GraphiQL.tsx b/packages/graphiql/src/GraphiQL.tsx index ae57914eef9..baebfbac546 100644 --- a/packages/graphiql/src/GraphiQL.tsx +++ b/packages/graphiql/src/GraphiQL.tsx @@ -47,7 +47,7 @@ import { UseQueryEditorArgs, UseResponseEditorArgs, useSchemaContext, - useStorageContext, + useStorage, useTheme, UseVariableEditorArgs, VariableEditor, @@ -230,8 +230,8 @@ export const GraphiQLInterface: FC = props => { const editorContext = useEditorContext({ nonNull: true }); const executionContext = useExecutionContext({ nonNull: true }); const schemaContext = useSchemaContext({ nonNull: true }); - const storageContext = useStorageContext(); - const pluginContext = usePluginContext(); + const storageContext = useStorage({ nonNull: true }); + const pluginContext = usePluginContext({ nonNull: true }); const forcedTheme = props.forcedTheme && THEMES.includes(props.forcedTheme) ? props.forcedTheme @@ -246,15 +246,15 @@ export const GraphiQLInterface: FC = props => { } }, [forcedTheme, setTheme]); - const PluginContent = pluginContext?.visiblePlugin?.content; + const PluginContent = pluginContext.visiblePlugin?.content; const pluginResize = useDragResize({ defaultSizeRelation: 1 / 3, direction: 'horizontal', - initiallyHidden: pluginContext?.visiblePlugin ? undefined : 'first', + initiallyHidden: pluginContext.visiblePlugin ? undefined : 'first', onHiddenElementChange(resizableElement) { if (resizableElement === 'first') { - pluginContext?.setVisiblePlugin(null); + pluginContext.setVisiblePlugin(null); } }, sizeThresholdSecond: 200, @@ -352,10 +352,7 @@ export const GraphiQLInterface: FC = props => { const handleClearData = () => { try { - // Optional chaining inside try-catch isn't supported yet by react-compiler - if (storageContext) { - storageContext.clear(); - } + storageContext.clear(); setClearStorageStatus('success'); } catch { setClearStorageStatus('error'); @@ -387,15 +384,16 @@ export const GraphiQLInterface: FC = props => { }; const handlePluginClick: MouseEventHandler = event => { - const context = pluginContext!; const pluginIndex = Number(event.currentTarget.dataset.index!); - const plugin = context.plugins.find((_, index) => pluginIndex === index)!; - const isVisible = plugin === context.visiblePlugin; + const plugin = pluginContext.plugins.find( + (_, index) => pluginIndex === index, + )!; + const isVisible = plugin === pluginContext.visiblePlugin; if (isVisible) { - context.setVisiblePlugin(null); + pluginContext.setVisiblePlugin(null); pluginResize.setHiddenElement('first'); } else { - context.setVisiblePlugin(plugin); + pluginContext.setVisiblePlugin(plugin); pluginResize.setHiddenElement(null); } }; @@ -465,7 +463,7 @@ export const GraphiQLInterface: FC = props => {
- {pluginContext?.plugins.map((plugin, index) => { + {pluginContext.plugins.map((plugin, index) => { const isVisible = plugin === pluginContext.visiblePlugin; const label = `${isVisible ? 'Hide' : 'Show'} ${plugin.title}`; return ( @@ -529,7 +527,7 @@ export const GraphiQLInterface: FC = props => { > {PluginContent ? : null}
- {pluginContext?.visiblePlugin && ( + {pluginContext.visiblePlugin && (
= props => {
)} - {storageContext ? ( -
-
-
- Clear storage -
-
- Remove all locally stored data and start fresh. -
+
+
+
Clear storage
+
+ Remove all locally stored data and start fresh.
-
- ) : null} + +