Skip to content

Commit 2b6ea31

Browse files
authoredFeb 1, 2024
Fix bug in Explorer plugin where characters are dropped when typing quickly (#3526)
Use optimistic editor state when interfacing between explorer and GraphiQL
1 parent 88d76ca commit 2b6ea31

File tree

6 files changed

+90
-2
lines changed

6 files changed

+90
-2
lines changed
 

‎.changeset/fresh-rabbits-move.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@graphiql/plugin-explorer': patch
3+
---
4+
5+
Fix bug whereby typing quickly into explorer sidebar would result in characters being dropped.

‎.changeset/silent-spoons-shout.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@graphiql/react': patch
3+
---
4+
5+
Add new `useOptimisticState` hook that can wrap a useState-like hook to perform optimistic caching of state changes, this helps to avoid losing characters when the user is typing rapidly. Example of usage: `const [state, setState] = useOptimisticState(useOperationsEditorState());`

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

+4-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
useExecutionContext,
55
useSchemaContext,
66
useOperationsEditorState,
7+
useOptimisticState,
78
} from '@graphiql/react';
89
import {
910
Explorer as GraphiQLExplorer,
@@ -139,7 +140,9 @@ function ExplorerPlugin(props: GraphiQLExplorerPluginProps) {
139140
);
140141

141142
// load the current editor tab state into the explorer
142-
const [operationsString, handleEditOperations] = useOperationsEditorState();
143+
const [operationsString, handleEditOperations] = useOptimisticState(
144+
useOperationsEditorState(),
145+
);
143146

144147
return (
145148
<GraphiQLExplorer

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

+74-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { EditorChange, EditorConfiguration } from 'codemirror';
33
import type { SchemaReference } from 'codemirror-graphql/utils/SchemaReference';
44
import copyToClipboard from 'copy-to-clipboard';
55
import { parse, print } from 'graphql';
6-
import { useCallback, useEffect, useMemo } from 'react';
6+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
77

88
import { useExplorerContext } from '../explorer';
99
import { usePluginContext } from '../plugin';
@@ -388,3 +388,76 @@ export const useHeadersEditorState = (): [
388388
] => {
389389
return useEditorState('header');
390390
};
391+
392+
/**
393+
* Implements an optimistic caching strategy around a useState-like hook in
394+
* order to prevent loss of updates when the hook has an internal delay and the
395+
* update function is called again before the updated state is sent out.
396+
*
397+
* Use this as a wrapper around `useOperationsEditorState`,
398+
* `useVariablesEditorState`, or `useHeadersEditorState` if you anticipate
399+
* calling them with great frequency (due to, for instance, mouse, keyboard, or
400+
* network events).
401+
*
402+
* Example:
403+
*
404+
* ```ts
405+
* const [operationsString, handleEditOperations] =
406+
* useOptimisticState(useOperationsEditorState());
407+
* ```
408+
*/
409+
export function useOptimisticState([
410+
upstreamState,
411+
upstreamSetState,
412+
]: ReturnType<typeof useEditorState>): ReturnType<typeof useEditorState> {
413+
const lastStateRef = useRef({
414+
/** The last thing that we sent upstream; we're expecting this back */
415+
pending: null as string | null,
416+
/** The last thing we received from upstream */
417+
last: upstreamState,
418+
});
419+
420+
const [state, setOperationsText] = useState(upstreamState);
421+
422+
useEffect(() => {
423+
if (lastStateRef.current.last === upstreamState) {
424+
// No change; ignore
425+
} else {
426+
lastStateRef.current.last = upstreamState;
427+
if (lastStateRef.current.pending === null) {
428+
// Gracefully accept update from upstream
429+
setOperationsText(upstreamState);
430+
} else if (lastStateRef.current.pending === upstreamState) {
431+
// They received our update and sent it back to us - clear pending, and
432+
// send next if appropriate
433+
lastStateRef.current.pending = null;
434+
if (upstreamState !== state) {
435+
// Change has occurred; upstream it
436+
lastStateRef.current.pending = state;
437+
upstreamSetState(state);
438+
}
439+
} else {
440+
// They got a different update; overwrite our local state (!!)
441+
lastStateRef.current.pending = null;
442+
setOperationsText(upstreamState);
443+
}
444+
}
445+
}, [upstreamState, state, upstreamSetState]);
446+
447+
const setState = useCallback(
448+
(newState: string) => {
449+
setOperationsText(newState);
450+
if (
451+
lastStateRef.current.pending === null &&
452+
lastStateRef.current.last !== newState
453+
) {
454+
// No pending updates and change has occurred... send it upstream
455+
lastStateRef.current.pending = newState;
456+
upstreamSetState(newState);
457+
}
458+
},
459+
[upstreamSetState],
460+
);
461+
462+
return useMemo(() => [state, setState], [state, setState]);
463+
}

‎packages/graphiql-react/src/editor/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export {
1818
usePrettifyEditors,
1919
useEditorState,
2020
useOperationsEditorState,
21+
useOptimisticState,
2122
useVariablesEditorState,
2223
useHeadersEditorState,
2324
} from './hooks';

‎packages/graphiql-react/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export {
1818
useVariableEditor,
1919
useEditorState,
2020
useOperationsEditorState,
21+
useOptimisticState,
2122
useVariablesEditorState,
2223
useHeadersEditorState,
2324
VariableEditor,

0 commit comments

Comments
 (0)
Please sign in to comment.