fix(Dialog): capture the opener before descendant autofocus effects by ManoharPaturi · Pull Request #6607 · facebook/astryx · GitHub
Skip to content

fix(Dialog): capture the opener before descendant autofocus effects - #6607

Merged
cixzhang merged 4 commits into
facebook:mainfrom
ManoharPaturi:fix/dialog-trigger-capture-on-mount
Oct 16, 2026
Merged

cixzhang merged 4 commits into
facebook:mainfrom
ManoharPaturi:fix/dialog-trigger-capture-on-mount

Conversation

@ManoharPaturi

Copy link
Copy Markdown
Contributor

Fixes #5637

Summary

A controlled Dialog whose content mounts in the open commit restored focus to <body> on close. Two defects, both fixed:

  1. The opener was captured too late. The Dialog captured document.activeElement in its passive effect, but React runs child passive effects before the parent's — so a freshly mounted DialogHeader focused its own title first, and the Dialog captured its own heading. When closing unmounts that content, focus restoration targeted a detached node and the user's focus stranded on <body>. The capture now runs in a useLayoutEffect on the open edge: all layout effects run before any passive effect, so the capture always sees the element the user was on before the dialog's descendants moved focus.
  2. DialogHeader focused its title while the dialog was closed. Its shouldAutoFocus check only excluded inline mode, and the standard path renders children (hidden) while closed, so a header mounted under isOpen={false} stole focus onto an invisible element. DialogContext now carries isOpen, and the header suppresses autofocus while closed — its effect re-runs when the dialog opens, so the title still becomes the screen reader anchor on open, exactly as before for content that mounts in the open commit.

