Skip to content

Commit

Permalink
Fix bug in Explorer plugin where characters are dropped when typing q…
Browse files Browse the repository at this point in the history
…uickly (#3526)

Use optimistic editor state when interfacing between explorer and GraphiQL
  • Loading branch information
benjie committed Feb 1, 2024
1 parent 88d76ca commit 2b6ea31
Show file tree
Hide file tree
Showing 6 changed files with 90 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .changeset/fresh-rabbits-move.md
@@ -0,0 +1,5 @@
---
'@graphiql/plugin-explorer': patch
---

Fix bug whereby typing quickly into explorer sidebar would result in characters being dropped.
5 changes: 5 additions & 0 deletions .changeset/silent-spoons-shout.md
@@ -0,0 +1,5 @@
---
'@graphiql/react': patch
---

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());`
5 changes: 4 additions & 1 deletion packages/graphiql-plugin-explorer/src/index.tsx
Expand Up @@ -4,6 +4,7 @@ import {
useExecutionContext,
useSchemaContext,
useOperationsEditorState,
useOptimisticState,
} from '@graphiql/react';
import {
Explorer as GraphiQLExplorer,
Expand Down Expand Up @@ -139,7 +140,9 @@ function ExplorerPlugin(props: GraphiQLExplorerPluginProps) {
);

// load the current editor tab state into the explorer
const [operationsString, handleEditOperations] = useOperationsEditorState();
const [operationsString, handleEditOperations] = useOptimisticState(
useOperationsEditorState(),
);

return (
<GraphiQLExplorer
Expand Down
75 changes: 74 additions & 1 deletion packages/graphiql-react/src/editor/hooks.ts
Expand Up @@ -3,7 +3,7 @@ import type { EditorChange, EditorConfiguration } from 'codemirror';
import type { SchemaReference } from 'codemirror-graphql/utils/SchemaReference';
import copyToClipboard from 'copy-to-clipboard';
import { parse, print } from 'graphql';
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

import { useExplorerContext } from '../explorer';
import { usePluginContext } from '../plugin';
Expand Down Expand Up @@ -388,3 +388,76 @@ export const useHeadersEditorState = (): [
] => {
return useEditorState('header');
};

/**
* Implements an optimistic caching strategy around a useState-like hook in
* order to prevent loss of updates when the hook has an internal delay and the
* update function is called again before the updated state is sent out.
*
* Use this as a wrapper around `useOperationsEditorState`,
* `useVariablesEditorState`, or `useHeadersEditorState` if you anticipate
* calling them with great frequency (due to, for instance, mouse, keyboard, or
* network events).
*
* Example:
*
* ```ts
* const [operationsString, handleEditOperations] =
* useOptimisticState(useOperationsEditorState());
* ```
*/
export function useOptimisticState([
upstreamState,
upstreamSetState,
]: ReturnType<typeof useEditorState>): ReturnType<typeof useEditorState> {
const lastStateRef = useRef({
/** The last thing that we sent upstream; we're expecting this back */
pending: null as string | null,
/** The last thing we received from upstream */
last: upstreamState,
});

const [state, setOperationsText] = useState(upstreamState);

useEffect(() => {
if (lastStateRef.current.last === upstreamState) {
// No change; ignore
} else {
lastStateRef.current.last = upstreamState;
if (lastStateRef.current.pending === null) {
// Gracefully accept update from upstream
setOperationsText(upstreamState);
} else if (lastStateRef.current.pending === upstreamState) {
// They received our update and sent it back to us - clear pending, and
// send next if appropriate
lastStateRef.current.pending = null;
if (upstreamState !== state) {
// Change has occurred; upstream it
lastStateRef.current.pending = state;
upstreamSetState(state);
}
} else {
// They got a different update; overwrite our local state (!!)
lastStateRef.current.pending = null;
setOperationsText(upstreamState);
}
}
}, [upstreamState, state, upstreamSetState]);

const setState = useCallback(
(newState: string) => {
setOperationsText(newState);
if (
lastStateRef.current.pending === null &&
lastStateRef.current.last !== newState
) {
// No pending updates and change has occurred... send it upstream
lastStateRef.current.pending = newState;
upstreamSetState(newState);
}
},
[upstreamSetState],
);

return useMemo(() => [state, setState], [state, setState]);
}
1 change: 1 addition & 0 deletions packages/graphiql-react/src/editor/index.ts
Expand Up @@ -18,6 +18,7 @@ export {
usePrettifyEditors,
useEditorState,
useOperationsEditorState,
useOptimisticState,
useVariablesEditorState,
useHeadersEditorState,
} from './hooks';
Expand Down
1 change: 1 addition & 0 deletions packages/graphiql-react/src/index.ts
Expand Up @@ -18,6 +18,7 @@ export {
useVariableEditor,
useEditorState,
useOperationsEditorState,
useOptimisticState,
useVariablesEditorState,
useHeadersEditorState,
VariableEditor,
Expand Down

0 comments on commit 2b6ea31

Please sign in to comment.