Skip to content

Commit 117627b

Browse files
authored
feat(@graphiql/react): migrate React context to zustand, replace usePluginContext with usePluginStore hook (#3945)
* upd * upd * upd * upd * upd * upd * upd * upd * upd * upd * upd * changeset * upd * upd * fix tests * fix example * fix tests * upd * upd * upd * upd * upd * less git lines * fix tests * fix tests * lint * fix build * fix build * fix cspell * Update packages/graphiql-react/src/schema.ts
1 parent 7275472 commit 117627b

File tree

18 files changed

+186
-117
lines changed

18 files changed

+186
-117
lines changed

.changeset/good-geese-joke.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@graphiql/plugin-doc-explorer': patch
3+
'@graphiql/plugin-explorer': patch
4+
'@graphiql/plugin-history': patch
5+
'@graphiql/react': minor
6+
'graphiql': patch
7+
---
8+
9+
feat(@graphiql/react): migrate React context to zustand, replace `usePluginContext` with `usePluginStore` hook
10+

.eslintrc.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ module.exports = {
3131
'**/CHANGELOG.md',
3232
'functions/*',
3333
'packages/vscode-graphql-syntax/tests/__fixtures__/*',
34+
// symlinks
35+
'packages/graphiql-plugin-doc-explorer/__mocks__/zustand.ts',
36+
'packages/graphiql-plugin-history/__mocks__/zustand.ts',
3437
],
3538
overrides: [
3639
{
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../graphiql/__mocks__/zustand.ts
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
'use no memo';
22

33
import '@testing-library/jest-dom';
4+
5+
vi.mock('zustand'); // to make it works like Jest (auto-mocking)

packages/graphiql-plugin-doc-explorer/src/index.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {
22
DocsFilledIcon,
33
DocsIcon,
44
GraphiQLPlugin,
5-
usePluginContext,
5+
usePluginStore,
66
} from '@graphiql/react';
77
import { DocExplorer } from './components';
88

@@ -25,8 +25,8 @@ export type {
2525
export const DOC_EXPLORER_PLUGIN: GraphiQLPlugin = {
2626
title: 'Documentation Explorer',
2727
icon: function Icon() {
28-
const pluginContext = usePluginContext();
29-
return pluginContext?.visiblePlugin === DOC_EXPLORER_PLUGIN ? (
28+
const { visiblePlugin } = usePluginStore();
29+
return visiblePlugin === DOC_EXPLORER_PLUGIN ? (
3030
<DocsFilledIcon />
3131
) : (
3232
<DocsIcon />
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../graphiql/__mocks__/zustand.ts
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
'use no memo';
2+
3+
import '@testing-library/jest-dom';
4+
5+
vi.mock('zustand'); // to make it works like Jest (auto-mocking)

packages/graphiql-plugin-history/vitest.config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ export default defineConfig({
66
test: {
77
globals: true,
88
environment: 'jsdom',
9+
setupFiles: ['./setup-files.ts'],
910
},
1011
});

packages/graphiql-react/src/editor/hooks.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import copyToClipboard from 'copy-to-clipboard';
1010
import { parse, print } from 'graphql';
1111
// 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
1212
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
13-
import { usePluginContext } from '../plugin';
13+
import { usePluginStore } from '../plugin';
1414
import { useSchemaStore } from '../schema';
1515
import { useStorage } from '../storage';
1616
import { debounce } from '../utility';
@@ -97,7 +97,7 @@ export function useCompletion(
9797
callback?: (reference: SchemaReference) => void,
9898
) {
9999
const { schema, setSchemaReference } = useSchemaStore();
100-
const plugin = usePluginContext();
100+
const plugin = usePluginStore();
101101
useEffect(() => {
102102
if (!editor) {
103103
return;

packages/graphiql-react/src/editor/query-editor.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
import { RefObject, useEffect, useRef } from 'react';
1515
import { useExecutionContext } from '../execution';
1616
import { markdown } from '../markdown';
17-
import { usePluginContext } from '../plugin';
17+
import { usePluginStore } from '../plugin';
1818
import { useSchemaStore } from '../schema';
1919
import { useStorage } from '../storage';
2020
import { debounce } from '../utility/debounce';
@@ -145,7 +145,7 @@ export function useQueryEditor(
145145
});
146146
const executionContext = useExecutionContext();
147147
const storage = useStorage();
148-
const plugin = usePluginContext();
148+
const plugin = usePluginStore();
149149
const copy = useCopyQuery({ caller: caller || _useQueryEditor, onCopyQuery });
150150
const merge = useMergeQuery({ caller: caller || _useQueryEditor });
151151
const prettify = usePrettifyEditors({

packages/graphiql-react/src/index.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,7 @@ export {
2828
ExecutionContextProvider,
2929
useExecutionContext,
3030
} from './execution';
31-
export {
32-
PluginContext,
33-
PluginContextProvider,
34-
usePluginContext,
35-
} from './plugin';
31+
export { PluginContextProvider, usePluginStore } from './plugin';
3632
export { GraphiQLProvider } from './provider';
3733
export { SchemaContextProvider, useSchemaStore } from './schema';
3834
export { StorageContextProvider, useStorage } from './storage';
Lines changed: 69 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { ComponentType, FC, ReactNode, useEffect, useState } from 'react';
1+
// eslint-disable-next-line react/jsx-filename-extension -- TODO
2+
import { ComponentType, FC, ReactNode, useEffect } from 'react';
23
import { useStorage } from './storage';
3-
import { createContextHook, createNullableContext } from './utility';
4+
import { createStore, useStore } from 'zustand';
45

56
export type GraphiQLPlugin = {
67
/**
@@ -41,21 +42,21 @@ export type PluginContextType = {
4142
* The plugin which is used to display the reference documentation when selecting a type.
4243
*/
4344
referencePlugin?: GraphiQLPlugin;
44-
};
45-
46-
export const PluginContext =
47-
createNullableContext<PluginContextType>('PluginContext');
48-
49-
type PluginContextProviderProps = Pick<PluginContextType, 'referencePlugin'> & {
50-
children: ReactNode;
5145
/**
5246
* Invoked when the visibility state of any plugin changes.
5347
* @param visiblePlugin The plugin object that is now visible. If no plugin
5448
* is visible, the function will be invoked with `null`.
5549
*/
5650
onTogglePluginVisibility?(visiblePlugin: GraphiQLPlugin | null): void;
51+
};
52+
53+
type PluginContextProviderProps = Pick<
54+
PluginContextType,
55+
'referencePlugin' | 'onTogglePluginVisibility'
56+
> & {
57+
children: ReactNode;
5758
/**
58-
* This props accepts a list of plugins that will be shown in addition to the
59+
* This prop accepts a list of plugins that will be shown in addition to the
5960
* built-in ones (the doc explorer and the history).
6061
*/
6162
plugins?: GraphiQLPlugin[];
@@ -68,93 +69,78 @@ type PluginContextProviderProps = Pick<PluginContextType, 'referencePlugin'> & {
6869
visiblePlugin?: GraphiQLPlugin | string;
6970
};
7071

72+
export const pluginStore = createStore<PluginContextType>((set, get) => ({
73+
plugins: [],
74+
visiblePlugin: null,
75+
referencePlugin: undefined,
76+
setVisiblePlugin(plugin) {
77+
const { plugins, onTogglePluginVisibility } = get();
78+
const byTitle = typeof plugin === 'string';
79+
const newVisiblePlugin: PluginContextType['visiblePlugin'] =
80+
(plugin && plugins.find(p => (byTitle ? p.title : p) === plugin)) || null;
81+
set(({ visiblePlugin }) => {
82+
if (newVisiblePlugin === visiblePlugin) {
83+
return { visiblePlugin };
84+
}
85+
onTogglePluginVisibility?.(newVisiblePlugin);
86+
return { visiblePlugin: newVisiblePlugin };
87+
});
88+
},
89+
}));
90+
91+
// @ts-expect-error -- ignore `children` type warning
7192
export const PluginContextProvider: FC<PluginContextProviderProps> = ({
7293
onTogglePluginVisibility,
7394
children,
7495
visiblePlugin,
75-
plugins: $plugins,
96+
plugins = [],
7697
referencePlugin,
7798
}) => {
7899
const storage = useStorage();
79-
const plugins = (() => {
80-
const pluginList: GraphiQLPlugin[] = [];
81-
const pluginTitles: Record<string, true> = {};
82-
for (const plugin of $plugins || []) {
83-
if (typeof plugin.title !== 'string' || !plugin.title) {
84-
throw new Error('All GraphiQL plugins must have a unique title');
85-
}
86-
if (pluginTitles[plugin.title]) {
87-
throw new Error(
88-
`All GraphiQL plugins must have a unique title, found two plugins with the title '${plugin.title}'`,
89-
);
90-
} else {
91-
pluginList.push(plugin);
92-
pluginTitles[plugin.title] = true;
93-
}
94-
}
95-
96-
return pluginList;
97-
})();
98100

99-
const [$visiblePlugin, internalSetVisiblePlugin] =
100-
useState<GraphiQLPlugin | null>(() => {
101-
const storedValue = storage?.get(STORAGE_KEY);
102-
const pluginForStoredValue = plugins.find(
103-
plugin => plugin.title === storedValue,
104-
);
105-
if (pluginForStoredValue) {
106-
return pluginForStoredValue;
107-
}
108-
if (storedValue) {
109-
storage?.set(STORAGE_KEY, '');
101+
useEffect(() => {
102+
const seenTitles = new Set<string>();
103+
const msg = 'All GraphiQL plugins must have a unique title';
104+
for (const { title } of plugins) {
105+
if (typeof title !== 'string' || !title) {
106+
throw new Error(msg);
110107
}
111-
112-
if (!visiblePlugin) {
113-
return null;
108+
if (seenTitles.has(title)) {
109+
throw new Error(`${msg}, found two plugins with the title '${title}'`);
114110
}
115-
116-
return (
117-
plugins.find(
118-
plugin =>
119-
(typeof visiblePlugin === 'string' ? plugin.title : plugin) ===
120-
visiblePlugin,
121-
) || null
122-
);
123-
});
124-
125-
const setVisiblePlugin: PluginContextType['setVisiblePlugin'] = // eslint-disable-line react-hooks/exhaustive-deps -- false positive, function is optimized by react-compiler, no need to wrap with useCallback
126-
plugin => {
127-
const newVisiblePlugin = plugin
128-
? plugins.find(
129-
p => (typeof plugin === 'string' ? p.title : p) === plugin,
130-
) || null
131-
: null;
132-
internalSetVisiblePlugin(current => {
133-
if (newVisiblePlugin === current) {
134-
return current;
135-
}
136-
onTogglePluginVisibility?.(newVisiblePlugin);
137-
return newVisiblePlugin;
138-
});
139-
};
140-
141-
useEffect(() => {
142-
if (visiblePlugin) {
143-
setVisiblePlugin(visiblePlugin);
111+
seenTitles.add(title);
144112
}
145-
}, [plugins, visiblePlugin, setVisiblePlugin]);
113+
// TODO: visiblePlugin initial data
114+
// const storedValue = storage?.get(STORAGE_KEY);
115+
// const pluginForStoredValue = plugins.find(
116+
// plugin => plugin.title === storedValue,
117+
// );
118+
// if (pluginForStoredValue) {
119+
// return pluginForStoredValue;
120+
// }
121+
// if (storedValue) {
122+
// storage?.set(STORAGE_KEY, '');
123+
// }
146124

147-
const value: PluginContextType = {
125+
pluginStore.setState({
126+
plugins,
127+
onTogglePluginVisibility,
128+
referencePlugin,
129+
});
130+
pluginStore.getState().setVisiblePlugin(visiblePlugin ?? null);
131+
}, [
148132
plugins,
149-
setVisiblePlugin,
150-
visiblePlugin: $visiblePlugin,
133+
onTogglePluginVisibility,
151134
referencePlugin,
152-
};
153-
return (
154-
<PluginContext.Provider value={value}>{children}</PluginContext.Provider>
155-
);
135+
storage,
136+
visiblePlugin,
137+
]);
138+
139+
return children;
156140
};
157141

158-
export const usePluginContext = createContextHook(PluginContext);
142+
export function usePluginStore() {
143+
return useStore(pluginStore);
144+
}
159145

160-
const STORAGE_KEY = 'visiblePlugin';
146+
// const STORAGE_KEY = 'visiblePlugin';

packages/graphiql-react/src/utility/resize.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,21 +96,15 @@ export function useDragResize({
9696
(storageKey && storage?.get(storageKey)) || defaultFlexRef.current;
9797

9898
if (firstRef.current) {
99-
firstRef.current.style.display = 'flex';
10099
firstRef.current.style.flex =
101100
storedValue === HIDE_FIRST || storedValue === HIDE_SECOND
102101
? defaultFlexRef.current
103102
: storedValue;
104103
}
105104

106105
if (secondRef.current) {
107-
secondRef.current.style.display = 'flex';
108106
secondRef.current.style.flex = '1';
109107
}
110-
111-
if (dragBarRef.current) {
112-
dragBarRef.current.style.display = 'flex';
113-
}
114108
}, [direction, storage, storageKey]);
115109

116110
/**
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/* eslint-disable no-console */
2+
3+
/**
4+
* Mocking Zustand for Vitest with resetting store between each test.
5+
* @see https://zustand.docs.pmnd.rs/guides/testing#vitest
6+
*/
7+
8+
import { act } from '@testing-library/react';
9+
// eslint-disable-next-line import-x/no-extraneous-dependencies
10+
import {
11+
create as originalCreate,
12+
createStore as originalCreateStore,
13+
useStore,
14+
StateCreator,
15+
} from 'zustand';
16+
17+
// Originally zustand docs suggest to use `export * from 'zustand'`, but I had issues with it.
18+
// It conflicts with locale export of `create` and `createStore` functions
19+
export { useStore };
20+
21+
// a variable to hold reset functions for all stores declared in the app
22+
export const storeResetFns = new Set<() => void>();
23+
24+
const createUncurried = <T>(stateCreator: StateCreator<T>) => {
25+
const store = originalCreate(stateCreator);
26+
const initialState = store.getInitialState();
27+
storeResetFns.add(() => {
28+
store.setState(initialState, true);
29+
});
30+
return store;
31+
};
32+
33+
// when creating a store, we get its initial state, create a reset function and add it in the set
34+
export const create = (<T>(stateCreator: StateCreator<T>) => {
35+
console.log('zustand create mock');
36+
37+
// to support a curried version of create
38+
return typeof stateCreator === 'function'
39+
? createUncurried(stateCreator)
40+
: createUncurried;
41+
}) as typeof originalCreate;
42+
43+
function createStoreUncurried<T>(stateCreator: StateCreator<T>) {
44+
const store = originalCreateStore(stateCreator);
45+
const initialState = store.getInitialState();
46+
47+
storeResetFns.add(() => {
48+
store.setState(initialState, true);
49+
});
50+
return store;
51+
}
52+
53+
// When creating a store, we get its initial state, create a reset function and add it in the set
54+
export const createStore = (<T>(stateCreator: StateCreator<T>) => {
55+
console.log('zustand createStore mock');
56+
57+
// to support a curried version of createStore
58+
return typeof stateCreator === 'function'
59+
? createStoreUncurried(stateCreator)
60+
: createStoreUncurried;
61+
}) as typeof originalCreateStore;
62+
63+
// Reset all stores after each test run
64+
afterEach(() => {
65+
act(() => {
66+
for (const resetFn of storeResetFns) {
67+
resetFn();
68+
}
69+
});
70+
});

0 commit comments

Comments
 (0)