Skip to content

fix(apps/ensadmin): keep open ENSAdmin GraphiQL docs sidebar#2001

Open
notrab wants to merge 1 commit intomainfrom
notrab/fix-ensadmin-graphiql-introspection-loop
Open

fix(apps/ensadmin): keep open ENSAdmin GraphiQL docs sidebar#2001
notrab wants to merge 1 commit intomainfrom
notrab/fix-ensadmin-graphiql-introspection-loop

Conversation

@notrab
Copy link
Copy Markdown
Member

@notrab notrab commented Apr 27, 2026

…pen 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

Lite PR

Tip: Review docs on the ENSNode PR process

Summary

  • Memoize the GraphiQL fetcher, storage, and plugins in apps/ensadmin/src/components/graphiql-editor/components.tsx so they keep stable references across renders.
  • Hoist the plugins = [] default to a module-level constant so it doesn't bust the memo on every render.
  • Move the if (!url || typeof window === "undefined") early-return below the hooks to keep hook order stable.

Why

The omnigraph page's parent ticks once per second via useNow({ timeToRefresh: 1 }) inside useIndexingStatusWithSwr (driving the realtime indexing-status projection).

The GraphiQLEditor was rebuilding its fetcher, storage, and explorer plugin on every render, and GraphiQL re-runs schema introspection whenever the fetcher reference changes. Net result: the docs sidebar would never stay open and the schema was re-fetched every sec.


Testing

  • How this was tested.
  • If you didn't test it, say why.

Notes for Reviewer (Optional)

  • The 10s /api/indexing-status refetch and th realtime-projection re-renders are both intentional and unchanged by this PR.
  • You can verify against any ENSNode server that has been redeployed since Extend stack info data model #1988 (or by running ensindexer + ensapi locally from this branch). Expected behaviour: a single introspection request, /api/indexing-status polled every 10s, docs sidebar opens and stays open.

Pre-Review Checklist (Blocking)

  • This PR does not introduce significant changes and is low-risk to review quickly.
  • Relevant changesets are included (or are not required)

…pen 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
Copilot AI review requested due to automatic review settings April 27, 2026 11:06
@notrab notrab requested a review from a team as a code owner April 27, 2026 11:06
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Apr 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
admin.ensnode.io Ready Ready Preview, Comment Apr 27, 2026 11:06am
ensnode.io Ready Ready Preview, Comment Apr 27, 2026 11:06am
ensrainbow.io Ready Ready Preview, Comment Apr 27, 2026 11:06am

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 27, 2026

🦋 Changeset detected

Latest commit: 24f323f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 23 packages
Name Type
ensadmin Patch
ensindexer Patch
ensrainbow Patch
ensapi Patch
fallback-ensapi Patch
enssdk Patch
enscli Patch
enskit Patch
ensskills Patch
@ensnode/datasources Patch
@ensnode/ensrainbow-sdk Patch
@ensnode/ensdb-sdk Patch
@ensnode/ensnode-react Patch
@ensnode/ensnode-sdk Patch
@ensnode/integration-test-env Patch
@ensnode/ponder-sdk Patch
@ensnode/ponder-subgraph Patch
@ensnode/shared-configs Patch
@docs/ensnode Patch
@docs/ensrainbow Patch
@namehash/ens-referrals Patch
@namehash/namehash-ui Patch
@ensnode/enskit-react-example Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 27, 2026

📝 Walkthrough

Walkthrough

This PR optimizes the GraphiQL editor component in the ENSAdmin application by memoizing the fetcher, storage, and plugins to prevent unnecessary re-renders and schema re-introspection during frequent parent updates from the realtime indexing-status projection.

Changes

Cohort / File(s) Summary
Changeset Documentation
.changeset/large-fans-stop.md
Patch release note for ensadmin documenting a fix where the GraphiQL docs sidebar remains open on the omnigraph page by preventing schema re-introspection via memoization.
GraphiQL Editor Optimization
apps/ensadmin/src/components/graphiql-editor/components.tsx
Memoizes fetcher, storage, and plugins using useMemo keyed by url to prevent reconstruction on parent re-renders. Adds EMPTY_PLUGINS constant and refactors null guard placement.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐰 A memo here, a memo there,
Plugins memoized with utmost care,
No more re-renders in the night,
GraphiQL docs stay open, bright! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title directly and specifically describes the main fix: preventing the ENSAdmin GraphiQL docs sidebar from closing during parent re-renders.
Description check ✅ Passed The description follows the template with all required sections (Summary, Why, Testing, Notes, Checklist) completed and provides clear technical detail about the memoization fix and its rationale.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch notrab/fix-ensadmin-graphiql-introspection-loop

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 27, 2026

Greptile Summary

