Skip to content

fix(overlay): take focus when opening and hand it back when closing - #2423

Open
alexander-akait wants to merge 1 commit into
mainfrom
feat/overlay-merge
Open

alexander-akait wants to merge 1 commit into
mainfrom
feat/overlay-merge

Conversation

@alexander-akait

@alexander-akait alexander-akait commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Summary

Rebased onto main now that #2421 has merged, so this stands on its own — one commit.

Before webpack-dev-server can drop its own overlay and use this one, this one has to do everything that one does. Comparing them turned up two things it did not, both accessibility:

  1. Focus never moved. The overlay opened without touching it, so reaching the overlay meant clicking into the frame first — Escape and the arrow keys did nothing until you did, and a screen reader stayed wherever the page already was. webpack-dev-server's focuses its close button on open, saves what had focus, and gives it back on close. This one now does the same, so closing no longer leaves focus on a removed element with the next Tab starting from the top of the document.

  2. The frame had no accessible name. webpack-dev-server sets title on its iframe; this one did not, leaving it announced by its about:blank url.

One subtlety worth pointing out, because it is where the two overlays genuinely differ: this one paginates, and paginating re-renders the card. Focusing on every render would take focus off the ‹ › button being clicked, so only the render that opens the overlay takes focus.

On the wider "merge the overlays" step

I had said this would be a dedupe, with this package's overlay being the superset that wins. Having actually compared them, that was wrong, and I would rather correct it than let it shape the next PR:

webpack-dev-server webpack-dev-middleware
lines 738 873
architecture state machine module-level state, no machine
extra options — styles, ansiColors, openEditorEndpoint, paginate
shared trustedTypesPolicyName, catchRuntimeError, errors/warnings/runtimeErrors filters same

They are two implementations with overlapping, not nested, feature sets. This PR closes the behavioral gaps so nothing is lost by switching; it deliberately does not attempt to reconcile the architectures.

formatProblem, which webpack-dev-server exports from its overlay, is not a gap: this package formats build problems server-side in src/hot.js and runtime errors inside the overlay, so the same work happens in a different place.

What kind of change does this PR introduce?

fix

Did you add tests for your changes?

Yes — one end-to-end case that focuses an input, triggers a build error, and checks that focus lands on the close button inside the frame and returns to the input after Escape, plus that the frame has a title.

Teeth-checked in both halves: removing the closeButton.focus() fails it, and separately removing the focus restoration fails it on the focus-me assertion. All 36 overlay cases and all 82 end-to-end cases pass, re-run after the rebase.

A correction to what I claimed on #2420 and #2421: I said the browser end-to-end suites could not run in my sandbox, and that this was environmental because they failed the same way on a clean main. That was wrong. They fail when jest is invoked without --experimental-vm-modules — which npm run test:e2e passes and I was not — and puppeteer then needs pointing at a browser. With both fixed they run fine, and I have now run the whole suite locally, including the two client.test.js snapshots that CI caught on #2421 and that I had only reasoned about rather than executed.

test/logging.test.js still fails 74/74 on a clean main, unrelated to any of this.

Does this PR introduce a breaking change?

No. The overlay gains focus behavior it did not have; nothing about its appearance, options or dismissal changes.

If relevant, what needs to be documented once your changes are merged or what have you already documented?

Nothing — this restores expected behavior rather than adding a knob.

Use of AI

AI-assisted (Claude Code). It was used to compare the two overlays, write the fix and the test, and verify it: the test was run against both halves of the change reverted in turn to confirm it fails, and the full end-to-end suite was run locally.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA

Summary by CodeRabbit

  • New Features
    • The error overlay now moves keyboard focus inside it when it opens and returns focus to the previously active element when it closes, if that element is still available.
    • The overlay frame now has an accessible name, making it easier for assistive technologies to identify.

@changeset-bot

changeset-bot Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: df7df50

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

This PR includes changesets to release 1 package
Name Type
webpack-dev-middleware 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

Base automatically changed from feat/client-transports to main September 24, 2026 18:57
The overlay opened without touching focus, so reaching it meant clicking
into the frame first: Escape and the arrow keys did nothing until then, and
a screen reader stayed wherever the page was. Its frame had no accessible
name either, leaving it announced by its `about:blank` url.

Only the render that opens it takes focus — paginating re-renders the card,
and moving focus then would take it off the button being clicked.

These are the parts webpack-dev-server's own overlay has and this one did
not, which it would otherwise lose by switching to this one.
@coderabbitai

coderabbitai Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Walkthrough

