From 24f323f193fc772e3e5320c693d588731670735e Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Mon, 27 Apr 2026 12:01:48 +0100 Subject: [PATCH 1/4] docs(changeset): 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 --- .changeset/large-fans-stop.md | 5 ++ .../components/graphiql-editor/components.tsx | 75 +++++++++++-------- 2 files changed, 48 insertions(+), 32 deletions(-) create mode 100644 .changeset/large-fans-stop.md diff --git a/.changeset/large-fans-stop.md b/.changeset/large-fans-stop.md new file mode 100644 index 0000000000..139b328fac --- /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..9a951d74f2 100644 --- a/apps/ensadmin/src/components/graphiql-editor/components.tsx +++ b/apps/ensadmin/src/components/graphiql-editor/components.tsx @@ -7,50 +7,61 @@ 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; - } - - const fetcher = createGraphiQLFetcher({ - url, - // Disable subscriptions for now since we don't have a WebSocket server - // legacyWsClient: false, - subscriptionUrl: undefined, - wsConnectionParams: undefined, - }); +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], + ); - // Create a unique storage namespace for each endpoint - const storageNamespace = `ensnode:graphiql:${url}`; + const storage = useMemo(() => { + const storageNamespace = `ensnode:graphiql:${url}`; + return { + getItem: (key: string) => 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, + }; + }, [url]); - // 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, explorerPlugin(), ...plugins], + [plugins], + ); - const explorer = explorerPlugin(); + if (!url || typeof window === "undefined") { + return null; + } return (
@@ -60,7 +71,7 @@ export function GraphiQLEditor({ url, plugins = [], ...props }: GraphiQLPropsWit storage={storage} forcedTheme="light" fetcher={fetcher} - plugins={[HISTORY_PLUGIN, explorer, ...plugins]} + plugins={mergedPlugins} {...props} />
From 59659c8960eba37d7be9ac0b3d25ea3167b60563 Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 29 Apr 2026 13:10:47 -0500 Subject: [PATCH 2/4] fix(ensadmin): SSR-safe storage memo, namespaced clear, hoisted explorer - Guard storage useMemo body against SSR (typeof window check) so localStorage access doesn't throw before the early-return; also makes length a getter so it's no longer a stale snapshot. - Restrict storage.clear() to the per-URL namespace prefix so it stops wiping unrelated ENSAdmin localStorage state. - Hoist explorerPlugin() into its own useMemo with [] deps so callers passing an unstable plugins array don't reset the explorer instance. - Trailing period in changeset. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/large-fans-stop.md | 2 +- .../components/graphiql-editor/components.tsx | 35 +++++++++++-------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/.changeset/large-fans-stop.md b/.changeset/large-fans-stop.md index 139b328fac..9e695b4f56 100644 --- a/.changeset/large-fans-stop.md +++ b/.changeset/large-fans-stop.md @@ -2,4 +2,4 @@ "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 +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 9a951d74f2..10d6553a19 100644 --- a/apps/ensadmin/src/components/graphiql-editor/components.tsx +++ b/apps/ensadmin/src/components/graphiql-editor/components.tsx @@ -20,11 +20,7 @@ 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 = EMPTY_PLUGINS, - ...props -}: GraphiQLPropsWithUrl) { +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. @@ -40,24 +36,33 @@ export function GraphiQLEditor({ [url], ); + // 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(`${storageNamespace}:${key}`), - setItem: (key: string, value: string) => - localStorage.setItem(`${storageNamespace}:${key}`, value), - removeItem: (key: string) => localStorage.removeItem(`${storageNamespace}:${key}`), + 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: () => { - localStorage.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; }, - length: localStorage.length, }; }, [url]); - const mergedPlugins = useMemo( - () => [HISTORY_PLUGIN, explorerPlugin(), ...plugins], - [plugins], - ); + const explorer = useMemo(() => explorerPlugin(), []); + + const mergedPlugins = useMemo(() => [HISTORY_PLUGIN, explorer, ...plugins], [explorer, plugins]); if (!url || typeof window === "undefined") { return null; From 062ba522a4803b56e5d4bde403092608b5d23431 Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 29 Apr 2026 14:02:50 -0500 Subject: [PATCH 3/4] refactor(ensadmin): hoist explorerPlugin() to module scope Replace useMemo([]) with a plain module-level const since the plugin takes no inputs and a stable reference is all we need. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ensadmin/src/components/graphiql-editor/components.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/ensadmin/src/components/graphiql-editor/components.tsx b/apps/ensadmin/src/components/graphiql-editor/components.tsx index 10d6553a19..4aee303eaf 100644 --- a/apps/ensadmin/src/components/graphiql-editor/components.tsx +++ b/apps/ensadmin/src/components/graphiql-editor/components.tsx @@ -15,6 +15,7 @@ interface GraphiQLPropsWithUrl extends Omit { } const EMPTY_PLUGINS: NonNullable = []; +const EXPLORER_PLUGIN = explorerPlugin(); /** * The GraphiQL editor component used to render the generic GraphiQL editor UI. @@ -60,9 +61,7 @@ export function GraphiQLEditor({ url, plugins = EMPTY_PLUGINS, ...props }: Graph }; }, [url]); - const explorer = useMemo(() => explorerPlugin(), []); - - const mergedPlugins = useMemo(() => [HISTORY_PLUGIN, explorer, ...plugins], [explorer, plugins]); + const mergedPlugins = useMemo(() => [HISTORY_PLUGIN, EXPLORER_PLUGIN, ...plugins], [plugins]); if (!url || typeof window === "undefined") { return null; From bdd730eb9ff135df5586505d5269a4c0d8f38bbc Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 29 Apr 2026 14:11:43 -0500 Subject: [PATCH 4/4] revert: keep explorerPlugin per-instance via useMemo The explorer plugin holds editor-scoped state, so a single shared instance would clobber state across multiple GraphiQL editors mounted simultaneously. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ensadmin/src/components/graphiql-editor/components.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/ensadmin/src/components/graphiql-editor/components.tsx b/apps/ensadmin/src/components/graphiql-editor/components.tsx index 4aee303eaf..d2395912a1 100644 --- a/apps/ensadmin/src/components/graphiql-editor/components.tsx +++ b/apps/ensadmin/src/components/graphiql-editor/components.tsx @@ -15,7 +15,6 @@ interface GraphiQLPropsWithUrl extends Omit { } const EMPTY_PLUGINS: NonNullable = []; -const EXPLORER_PLUGIN = explorerPlugin(); /** * The GraphiQL editor component used to render the generic GraphiQL editor UI. @@ -61,7 +60,11 @@ export function GraphiQLEditor({ url, plugins = EMPTY_PLUGINS, ...props }: Graph }; }, [url]); - const mergedPlugins = useMemo(() => [HISTORY_PLUGIN, EXPLORER_PLUGIN, ...plugins], [plugins]); + // 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(), []); + + const mergedPlugins = useMemo(() => [HISTORY_PLUGIN, explorer, ...plugins], [explorer, plugins]); if (!url || typeof window === "undefined") { return null;