This PR memoizes the GraphiQL fetcher, storage, and plugins in GraphiQLEditor to prevent 1 Hz parent re-renders from triggering schema re-introspection, fixing the docs sidebar resetting. The approach is correct, but moving the typeof window === "undefined" guard below the hooks exposes a localStorage access in the storage memo to SSR, where localStorage is not defined.

  • P1: localStorage.length is evaluated eagerly inside the storage useMemo (line 53) before the typeof window === "undefined" early-return (line 62). In Next.js SSR this throws ReferenceError: localStorage is not defined.

Confidence Score: 3/5

Not safe to merge as-is — the storage memo accesses localStorage before the SSR guard, causing a ReferenceError during server-side rendering.

A P1 bug is present: localStorage.length is evaluated eagerly inside the storage useMemo before the typeof window === 'undefined' guard, which throws in SSR. The fix pattern is small, but it needs to be addressed before merging.

apps/ensadmin/src/components/graphiql-editor/components.tsx — the storage useMemo needs a window guard before any localStorage access.

Important Files Changed

Filename Overview
apps/ensadmin/src/components/graphiql-editor/components.tsx Memoizes fetcher, storage, and plugins to fix docs sidebar reset — but moving the early return below hooks exposes localStorage to SSR before the window guard.
.changeset/large-fans-stop.md Changeset entry correctly marking the fix as a patch release for ensadmin.

Reviews (1): Last reviewed commit: "docs(changeset): Fix ENSAdmin GraphiQL d..." | Re-trigger Greptile

Comment on lines +43 to +55
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]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 localStorage accessed before SSR guard

The storage memo eagerly evaluates localStorage.length (line 53) and all four methods close over localStorage — both happen before the typeof window === "undefined" guard that was intentionally moved to line 62. In Next.js App Router, "use client" components still SSR on the server, where localStorage is not defined and this throws ReferenceError: localStorage is not defined at render time.

The original code was safe because the const storage = {…} block sat after the early-return guard; hoisting it into a useMemo above the guard removes that protection.

Suggested fix — guard the memo body:

const storage = useMemo(() => {
  if (typeof window === "undefined") return null;
  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(); },
    get length() { return localStorage.length; },
  };
}, [url]);

(Note: the get length() accessor also fixes the stale snapshot of localStorage.length captured at memo-creation time.)

