Skip to content

Conversation

@phryneas
Copy link
Member

@phryneas phryneas commented Jan 29, 2026

This fixes two things:

  • Previously, executeQuery would call client.query( without specifying an errorPolicy, which could cause the promise to reject - our logic expected it to resolve with the error, though. This did lead to a number of uncaught promise rejections.
  • Previously, the introspection query was fired off immediately when the extension started, not when the user moved into the explorer tab. As a result, users could have errors ending up in their link chain the moment you started the devtools if the server had introspection disabled. We can't avoid that error in the linkchain fully, but we can at least defer when the introspection query runs. Since it's not needed before opening the Explorer window, it's now deferred until the explorer is visible.

Also, the whole Explorer iframe logic was very complicated and added a lot of code to deal with the nullishness of the iframe - which only played a role in the initial render and would become irrelevant already on the second render. I moved this to an useImperativeHandle-managed ref instead.

Some of these changes also required a TS version bump, so I did that too.

I also bumped the ESLint rules for react-hooks by 3 major versions (the current ones didn't know useEffectEvent) and also removed that other non-official eslint-plugin-react since it seems seriously outdated.

@phryneas phryneas requested a review from a team as a code owner January 29, 2026 11:15
@relativeci
Copy link

relativeci bot commented Jan 29, 2026

#1768 Bundle Size — 2.01MiB (+0.07%).

3988b89(current) vs 8961f38 main#1758(baseline)

Warning

Bundle contains 14 duplicate packages – View duplicate packages

Bundle metrics  Change 3 changes Improvement 1 improvement
                 Current
#1768
     Baseline
#1758
Improvement  Initial JS 1.73MiB(~-0.01%) 1.73MiB
No change  Initial CSS 0B 0B
Change  Cache Invalidation 82.58% 0%
No change  Chunks 5 5
Change  Assets 237(+0.85%) 235
No change  Modules 1527 1527
No change  Duplicate Modules 134 134
No change  Duplicate Code 5.71% 5.71%
No change  Packages 179 179
No change  Duplicate Packages 11 11
Bundle size by type  Change 2 changes Regression 1 regression Improvement 1 improvement
                 Current
#1768
     Baseline
#1758
Improvement  JS 1.73MiB (~-0.01%) 1.73MiB
Regression  Other 252.71KiB (+0.59%) 251.23KiB
No change  IMG 35.85KiB 35.85KiB
No change  HTML 857B 857B

Bundle analysis reportBranch pr/lazy-introspectionProject dashboard


Generated by RelativeCIDocumentationReport issue

@pkg-pr-new
Copy link

pkg-pr-new bot commented Jan 29, 2026

npm i https://pkg.pr.new/apollographql/apollo-client-devtools@1800
npm i https://pkg.pr.new/apollographql/apollo-client-devtools/@apollo/client-devtools-vscode@1800

commit: 3988b89

@phryneas
Copy link
Member Author

phryneas commented Jan 29, 2026

Open problem: clicking the "run in explorer" button will open the explorer and fill in the query, but if navigating to the explorer this way, the "We could not introspect your schema." screen will not open.

I suspect that it opens and immediately closes because another command comes in to overwrite it.

Okay, this is addressed

Copy link

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 pull request fixes error handling in introspection queries and defers their execution until the Explorer tab is visible. The main changes include:

Purpose: Fix uncaught promise rejections in introspection queries and defer their execution until needed.

Changes:

  • Added errorPolicy: "all" to query execution in v3 and v4 handlers to prevent promise rejections
  • Deferred introspection query execution until Explorer tab is visible (via isVisible check)
  • Refactored Explorer iframe management from state-based to ref-based using useImperativeHandle
  • Updated TypeScript to 5.9.3 and added ES2024.Promise lib for Promise.withResolvers support

Reviewed changes

Copilot reviewed 18 out of 22 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tsconfig.json Added ES2024.Promise lib to support Promise.withResolvers
src/extension/tab/v4/handler.ts Added errorPolicy: "all" to executeQuery to prevent promise rejections
src/extension/tab/v3/handler.ts Added errorPolicy: "all" to executeQuery to prevent promise rejections
src/application/components/Explorer/Explorer.tsx Refactored to use ref-based iframe management and deferred introspection query
src/application/components/Queries/RunInExplorerButton.tsx Updated to use explorerRef instead of embeddedExplorerIFrame
src/application/components/Queries/Queries.tsx Updated prop from explorerIFrame to explorerRef
src/application/components/Mutations/Mutations.tsx Updated prop from explorerIFrame to explorerRef
src/application/App.tsx Changed from useState to useRef for explorer management
src/application/components/Explorer/GraphRefModal.tsx Changed from receiving iframe to receiving postMessage function
src/testUtils/testMessageAdapter.ts Exported TestAdapter interface for testing
src/application/components/Select/Option.tsx Exported OptionProps interface for testing
package.json Updated TypeScript from 5.5.4 to 5.9.3
Test files Updated to use explorerRef pattern instead of explorerIFrame
Development files Added ErrorLink for debugging purposes
Files not reviewed (2)
  • development/client/package-lock.json: Language not supported
  • development/server/package-lock.json: Language not supported

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

Comment on lines 148 to 155
const postMessage = (message: OutgoingMessageEvent) => {
iframeRef.current.promise.then((embeddedExplorerIFrame) => {
postMessageToEmbed({
embeddedExplorerIFrame,
message,
});
});
};
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

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

The postMessage function should be wrapped in useCallback to ensure a stable reference. While it's defined outside useImperativeHandle, having it recreated on every render could cause issues if consumers of the ref hold onto the function reference. Additionally, it captures iframeRef from closure which is problematic since iframeRef.current is being recreated on every render (see separate comment).

Copilot uses AI. Check for mistakes.
useEffect,
useImperativeHandle,
useRef,
useEffectEvent,
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

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

useEffectEvent is being imported from React but this hook was experimental and under RFC as of React 18. Verify that this hook is available and stable in React 19.2.1 (the version used in this project). If it's not available, this will cause a runtime error. Consider using a standard useCallback with appropriate dependencies or implementing a custom hook pattern to achieve the same effect.

Suggested change
useEffectEvent,

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

This is stable.

Comment on lines 21 to 33
<Button
variant="secondary"
size="sm"
className="ml-auto"
disabled={!embeddedExplorerIFrame}
icon={<IconRun />}
onClick={() => {
if (embeddedExplorerIFrame) {
// send a post message to the embedded explorer to fill the operation
postMessageToEmbed({
message: {
name: SET_OPERATION,
operation,
variables: JSON.stringify(variables),
},
embeddedExplorerIFrame,
});
currentScreen(Screens.Explorer);
}
explorerRef.current?.postMessage({
name: SET_OPERATION,
operation,
variables: JSON.stringify(variables),
});
currentScreen(Screens.Explorer);
}}
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

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

The disabled prop was removed from the Button component. Previously, the button was disabled when embeddedExplorerIFrame was null to prevent users from clicking before the iframe was ready. Now the button can be clicked at any time, and while explorerRef.current?.postMessage uses optional chaining to handle null/undefined, clicking the button when the explorer isn't ready will silently fail without user feedback. Consider adding the disabled prop back with a check like disabled={!explorerRef.current}, or handle this scenario with user feedback (e.g., a loading state or error message).

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

We won't care about that first render that's the for a split second if at all.

});
}
});
},
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

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

