Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/large-fans-stop.md
Original file line number Diff line number Diff line change
@@ -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.
80 changes: 49 additions & 31 deletions apps/ensadmin/src/components/graphiql-editor/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<GraphiQLProps, "fetcher"> {
/** The URL of the GraphQL endpoint */
url: string;
}

const EMPTY_PLUGINS: NonNullable<GraphiQLProps["plugins"]> = [];

/**
* 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],
Comment thread
shrugs marked this conversation as resolved.
);

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;
},
Comment thread
shrugs marked this conversation as resolved.
};
}, [url]);
Comment thread
shrugs marked this conversation as resolved.

// 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;
}
Comment thread
shrugs marked this conversation as resolved.

return (
<div className="flex-1 graphiql-container">
Expand All @@ -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}
/>
</div>
Expand Down
Loading