The error overlay now saves the host page’s active element when it opens and focuses its close button after the opening render. When cleared, it restores focus if the saved element remains connected and supports focus. The iframe receives an accessible title. An end-to-end test covers focus capture and restoration, and a changeset declares a patch release.

Merge Risk: 🟡 Moderate · up to df7df

Keyboard focus can be lost while navigating build problems, and focus may not return to a control inside a web component when the overlay closes. Fix these focus paths before merging.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: transferring focus to the overlay when it opens and restoring it when it closes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 2 files. (1 skipped: 1 …
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.
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 60de74d1-aae2-4bf1-81b1-f716bc931dc2

📥 Commits

Reviewing files that changed from the base of the PR and between eed5f19 and df7df50.

📒 Files selected for processing (3)
  • .changeset/overlay-focus.md
  • client-src/overlay.js
  • test/e2e/overlay.test.js

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.

Comment thread client-src/overlay.js

// Whatever the page had focused, so it can be given back — the overlay
// takes focus to be reachable by keyboard, and is rude if it keeps it.
state.previousActiveElement = document.activeElement;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '435,520p' client-src/overlay.js
sed -n '175,235p' test/e2e/overlay.test.js

Repository: webpack/webpack-dev-middleware

Length of output: 5181


🏁 Script executed:

set -eu
printf '%s\n' '--- overlay focus-related definitions and callers ---'
rg -n -C 8 'previousActiveElement|activeElement|focusOnRender|function clear|function ensureOverlay|delegatesFocus|shadowRoot' client-src/overlay.js test
printf '%s\n' '--- overlay file outline ---'
ast-grep outline client-src/overlay.js
printf '%s\n' '--- focused test file outline ---'
ast-grep outline test/e2e/overlay.test.js

Repository: webpack/webpack-dev-middleware

Length of output: 14363


Preserve the focused control inside an open shadow root.

When an input inside an open shadow root has focus, document.activeElement returns the shadow host. clear() later calls focus() on that host. Without delegatesFocus, this does not restore focus to the input. Traverse shadowRoot.activeElement before saving the element, including nested open shadow roots.

Suggested fix
-  state.previousActiveElement = document.activeElement;
+  let previousActiveElement = document.activeElement;
+  while (previousActiveElement?.shadowRoot?.activeElement) {
+    previousActiveElement = previousActiveElement.shadowRoot.activeElement;
+  }
+  state.previousActiveElement = previousActiveElement;
📝 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
state.previousActiveElement = document.activeElement;
let previousActiveElement = document.activeElement;
while (previousActiveElement?.shadowRoot?.activeElement) {
previousActiveElement = previousActiveElement.shadowRoot.activeElement;
}
state.previousActiveElement = previousActiveElement;

Comment thread client-src/overlay.js
Comment on lines +750 to +752
if (state.focusOnRender) {
state.focusOnRender = false;
closeButton.focus();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

printf '%s\n' '--- overlay focus/render paths ---'
sed -n '360,425p;480,525p;590,715p;735,805p' client-src/overlay.js
printf '%s\n' '--- relevant tests ---'
sed -n '180,240p;590,675p;1065,1130p' test/e2e/overlay.test.js

Repository: webpack/webpack-dev-middleware

Length of output: 16961


Restore the focused control from the iframe document after rebuilding the card.

renderProblems() removes the focused control from frameDocument. Restore the matching pagination button after goToPage(). If a changed problem set removes the focused Close button, focus the new Close button. If pagination disappears, use Close as the in-card fallback. Do not restore focus when frameDocument.activeElement is not inside card, because the host page or another iframe owns focus.

Suggested fix
   const { type, lines } = state.currentProblems;
   const paginated = paginate && lines.length > 1;
+  const previousFocusedElement = frameDocument.activeElement;
+  const previousFocusWasInCard =
+    previousFocusedElement !== null &&
+    card.contains(previousFocusedElement);
+  const previousFocusLabel = previousFocusWasInCard
+    ? previousFocusedElement.getAttribute("aria-label")
+    : null;

   // Accent the top bar with the problem color (red for errors, yellow for warnings).
   card.style.borderTopColor = `#${problemColor(type)}`;
   setHTML(card, "");
   if (state.focusOnRender) {
     state.focusOnRender = false;
     closeButton.focus();
+  } else if (previousFocusWasInCard) {
+    const replacement =
+      previousFocusLabel === "Close"
+        ? closeButton
+        : Array.from(card.querySelectorAll("button")).find(
+            (button) =>
+              button.getAttribute("aria-label") === previousFocusLabel,
+          ) || closeButton;
+
+    replacement.focus();
   }

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.

1 participant