The observer.subscribe call only provides a next callback but doesn't handle error or complete callbacks. If the observable errors (e.g., network failure, parsing error), the error will be silently ignored. Consider adding error and complete handlers to properly handle all observable states:

observer.subscribe({
  next: (response) => { /* existing code */ },
  error: (error) => { /* handle error */ },
  complete: () => { /* optional cleanup */ }
});
Suggested change
},
},
error: (error) => {
// Handle observable-level errors (e.g., network failures) similarly to
// GraphQL-level introspection errors.
const graphRefFromLocalStorage = getGraphRefFromLocalStorage();
if (graphRefFromLocalStorage) {
setGraphRef(graphRefFromLocalStorage);
} else {
// Delay opening the modal for a moment. If the "Run in Explorer" button
// navigates here, this might cause the HeadlessUI Dialog to call `onClose`
// immediately because of the tab change
setTimeout(() => {
setShowGraphRefModal("triggeredByIntrospectionFailure");
}, 100);
}
postMessage({
name: SCHEMA_ERROR,
errors: undefined,
error:
error instanceof Error
? error.message
: String(error),
});
},
complete: () => {
// No-op: placeholder for any future cleanup when introspection completes.
},

Copilot uses AI. Check for mistakes.
setPreviousClientId(clientId);
setSchema(null);
}
const iframeRef = useRef(Promise.withResolvers<HTMLIFrameElement>());
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

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

The iframeRef is initialized with useRef(Promise.withResolvers<HTMLIFrameElement>()) on every render, creating a new Promise instance each time the component re-renders. This breaks the ref pattern because:

  1. The postMessage function references iframeRef.current.promise which will be a different promise after each render
  2. Any messages sent via postMessage during one render will be waiting on a promise that gets replaced on the next render
  3. The resolve call in iframeRefFn will resolve the wrong promise instance if it happens after a re-render

The initialization should only happen once. Consider using lazy initialization:

const iframeRef = useRef<{promise: Promise<HTMLIFrameElement>; resolve: (iframe: HTMLIFrameElement) => void; reject: (reason?: any) => void} | null>(null);
if (!iframeRef.current) {
  iframeRef.current = Promise.withResolvers<HTMLIFrameElement>();
}

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

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

What's wrong with Copilot today? Of course that value will only be used the first time.

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