diff --git a/.changeset/large-fans-stop.md b/.changeset/large-fans-stop.md new file mode 100644 index 0000000000..9e695b4f56 --- /dev/null +++ b/.changeset/large-fans-stop.md @@ -0,0 +1,5 @@ +--- +"ensadmin": patch +--- + +Fix ENSAdmin GraphiQL docs sidebar failing to stay open on the omnigraph page. The editor now memoizes its fetcher, storage, and plugins so 1Hz parent re-renders (driven by the realtime indexing-status projection) no longer trigger schema re-introspection. diff --git a/apps/ensadmin/src/components/graphiql-editor/components.tsx b/apps/ensadmin/src/components/graphiql-editor/components.tsx index 8a0d75d99b..d2395912a1 100644 --- a/apps/ensadmin/src/components/graphiql-editor/components.tsx +++ b/apps/ensadmin/src/components/graphiql-editor/components.tsx @@ -7,50 +7,68 @@ import "@graphiql/plugin-explorer/style.css"; import { explorerPlugin } from "@graphiql/plugin-explorer"; import { createGraphiQLFetcher } from "@graphiql/toolkit"; import { GraphiQL, type GraphiQLProps, HISTORY_PLUGIN } from "graphiql"; +import { useMemo } from "react"; interface GraphiQLPropsWithUrl extends Omit { /** The URL of the GraphQL endpoint */ url: string; } +const EMPTY_PLUGINS: NonNullable = []; + /** * The GraphiQL editor component used to render the generic GraphiQL editor UI. * We use this component to render GraphiQL editors. */ -export function GraphiQLEditor({ url, plugins = [], ...props }: GraphiQLPropsWithUrl) { - if (!url || typeof window === "undefined") { - return null; - } +export function GraphiQLEditor({ url, plugins = EMPTY_PLUGINS, ...props }: GraphiQLPropsWithUrl) { + // Memoize the fetcher so its reference is stable across re-renders. Otherwise + // GraphiQL re-runs schema introspection on every parent re-render (e.g. when + // a parent subscribes to a 1s-ticking hook), which resets the docs sidebar. + const fetcher = useMemo( + () => + createGraphiQLFetcher({ + url, + // Disable subscriptions for now since we don't have a WebSocket server + // legacyWsClient: false, + subscriptionUrl: undefined, + wsConnectionParams: undefined, + }), + [url], + ); - const fetcher = createGraphiQLFetcher({ - url, - // Disable subscriptions for now since we don't have a WebSocket server - // legacyWsClient: false, - subscriptionUrl: undefined, - wsConnectionParams: undefined, - }); + // Guard against SSR: hooks run before the early-return below, and `localStorage` + // is undefined on the server. Returning `undefined` is safe because the component + // returns `null` after the hooks when there's no window. + const storage = useMemo(() => { + if (typeof window === "undefined") return undefined; + const storageNamespace = `ensnode:graphiql:${url}`; + const prefix = `${storageNamespace}:`; + return { + getItem: (key: string) => localStorage.getItem(`${prefix}${key}`), + setItem: (key: string, value: string) => localStorage.setItem(`${prefix}${key}`, value), + removeItem: (key: string) => localStorage.removeItem(`${prefix}${key}`), + // Only clear keys in this namespace so unrelated ENSAdmin state survives. + clear: () => { + for (let i = localStorage.length - 1; i >= 0; i--) { + const key = localStorage.key(i); + if (key?.startsWith(prefix)) localStorage.removeItem(key); + } + }, + get length() { + return localStorage.length; + }, + }; + }, [url]); - // Create a unique storage namespace for each endpoint - const storageNamespace = `ensnode:graphiql:${url}`; + // Instantiated per editor instance — explorerPlugin holds editor-scoped state, + // so sharing one instance across editors causes them to clobber each other. + const explorer = useMemo(() => explorerPlugin(), []); - // Custom storage implementation with namespaced keys - const storage = { - getItem: (key: string) => { - return localStorage.getItem(`${storageNamespace}:${key}`); - }, - setItem: (key: string, value: string) => { - localStorage.setItem(`${storageNamespace}:${key}`, value); - }, - removeItem: (key: string) => { - localStorage.removeItem(`${storageNamespace}:${key}`); - }, - clear: () => { - localStorage.clear(); - }, - length: localStorage.length, - }; + const mergedPlugins = useMemo(() => [HISTORY_PLUGIN, explorer, ...plugins], [explorer, plugins]); - const explorer = explorerPlugin(); + if (!url || typeof window === "undefined") { + return null; + } return (
@@ -60,7 +78,7 @@ export function GraphiQLEditor({ url, plugins = [], ...props }: GraphiQLPropsWit storage={storage} forcedTheme="light" fetcher={fetcher} - plugins={[HISTORY_PLUGIN, explorer, ...plugins]} + plugins={mergedPlugins} {...props} />