Tests

  • Regression 1 (the issue's minimal shape): opener button → open with content mounting in the same commit → title focused while open → close → document.activeElement is the opener. Failed on main (focus landed on <body>), passes here.
  • Regression 2: DialogHeader mounted under isOpen={false} leaves focus on <body>. Failed on main (title stolen focus), passes here.
  • Full Dialog directory: 85 passed. Dependent suites (CommandPalette, BottomSheet, Popover, Layer): 490 passed.

Verification

tsc --noEmit clean (core); eslint 0 problems on touched files; prettier clean; check:changesets clean. Changeset included.

Copilot AI lite review requested due to automatic review settings September 24, 2026 19:01
@vercel

vercel Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Sep 24, 2026

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot added community Authored by a community contributor (not on the eng/design team) needs:code-review High-risk change (new package/component/API) — needs human code review before merge labels Sep 24, 2026
github-actions Bot added a commit that referenced this pull request Sep 24, 2026
@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

No new or modified components detected.

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.9KB 1.2KB

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

38 of 478 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 754,077
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,188
Chat Full AI Chat probe light 238,087
Chat Full AI Chat probe dark 237,060
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
Chat Default neutral dark 33,205
Chat Default neutral light 32,876
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232
ProgressBar With Target Mark probe dark 7,895
ProgressBar With Target Mark probe light 7,895
TableRowExpansion Detail Panel neutral dark 5,150
TableRowExpansion Detail Panel neutral light 4,068
SideNav Resizable in AppShell probe light 3,846
TableGroupedRows Default neutral light 2,133

and 18 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@astracat-bot

astracat-bot Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 16, 2026, 6:46 PM UTC

@astracat-bot astracat-bot 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.

Thanks — the focus fix itself looks right and matches the Dialog contract: capturing the opener in a layout effect means a DialogHeader that mounts in the open commit can no longer replace the return target, and suppressing header autofocus while the dialog is closed stops focus landing on an invisible title. The new regressions cover both shapes, and the Dialog and dependent suites pass.

One change is needed before this lands:

  • packages/core/src/Dialog/__tests__/Dialog.a11y.known-failures.ts still records modal-dialog.focus.restored for the conditional-content-focus-restoration state as a current failure ("closing the modal dialog did not return focus to its still-available invoker"). This PR makes that outcome pass, and the Chromium accessibility contract requires every known-failure record to match exactly one executed result — an unmatched record fails Dialog.a11y.chromium.spec.ts when it runs at release-check (it does not run in PR CI, which is why checks are green). Please delete just that record, keeping the enters-after-modal record, whose focus-entry ordering this PR does not change and which remains accurate. Running the Dialog Chromium accessibility spec after the removal, where a browser is available, would confirm it.

No other blocking issues were found; the pre-existing focus-entry ordering issue stays recorded and is out of scope here.

[Automated review]

@astracat-bot astracat-bot 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.

Requesting changes: the layout-effect opener capture fixes #5637 (in Chromium, closing now returns focus to the opener, and the known-failure removal is correct), but the DialogHeader isOpen gate makes the header focus its title when the dialog opens, before Dialog calls showModal(), so with an always-mounted DialogHeader focus now enters the dialog before it is modal. Dialog.a11y.chromium.spec.ts, which runs at release-check rather than in PR CI, now fails modal-dialog.focus.enters-after-modal for labelled-described-default-title and explicit-descendant-focus, and passes again when only the DialogHeader.tsx change is reverted. Please keep the capture and either drop the open-edge refocus (Chromium already gives no focus to a header under a closed dialog, since the closed dialog isn't rendered) or have Dialog focus the title after showModal(), then update the changeset and rerun that spec.

[Automated review]

Comment on lines +169 to +170
const shouldAutoFocus =
dialogContext?.isInline !== true && dialogContext?.isOpen !== false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This makes the focus effect below re-run when the Dialog opens. It runs as a child passive effect, before Dialog's passive effect calls showModal(). So for a header that stays mounted, the title gets focus while the dialog is not yet modal, which breaks component:Dialog FR1. Dialog.a11y.chromium.spec.ts fails modal-dialog.focus.enters-after-modal for labelled-described-default-title and explicit-descendant-focus with this change, and passes without it. A closed dialog is display: none, so in Chromium the old mount-time focus() call already moved nothing.

… refocus

Re-running the header's focus effect when isOpen flips focused the title
before Dialog calls showModal(), so focus entered the dialog before it was
modal (component:Dialog FR1; modal-dialog.focus.enters-after-modal). A
header under a closed dialog gets no focus in a browser anyway, since the
closed dialog is not rendered. Revert the isOpen context gate and its
jsdom-only test; the layout-effect capture (FR4) stays.

@astracat-bot astracat-bot 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.

This looks ready to merge: saving the opener in a layout effect fixes the #5637 focus return for DialogHeader-style content, the Dialog Chromium accessibility contract now passes focus restoration for content that mounts on open, and focus-entry order is unchanged. A few non-blocking follow-ups:

  • Please update the PR description, which still describes the reverted DialogHeader/DialogContext change and its second regression test.
  • Content that takes focus during the opening commit itself, such as a TextInput with hasAutoFocus, still returns focus to the page body on close as before, so consider narrowing the changeset's first sentence and keeping #5637 or a follow-up open for that case.
  • Optionally, saving the opener only once per open (skipping the save while one is held) would also keep it under React StrictMode for a Dialog that mounts already open.

[Automated review]

'@astryxdesign/core': patch
---

[fix] Restore focus to the element that opened a Dialog when its content mounts in the open commit. The opener was captured in the Dialog's passive effect, which runs after a freshly mounted DialogHeader's autofocus effect had already moved focus to the dialog's own title — closing then restored focus to that detached title and the user's focus landed on `<body>`. The capture now runs in a layout effect, before any descendant's passive effect. (#5637)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: content that takes focus during the opening commit itself, such as a TextInput with hasAutoFocus (React applies autoFocus before this layout effect runs), is still saved as the opener, so this first sentence reads broader than the fix. Consider scoping it to focus moved from a passive effect, such as DialogHeader's title.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional hardening: when a Dialog mounts with isOpen already true, React StrictMode re-runs this effect after the first pass's passive effects, so the saved element becomes the dialog's own title (development only, and the same outcome as before this change). Saving once per open keeps the opener, and the close branch already clears the ref:

if (isInline || !isOpen || triggerElementRef.current !== null) {
  return;
}

@cixzhang
cixzhang enabled auto-merge (squash) October 16, 2026 23:04
@github-actions github-actions Bot removed the needs:code-review High-risk change (new package/component/API) — needs human code review before merge label Oct 16, 2026
@cixzhang
cixzhang merged commit d150f78 into facebook:main Oct 16, 2026
55 checks passed

This branch was successfully deployed

1 active deployment
Preview — c6cba76f Deployed Oct 16, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. community Authored by a community contributor (not on the eng/design team)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dialog captures the wrong trigger when focusable content mounts on open

3 participants