diff --git a/.changeset/dry-olives-crash.md b/.changeset/dry-olives-crash.md new file mode 100644 index 00000000000..766b267ca0c --- /dev/null +++ b/.changeset/dry-olives-crash.md @@ -0,0 +1,7 @@ +--- +'graphiql': patch +'@graphiql/plugin-explorer': patch +'@graphiql/react': patch +--- + +avoid unecessary renders by using useMemo or useCallback diff --git a/.eslintrc.js b/.eslintrc.js index 1f4ffac28a5..97a639e3807 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -50,6 +50,12 @@ module.exports = { }, rules: { + '@arthurgeron/react-usememo/require-usememo': [ + 'error', + { + checkHookCalls: false, + }, + ], // Possible Errors (http://eslint.org/docs/rules/#possible-errors) 'no-console': 'error', 'no-constant-binary-expression': 2, @@ -310,7 +316,7 @@ module.exports = { '@typescript-eslint/no-namespace': 'off', }, - plugins: ['promise', 'sonarjs', 'unicorn'], + plugins: ['promise', 'sonarjs', 'unicorn', '@arthurgeron/react-usememo'], }, { // Rules that requires type information @@ -348,6 +354,7 @@ module.exports = { rules: { 'jest/no-conditional-expect': 'off', 'jest/expect-expect': ['error', { assertFunctionNames: ['expect*'] }], + '@arthurgeron/react-usememo/require-usememo': 'off', }, }, { @@ -413,6 +420,7 @@ module.exports = { 'no-undef': 'off', 'react/jsx-no-undef': 'off', 'react-hooks/rules-of-hooks': 'off', + '@arthurgeron/react-usememo/require-usememo': 'off', }, }, ], diff --git a/custom-words.txt b/custom-words.txt index 13a2c04df3d..96728a85636 100644 --- a/custom-words.txt +++ b/custom-words.txt @@ -65,6 +65,7 @@ LekoArts // packages and tools argparse +arthurgeron atpl browserslist bundlephobia @@ -104,6 +105,7 @@ templayed typedoc twing undici +usememo velocityjs vite vitejs diff --git a/examples/graphiql-parcel/src/index.tsx b/examples/graphiql-parcel/src/index.tsx index d21c3c8fbee..43c9e767c8b 100644 --- a/examples/graphiql-parcel/src/index.tsx +++ b/examples/graphiql-parcel/src/index.tsx @@ -1,30 +1,29 @@ -import ReactDOM from 'react-dom'; +import { render } from 'react-dom'; import { GraphiQL } from 'graphiql'; +import type { Fetcher } from '@graphiql/toolkit'; +import { CSSProperties } from 'react'; -const App = () => ( - { - const data = await fetch( - 'https://swapi-graphql.netlify.app/.netlify/functions/index', - { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(graphQLParams), - credentials: 'same-origin', - }, - ); - return data.json().catch(() => data.text()); - }} - /> -); +const fetcher: Fetcher = async graphQLParams => { + const data = await fetch( + 'https://swapi-graphql.netlify.app/.netlify/functions/index', + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(graphQLParams), + credentials: 'same-origin', + }, + ); + return data.json().catch(() => data.text()); +}; -ReactDOM.render(, document.getElementById('root')); +const style: CSSProperties = { height: '100vh' }; + +const App = () => ; + +render(, document.getElementById('root')); // Hot Module Replacement -if (module.hot) { - module.hot.accept(); -} +module.hot?.accept(); diff --git a/examples/graphiql-webpack/src/index.jsx b/examples/graphiql-webpack/src/index.jsx index e955f2693ff..905c6ea945a 100644 --- a/examples/graphiql-webpack/src/index.jsx +++ b/examples/graphiql-webpack/src/index.jsx @@ -51,6 +51,25 @@ ${getQuery(arg, 2)} const snippets = [exampleSnippetOne, exampleSnippetTwo]; +const fetcher = async (graphQLParams, options) => { + const data = await fetch( + 'https://swapi-graphql.netlify.app/.netlify/functions/index', + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...options.headers, + }, + body: JSON.stringify(graphQLParams), + credentials: 'same-origin', + }, + ); + return data.json().catch(() => data.text()); +}; + +const style = { height: '100vh' }; + const App = () => { const [query, setQuery] = React.useState(''); const explorerPlugin = useExplorerPlugin({ @@ -62,28 +81,18 @@ const App = () => { snippets, }); + const plugins = React.useMemo( + () => [explorerPlugin, exporterPlugin], + [explorerPlugin, exporterPlugin], + ); + return ( { - const data = await fetch( - 'https://swapi-graphql.netlify.app/.netlify/functions/index', - { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - ...options.headers, - }, - body: JSON.stringify(graphQLParams), - credentials: 'same-origin', - }, - ); - return data.json().catch(() => data.text()); - }} + plugins={plugins} + fetcher={fetcher} /> ); }; diff --git a/package.json b/package.json index 51734764a35..7b598e63875 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "tsc": "tsc --build" }, "devDependencies": { + "@arthurgeron/eslint-plugin-react-usememo": "^1.1.4", "@babel/cli": "^7.21.0", "@babel/core": "^7.21.0", "@babel/plugin-proposal-class-properties": "^7.18.6", diff --git a/packages/graphiql-plugin-explorer/src/index.tsx b/packages/graphiql-plugin-explorer/src/index.tsx index 4fd4116b678..35be73d05e8 100644 --- a/packages/graphiql-plugin-explorer/src/index.tsx +++ b/packages/graphiql-plugin-explorer/src/index.tsx @@ -5,122 +5,138 @@ import { useSchemaContext, } from '@graphiql/react'; import GraphiQLExplorer, { GraphiQLExplorerProps } from 'graphiql-explorer'; -import React, { useRef } from 'react'; +import React, { useCallback, useRef } from 'react'; import './graphiql-explorer.d.ts'; import './index.css'; +const colors = { + keyword: 'hsl(var(--color-primary))', + def: 'hsl(var(--color-tertiary))', + property: 'hsl(var(--color-info))', + qualifier: 'hsl(var(--color-secondary))', + attribute: 'hsl(var(--color-tertiary))', + number: 'hsl(var(--color-success))', + string: 'hsl(var(--color-warning))', + builtin: 'hsl(var(--color-success))', + string2: 'hsl(var(--color-secondary))', + variable: 'hsl(var(--color-secondary))', + atom: 'hsl(var(--color-tertiary))', +}; + +const arrowOpen = ( + + + +); + +const arrowClosed = ( + + + +); + +const checkboxUnchecked = ( + + + +); +const checkboxChecked = ( + + + + +); + +const styles = { + buttonStyle: { + backgroundColor: 'transparent', + border: 'none', + color: 'hsla(var(--color-neutral), var(--alpha-secondary, 0.6))', + cursor: 'pointer', + fontSize: '1em', + }, + explorerActionsStyle: { + padding: 'var(--px-8) var(--px-4)', + }, + actionButtonStyle: { + backgroundColor: 'transparent', + border: 'none', + color: 'hsla(var(--color-neutral), var(--alpha-secondary, 0.6))', + cursor: 'pointer', + fontSize: '1em', + }, +}; + function ExplorerPlugin(props: GraphiQLExplorerProps) { const { setOperationName } = useEditorContext({ nonNull: true }); const { schema } = useSchemaContext({ nonNull: true }); const { run } = useExecutionContext({ nonNull: true }); + const handleRunOperation = useCallback( + (operationName: string | null) => { + if (operationName) { + setOperationName(operationName); + } + run(); + }, + [run, setOperationName], + ); + return ( { - if (operationName) { - setOperationName(operationName); - } - run(); - }} + onRunOperation={handleRunOperation} explorerIsOpen - colors={{ - keyword: 'hsl(var(--color-primary))', - def: 'hsl(var(--color-tertiary))', - property: 'hsl(var(--color-info))', - qualifier: 'hsl(var(--color-secondary))', - attribute: 'hsl(var(--color-tertiary))', - number: 'hsl(var(--color-success))', - string: 'hsl(var(--color-warning))', - builtin: 'hsl(var(--color-success))', - string2: 'hsl(var(--color-secondary))', - variable: 'hsl(var(--color-secondary))', - atom: 'hsl(var(--color-tertiary))', - }} - arrowOpen={ - - - - } - arrowClosed={ - - - - } - checkboxUnchecked={ - - - - } - checkboxChecked={ - - - - - } - styles={{ - buttonStyle: { - backgroundColor: 'transparent', - border: 'none', - color: 'hsla(var(--color-neutral), var(--alpha-secondary, 0.6))', - cursor: 'pointer', - fontSize: '1em', - }, - explorerActionsStyle: { - padding: 'var(--px-8) var(--px-4)', - }, - actionButtonStyle: { - backgroundColor: 'transparent', - border: 'none', - color: 'hsla(var(--color-neutral), var(--alpha-secondary, 0.6))', - cursor: 'pointer', - fontSize: '1em', - }, - }} + colors={colors} + arrowOpen={arrowOpen} + arrowClosed={arrowClosed} + checkboxUnchecked={checkboxUnchecked} + checkboxChecked={checkboxChecked} + styles={styles} {...props} /> ); diff --git a/packages/graphiql-react/src/editor/components/header-editor.tsx b/packages/graphiql-react/src/editor/components/header-editor.tsx index 5ab7dae7da3..672a2b759a2 100644 --- a/packages/graphiql-react/src/editor/components/header-editor.tsx +++ b/packages/graphiql-react/src/editor/components/header-editor.tsx @@ -24,8 +24,8 @@ export function HeaderEditor({ isHidden, ...hookArgs }: HeaderEditorProps) { const ref = useHeaderEditor(hookArgs, HeaderEditor); useEffect(() => { - if (headerEditor && !isHidden) { - headerEditor.refresh(); + if (!isHidden) { + headerEditor?.refresh(); } }, [headerEditor, isHidden]); diff --git a/packages/graphiql-react/src/explorer/components/field-documentation.tsx b/packages/graphiql-react/src/explorer/components/field-documentation.tsx index 655e37b9d64..90b210be2d1 100644 --- a/packages/graphiql-react/src/explorer/components/field-documentation.tsx +++ b/packages/graphiql-react/src/explorer/components/field-documentation.tsx @@ -1,5 +1,5 @@ import { GraphQLArgument } from 'graphql'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { Button, MarkdownContent } from '../../ui'; import { ExplorerFieldDef } from '../context'; @@ -36,6 +36,9 @@ export function FieldDocumentation(props: FieldDocumentationProps) { function Arguments({ field }: { field: ExplorerFieldDef }) { const [showDeprecated, setShowDeprecated] = useState(false); + const handleShowDeprecated = useCallback(() => { + setShowDeprecated(true); + }, []); if (!('args' in field)) { return null; @@ -68,12 +71,7 @@ function Arguments({ field }: { field: ExplorerFieldDef }) { ))} ) : ( - ) diff --git a/packages/graphiql-react/src/explorer/components/search.tsx b/packages/graphiql-react/src/explorer/components/search.tsx index 886899d7b89..ecd4258432a 100644 --- a/packages/graphiql-react/src/explorer/components/search.tsx +++ b/packages/graphiql-react/src/explorer/components/search.tsx @@ -14,7 +14,15 @@ import { isInterfaceType, isObjectType, } from 'graphql'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ChangeEventHandler, + KeyboardEventHandler, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { MagnifyingGlassIcon } from '../../icons'; import { useSchemaContext } from '../../schema'; import debounce from '../../utility/debounce'; @@ -67,63 +75,73 @@ export function Search() { isInterfaceType(navItem.def) || isInputObjectType(navItem.def); + const handleSelect = useCallback( + (value: string) => { + const def = value as unknown as TypeMatch | FieldMatch; + push( + 'field' in def + ? { name: def.field.name, def: def.field } + : { name: def.type.name, def: def.type }, + ); + }, + [push], + ); + + const handleChange: ChangeEventHandler = useCallback( + event => { + setSearchValue(event.target.value); + }, + [], + ); + + const handleKeyDown: KeyboardEventHandler = useCallback( + event => { + if (!event.isDefaultPrevented()) { + const container = popoverRef.current; + if (!container) { + return; + } + + window.requestAnimationFrame(() => { + const element = container.querySelector('[aria-selected=true]'); + if (!(element instanceof HTMLElement)) { + return; + } + const top = element.offsetTop - container.scrollTop; + const bottom = + container.scrollTop + + container.clientHeight - + (element.offsetTop + element.clientHeight); + if (bottom < 0) { + container.scrollTop -= bottom; + } + if (top < 0) { + container.scrollTop += top; + } + }); + } + + // We don't want, for example, "Escape" key presses to bubble up + // further. This could have other effects like closing a dialog + // that contains this component. + event.stopPropagation(); + }, + [], + ); + return shouldSearchBoxAppear ? ( - { - const def = value as unknown as TypeMatch | FieldMatch; - push( - 'field' in def - ? { name: def.field.name, def: def.field } - : { name: def.type.name, def: def.type }, - ); - }} - > +
{ - if (inputRef.current) { - inputRef.current.focus(); - } + inputRef.current?.focus(); }} > { - setSearchValue(event.target.value); - }} - onKeyDown={event => { - if (!event.isDefaultPrevented()) { - const container = popoverRef.current; - if (!container) { - return; - } - - window.requestAnimationFrame(() => { - const element = container.querySelector('[aria-selected=true]'); - if (!(element instanceof HTMLElement)) { - return; - } - const top = element.offsetTop - container.scrollTop; - const bottom = - container.scrollTop + - container.clientHeight - - (element.offsetTop + element.clientHeight); - if (bottom < 0) { - container.scrollTop -= bottom; - } - if (top < 0) { - container.scrollTop += top; - } - }); - } - - // We don't want for example "Escape" key presses to bubble up - // further. This could have other effects like closing a dialog - // that contains this component. - event.stopPropagation(); - }} + onChange={handleChange} + onKeyDown={handleKeyDown} placeholder="⌘ K" ref={inputRef} value={searchValue} diff --git a/packages/graphiql-react/src/explorer/components/type-documentation.tsx b/packages/graphiql-react/src/explorer/components/type-documentation.tsx index 205ab71a3b5..3e5bccbea62 100644 --- a/packages/graphiql-react/src/explorer/components/type-documentation.tsx +++ b/packages/graphiql-react/src/explorer/components/type-documentation.tsx @@ -8,7 +8,7 @@ import { isNamedType, isObjectType, } from 'graphql'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { useSchemaContext } from '../../schema'; import { Button, MarkdownContent } from '../../ui'; @@ -63,6 +63,10 @@ function ImplementsInterfaces({ type }: { type: GraphQLNamedType }) { function Fields({ type }: { type: GraphQLNamedType }) { const [showDeprecated, setShowDeprecated] = useState(false); + const handleShowDeprecated = useCallback(() => { + setShowDeprecated(true); + }, []); + if ( !isObjectType(type) && !isInterfaceType(type) && @@ -101,12 +105,7 @@ function Fields({ type }: { type: GraphQLNamedType }) { ))} ) : ( - ) @@ -158,6 +157,9 @@ function Field({ field }: { field: ExplorerFieldDef }) { function EnumValues({ type }: { type: GraphQLNamedType }) { const [showDeprecated, setShowDeprecated] = useState(false); + const handleShowDeprecated = useCallback(() => { + setShowDeprecated(true); + }, []); if (!isEnumType(type)) { return null; @@ -190,12 +192,7 @@ function EnumValues({ type }: { type: GraphQLNamedType }) { ))} ) : ( - ) diff --git a/packages/graphiql-react/src/history/components.tsx b/packages/graphiql-react/src/history/components.tsx index 3ca42552d85..f720c06df38 100644 --- a/packages/graphiql-react/src/history/components.tsx +++ b/packages/graphiql-react/src/history/components.tsx @@ -1,5 +1,12 @@ import { QueryStoreItem } from '@graphiql/toolkit'; -import { Fragment, useEffect, useRef, useState } from 'react'; +import { + Fragment, + MouseEventHandler, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; import { clsx } from 'clsx'; import { useEditorContext } from '../editor'; @@ -56,8 +63,8 @@ export function HistoryItem(props: QueryHistoryItemProps) { const [isEditable, setIsEditable] = useState(false); useEffect(() => { - if (isEditable && inputRef.current) { - inputRef.current.focus(); + if (isEditable) { + inputRef.current?.focus(); } }, [isEditable]); @@ -66,6 +73,40 @@ export function HistoryItem(props: QueryHistoryItemProps) { props.item.operationName || formatQuery(props.item.query); + const handleSave = useCallback(() => { + setIsEditable(false); + editLabel({ ...props.item, label: inputRef.current?.value }); + }, [editLabel, props.item]); + + const handleClose = useCallback(() => { + setIsEditable(false); + }, []); + + const handleEditLabel: MouseEventHandler = useCallback( + e => { + e.stopPropagation(); + setIsEditable(true); + }, + [], + ); + + const handleHistoryItemClick: MouseEventHandler = + useCallback(() => { + const { query, variables, headers } = props.item; + queryEditor?.setValue(query ?? ''); + variableEditor?.setValue(variables ?? ''); + headerEditor?.setValue(headers ?? ''); + }, [props.item, queryEditor, variableEditor, headerEditor]); + + const handleToggleFavorite: MouseEventHandler = + useCallback( + e => { + e.stopPropagation(); + toggleFavorite(props.item); + }, + [props.item, toggleFavorite], + ); + return (
  • {isEditable ? ( @@ -84,23 +125,10 @@ export function HistoryItem(props: QueryHistoryItemProps) { }} placeholder="Type a label" /> - { - setIsEditable(false); - editLabel({ ...props.item, label: inputRef.current?.value }); - }} - > + Save - { - setIsEditable(false); - }} - > + @@ -109,11 +137,7 @@ export function HistoryItem(props: QueryHistoryItemProps) { { - queryEditor?.setValue(props.item.query ?? ''); - variableEditor?.setValue(props.item.variables ?? ''); - headerEditor?.setValue(props.item.headers ?? ''); - }} + onClick={handleHistoryItemClick} > {displayName} @@ -121,10 +145,7 @@ export function HistoryItem(props: QueryHistoryItemProps) { { - e.stopPropagation(); - setIsEditable(true); - }} + onClick={handleEditLabel} aria-label="Edit label" >