Comment on lines +57 to +60
const mergedPlugins = useMemo(
() => [HISTORY_PLUGIN, explorerPlugin(), ...plugins],
[plugins],
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 explorerPlugin() called inside memo, new instance on every plugins change

explorerPlugin() is called inside the useMemo factory, so a new instance is created whenever plugins changes (e.g. if the caller doesn't stabilize the array reference). In the default case (plugins = EMPTY_PLUGINS) this is fine, but any caller that passes an unstable plugins array will cause the explorer to reset. Extracting the explorer instance to its own memoized value independent of plugins would isolate the two concerns:

const explorer = useMemo(() => explorerPlugin(), []);

const mergedPlugins = useMemo(
  () => [HISTORY_PLUGIN, explorer, ...plugins],
  [explorer, plugins],
);

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes ENSAdmin’s GraphiQL docs sidebar not staying open on the omnigraph page by stabilizing key GraphiQL prop references across frequent parent re-renders, preventing repeated schema re-introspection.

Changes:

  • Memoize the GraphiQL fetcher, storage, and plugins to keep stable references across renders.
  • Hoist the default plugins value to a module-level constant to avoid busting memoization.
  • Add a changeset documenting the patch release.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
apps/ensadmin/src/components/graphiql-editor/components.tsx Memoizes fetcher/storage/plugins to prevent GraphiQL schema re-introspection and UI resets on frequent parent renders.
.changeset/large-fans-stop.md Adds a patch changeset describing the GraphiQL docs sidebar fix.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +31 to +40
const fetcher = useMemo(
() =>
createGraphiQLFetcher({
url,
// Disable subscriptions for now since we don't have a WebSocket server
// legacyWsClient: false,
subscriptionUrl: undefined,
wsConnectionParams: undefined,
}),
[url],
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createGraphiQLFetcher is invoked unconditionally via useMemo even when url is falsy. Since the if (!url ...) return null guard is now after the hooks, an empty string URL can still cause fetcher creation (and potentially throw) before the component returns. Consider making the memo callback handle the empty-url case (e.g., return a sentinel fetcher) and then returning null after hooks when there’s no usable fetcher/url.

Copilot uses AI. Check for mistakes.
Comment on lines +45 to +53
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,
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

localStorage is accessed during render inside the useMemo callback, but the typeof window === "undefined" guard is evaluated later. In any non-browser render (SSR, node-based tests without jsdom, storybook SSR, etc.), this will throw before the component can return null. Consider guarding inside the memo callback (or providing a no-op/in-memory storage fallback) so the component can safely render when window/localStorage are unavailable.

Suggested change
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,
if (typeof window === "undefined" || !window.localStorage) {
return {
getItem: (_key: string) => null,
setItem: (_key: string, _value: string) => {},
removeItem: (_key: string) => {},
clear: () => {},
length: 0,
};
}
return {
getItem: (key: string) =>
window.localStorage.getItem(`${storageNamespace}:${key}`),
setItem: (key: string, value: string) =>
window.localStorage.setItem(`${storageNamespace}:${key}`, value),
removeItem: (key: string) =>
window.localStorage.removeItem(`${storageNamespace}:${key}`),
clear: () => {
window.localStorage.clear();
},
length: window.localStorage.length,

Copilot uses AI. Check for mistakes.
Comment on lines +45 to +51
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();
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

storage.clear calls localStorage.clear(), which wipes all localStorage for the origin (including unrelated ENSAdmin state). Since keys are already namespaced, clear should remove only keys within the ensnode:graphiql:${url}:* namespace to avoid surprising data loss.

Suggested change
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();
const storagePrefix = `${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}`),
clear: () => {
const keysToRemove: string[] = [];
for (let index = 0; index < localStorage.length; index += 1) {
const key = localStorage.key(index);
if (key?.startsWith(storagePrefix)) {
keysToRemove.push(key);
}
}
for (const key of keysToRemove) {
localStorage.removeItem(key);
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.changeset/large-fans-stop.md:
- Line 5: The changeset's release-note paragraph is missing a terminal period;
open the changeset's release-note paragraph (the sentence beginning "Fix
ENSAdmin GraphiQL docs sidebar...") and add a period at the end so the paragraph
ends with proper punctuation.

In `@apps/ensadmin/src/components/graphiql-editor/components.tsx`:
- Around line 50-52: The current clear() implementation calls
localStorage.clear() which wipes all origin storage; change clear() in
components.tsx to only remove keys that start with the GraphiQL namespace (the
`ensnode:graphiql:${url}` prefix) by iterating localStorage keys and calling
localStorage.removeItem(key) for matches so only per-URL namespaced entries are
deleted; ensure you reference the same `url`/prefix construction used elsewhere
to compute the namespace.
- Around line 43-64: The storage useMemo factory currently accesses
localStorage.length eagerly (in storage = useMemo(...)) which runs during render
and will throw during SSR before the later typeof window guard; fix by moving
the typeof window check into the useMemo factory (or return a safe fallback) and
make length a lazy getter that reads localStorage.length at access time (and
ensure getItem/setItem/removeItem/clear all reference window.localStorage inside
their functions rather than capturing it at memo time). Update the storage
useMemo so it returns a safe no-op storage when window/localStorage is
unavailable or defines length as a getter to avoid SSR crashes and stale
snapshot values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 21fc13fd-6eab-4768-bad9-45694bf296fc

📥 Commits

Reviewing files that changed from the base of the PR and between c186ad8 and 24f323f.

📒 Files selected for processing (2)
  • .changeset/large-fans-stop.md
  • apps/ensadmin/src/components/graphiql-editor/components.tsx

"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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Nit: missing terminal period.

The release-note paragraph ends mid-sentence without punctuation.

-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.

(MD041 hint ignored as per coding guidelines: "Ignore markdownlint's MD041 flags for changeset files.")

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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.
🧰 Tools
🪛 LanguageTool

[grammar] ~5-~5: Ensure spelling is correct
Context: ...es its fetcher, storage, and plugins so 1Hz parent re-renders (driven by the realti...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)


[grammar] ~5-~5: Please add a punctuation mark at the end of paragraph.
Context: ...us projection) no longer trigger schema re-introspection

(PUNCTUATION_PARAGRAPH_END)

🪛 markdownlint-cli2 (0.22.1)

[warning] 5-5: First line in a file should be a top-level heading

(MD041, first-line-heading, first-line-h1)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.changeset/large-fans-stop.md at line 5, The changeset's release-note
paragraph is missing a terminal period; open the changeset's release-note
paragraph (the sentence beginning "Fix ENSAdmin GraphiQL docs sidebar...") and
add a period at the end so the paragraph ends with proper punctuation.

Comment on lines +43 to +64
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;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm whether SSR is actually exercised for the omnigraph/subgraph pages
# (i.e. they aren't dynamically imported with { ssr: false }).
fd -t f 'page.tsx' apps/ensadmin/src/app/api | xargs -I{} sh -c 'echo "=== {} ==="; cat "{}"'
rg -nP -C2 "dynamic\s*\(.*GraphiQLEditor|ssr:\s*false" apps/ensadmin

Repository: namehash/ensnode

Length of output: 3159


🏁 Script executed:

cat -n apps/ensadmin/src/components/graphiql-editor/components.tsx | head -80

Repository: namehash/ensnode

Length of output: 2951


Critical: SSR will crash — localStorage.length is read before the typeof window guard.

useMemo runs synchronously during render, so the factory at lines 43–55 executes before the early return at line 62–64. The property length: localStorage.length is eagerly evaluated during object construction (line 53), not lazily. During SSR (these pages have SSR enabled by default; the typeof window guard confirms this is expected), localStorage is undefined and will throw ReferenceError: localStorage is not defined before the guard is reached.

Move the window check inside the memo factory, or use a lazy getter for length:

Suggested fix
  const storage = useMemo(() => {
+   if (typeof window === "undefined") return undefined;
    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,
+     get length() {
+       return localStorage.length;
+     },
    };
  }, [url]);

The lazy getter for length also fixes a secondary issue: the current form snapshots the value at memo time, making storage.length stale across writes.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
const storage = useMemo(() => {
if (typeof window === "undefined") return undefined;
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();
},
get length() {
return localStorage.length;
},
};
}, [url]);
const mergedPlugins = useMemo(
() => [HISTORY_PLUGIN, explorerPlugin(), ...plugins],
[plugins],
);
if (!url || typeof window === "undefined") {
return null;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ensadmin/src/components/graphiql-editor/components.tsx` around lines 43
- 64, The storage useMemo factory currently accesses localStorage.length eagerly
(in storage = useMemo(...)) which runs during render and will throw during SSR
before the later typeof window guard; fix by moving the typeof window check into
the useMemo factory (or return a safe fallback) and make length a lazy getter
that reads localStorage.length at access time (and ensure
getItem/setItem/removeItem/clear all reference window.localStorage inside their
functions rather than capturing it at memo time). Update the storage useMemo so
it returns a safe no-op storage when window/localStorage is unavailable or
defines length as a getter to avoid SSR crashes and stale snapshot values.

Comment on lines +50 to +52
clear: () => {
localStorage.clear();
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

clear() wipes the entire localStorage, defeating the per-URL namespacing.

The whole point of the ensnode:graphiql:${url} prefix is isolation, but clear() calls localStorage.clear() which removes every key in the origin's storage — including keys belonging to other ENSAdmin features and other GraphiQL endpoints. If GraphiQL ever invokes storage.clear() (e.g. via a "clear history" action), users lose unrelated state.

♻️ Restrict `clear()` to keys in this namespace
-      clear: () => {
-        localStorage.clear();
-      },
+      clear: () => {
+        const prefix = `${storageNamespace}:`;
+        for (let i = localStorage.length - 1; i >= 0; i--) {
+          const key = localStorage.key(i);
+          if (key && key.startsWith(prefix)) localStorage.removeItem(key);
+        }
+      },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
clear: () => {
localStorage.clear();
},
clear: () => {
const prefix = `${storageNamespace}:`;
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (key && key.startsWith(prefix)) localStorage.removeItem(key);
}
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ensadmin/src/components/graphiql-editor/components.tsx` around lines 50
- 52, The current clear() implementation calls localStorage.clear() which wipes
all origin storage; change clear() in components.tsx to only remove keys that
start with the GraphiQL namespace (the `ensnode:graphiql:${url}` prefix) by
iterating localStorage keys and calling localStorage.removeItem(key) for matches
so only per-URL namespaced entries are deleted; ensure you reference the same
`url`/prefix construction used elsewhere to compute the namespace.

clear: () => {
localStorage.clear();
},
length: localStorage.length,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
length: localStorage.length,
get length() {
return localStorage.length;
},

Storage memoization captures static snapshot of localStorage.length instead of dynamic property, causing stale values when storage items are added/removed

Fix on Vercel

Comment on lines +57 to +59
const mergedPlugins = useMemo(
() => [HISTORY_PLUGIN, explorerPlugin(), ...plugins],
[plugins],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const mergedPlugins = useMemo(
() => [HISTORY_PLUGIN, explorerPlugin(), ...plugins],
[plugins],
// Memoize the explorer plugin so its reference is stable across re-renders. This prevents
// GraphiQL from re-running schema introspection when the plugins array prop changes.
const explorer = useMemo(() => explorerPlugin(), []);
const mergedPlugins = useMemo(
() => [HISTORY_PLUGIN, explorer, ...plugins],
[plugins, explorer],

The explorerPlugin() function creates a new plugin instance each time the mergedPlugins useMemo re-executes due to plugins prop changes, despite the explorer configuration not changing

Fix on Vercel

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants