From 987141c3125e181b1ccc287ad946037ade050635 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 15 Apr 2026 12:03:10 +0000
Subject: [PATCH 1/2] Initial plan
From 37d8c4de0e5f40ffaa0ccd0d4813de535f2189d4 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 15 Apr 2026 13:03:37 +0000
Subject: [PATCH 2/2] fix(react-dialog): remove stale aria-hidden from
ancestors when stacked dialogs close (#35985)
Agent-Logs-Url: https://github.com/microsoft/fluentui/sessions/fc8d830a-fba4-4001-91af-89f9e553572e
Co-authored-by: Hotell <1223799+Hotell@users.noreply.github.com>
---
...-dialog-2026-04-15-stacked-dialog-fix.json | 7 +
.../src/components/Dialog/Dialog.cy.tsx | 194 ++++++++++++++++++
.../library/src/utils/useFocusFirstElement.ts | 48 ++++-
3 files changed, 246 insertions(+), 3 deletions(-)
create mode 100644 change/@fluentui-react-dialog-2026-04-15-stacked-dialog-fix.json
diff --git a/change/@fluentui-react-dialog-2026-04-15-stacked-dialog-fix.json b/change/@fluentui-react-dialog-2026-04-15-stacked-dialog-fix.json
new file mode 100644
index 00000000000000..5bd6a40a10d9ac
--- /dev/null
+++ b/change/@fluentui-react-dialog-2026-04-15-stacked-dialog-fix.json
@@ -0,0 +1,7 @@
+{
+ "type": "patch",
+ "comment": "fix: remove stale aria-hidden from dialog portal ancestors when focusing on open, addressing focus trap breakage when closing stacked non-nested sibling dialogs (https://github.com/microsoft/fluentui/issues/35985)",
+ "packageName": "@fluentui/react-dialog",
+ "email": "Hotell@users.noreply.github.com",
+ "dependentChangeType": "patch"
+}
diff --git a/packages/react-components/react-dialog/library/src/components/Dialog/Dialog.cy.tsx b/packages/react-components/react-dialog/library/src/components/Dialog/Dialog.cy.tsx
index 90e841625d9e58..8d65ac4fd9f2f1 100644
--- a/packages/react-components/react-dialog/library/src/components/Dialog/Dialog.cy.tsx
+++ b/packages/react-components/react-dialog/library/src/components/Dialog/Dialog.cy.tsx
@@ -700,6 +700,200 @@ describe('Dialog', () => {
cy.get('#second-dialog').should('not.exist');
cy.get('#first-dialog').should('not.exist');
});
+
+ describe('stacked non-nested dialogs (sibling)', () => {
+ /**
+ * Regression test for https://github.com/microsoft/fluentui/issues/35985
+ *
+ * Two sibling Dialogs (NOT nested inside each other's JSX tree).
+ * Dialog 1 is opened via a page-level trigger.
+ * Dialog 2 is opened via a button inside Dialog 1, but is a sibling in the React tree.
+ * When Dialog 2 closes, focus must return to the button inside Dialog 1 — NOT to the page trigger.
+ *
+ * Bug: Dialog 2's Modalizer leaves stale aria-hidden="true" on Dialog 1's portal mount node,
+ * blocking browser focus restoration back into Dialog 1.
+ */
+ it('should restore focus to underlying dialog when top stacked dialog closes', () => {
+ const StackedDialogsTest = () => {
+ const [dialog1Open, setDialog1Open] = React.useState(false);
+ const [dialog2Open, setDialog2Open] = React.useState(false);
+
+ return (
+ <>
+
+
+ {/* Dialog 1 — opened from the page-level trigger */}
+
+
+ {/* Dialog 2 — sibling in the React tree, NOT nested inside Dialog 1 */}
+
+ >
+ );
+ };
+
+ mount();
+
+ // Open Dialog 1
+ cy.get(dialogTriggerOpenSelector).realClick();
+ cy.get('#dialog-1-surface').should('exist');
+
+ // Open Dialog 2 from inside Dialog 1
+ cy.get('#open-dialog-2-btn').realClick();
+ cy.get('#dialog-2-surface').should('exist');
+
+ // Close Dialog 2
+ cy.get('#close-dialog-2-btn').realClick();
+ cy.get('#dialog-2-surface').should('not.exist');
+
+ // Dialog 1 should still be open and the trigger button for Dialog 2 should have focus
+ cy.get('#dialog-1-surface').should('exist');
+ cy.get('#open-dialog-2-btn').should('be.focused');
+ });
+
+ it('should not have stale aria-hidden on dialog 1 portal ancestors after dialog 2 closes', () => {
+ const StackedDialogsTest = () => {
+ const [dialog1Open, setDialog1Open] = React.useState(false);
+ const [dialog2Open, setDialog2Open] = React.useState(false);
+
+ return (
+ <>
+
+
+
+ >
+ );
+ };
+
+ mount();
+
+ cy.get(dialogTriggerOpenSelector).realClick();
+ cy.get('#open-dialog-2-btn').realClick();
+ cy.get('#close-dialog-2-btn').realClick();
+
+ // After Dialog 2 closes, no ancestor of Dialog 1's surface (up to body)
+ // should carry a stale aria-hidden="true" (the backdrop div is intentionally aria-hidden but is not an ancestor)
+ cy.get('#dialog-1-surface').then($el => {
+ let el = $el[0].parentElement;
+ while (el && el !== document.body) {
+ expect(el.getAttribute('aria-hidden'), `ancestor <${el.tagName}> should not be aria-hidden`).to.not.equal(
+ 'true',
+ );
+ el = el.parentElement;
+ }
+ });
+ });
+
+ it('should maintain focus trap in dialog 1 after stacked dialog 2 is dismissed', () => {
+ const StackedDialogsTest = () => {
+ const [dialog1Open, setDialog1Open] = React.useState(false);
+ const [dialog2Open, setDialog2Open] = React.useState(false);
+
+ return (
+ <>
+
+
+
+ >
+ );
+ };
+
+ mount();
+
+ cy.get(dialogTriggerOpenSelector).realClick();
+ cy.get('#open-dialog-2-btn').realClick();
+ cy.get('#dialog-2-surface').should('exist');
+
+ // Close Dialog 2 via its close button
+ cy.get('#close-dialog-2-btn').realClick();
+ cy.get('#dialog-2-surface').should('not.exist');
+ cy.get('#dialog-1-surface').should('exist');
+
+ // Tab should cycle inside Dialog 1 (focus trap re-engaged)
+ cy.get('#open-dialog-2-btn').should('be.focused').realPress('Tab');
+ cy.get(dialogTriggerCloseSelector).should('be.focused').realPress('Tab');
+ cy.get('#open-dialog-2-btn').should('be.focused');
+ });
+ });
});
const lorem = (
diff --git a/packages/react-components/react-dialog/library/src/utils/useFocusFirstElement.ts b/packages/react-components/react-dialog/library/src/utils/useFocusFirstElement.ts
index ff1a4c7837f30e..dd3b3074048a22 100644
--- a/packages/react-components/react-dialog/library/src/utils/useFocusFirstElement.ts
+++ b/packages/react-components/react-dialog/library/src/utils/useFocusFirstElement.ts
@@ -7,7 +7,39 @@ import type { DialogSurfaceElement } from '../DialogSurface';
import type { DialogModalType } from '../Dialog';
/**
- * Focus first element on content when dialog is opened,
+ * Removes stale `aria-hidden="true"` from ancestor nodes of `element` up to (but not including)
+ * `document.body`.
+ *
+ * This is a temporary mitigation for a Tabster Modalizer limitation where closing a stacked sibling
+ * dialog can leave a stale `aria-hidden` on the underlying dialog's portal mount node, blocking
+ * browser focus from entering the subtree.
+ *
+ * The fix is safe because:
+ * - It only runs when `open === true` (the dialog's own Modalizer is active).
+ * - It only touches direct ancestors of the dialog surface — not unrelated siblings.
+ * - Tabster's active Modalizer will immediately re-apply correct `aria-hidden` state on the next
+ * mutation cycle if the attribute was legitimately supposed to be there.
+ *
+ * TODO: Remove once Tabster Modalizer supports a proper activation stack.
+ * @see https://github.com/microsoft/fluentui/issues/35985
+ *
+ * @internal
+ */
+function removeStaleAriaHiddenFromAncestors(element: HTMLElement): void {
+ let current = element.parentElement;
+ while (current && current.ownerDocument && current !== current.ownerDocument.body) {
+ if (current.getAttribute('aria-hidden') === 'true') {
+ current.removeAttribute('aria-hidden');
+ }
+ current = current.parentElement;
+ }
+}
+
+/**
+ * Focus first element on content when dialog is opened.
+ * Also clears stale `aria-hidden` from portal ancestor nodes before focusing,
+ * guarding against the stacked-sibling-dialog focus trap breakage described in
+ * https://github.com/microsoft/fluentui/issues/35985.
*/
export function useFocusFirstElement(
open: boolean,
@@ -21,11 +53,21 @@ export function useFocusFirstElement(
if (!open) {
return;
}
- const element = dialogRef.current && findFirstFocusable(dialogRef.current);
+
+ const dialogEl = dialogRef.current;
+ if (!dialogEl) {
+ return;
+ }
+
+ // Workaround for https://github.com/microsoft/fluentui/issues/35985:
+ // Strip any stale aria-hidden="true" from ancestors of the dialog surface before focusing.
+ removeStaleAriaHiddenFromAncestors(dialogEl);
+
+ const element = findFirstFocusable(dialogEl);
if (element) {
element.focus();
} else {
- dialogRef.current?.focus(); // https://github.com/microsoft/fluentui/issues/25150
+ dialogEl.focus(); // https://github.com/microsoft/fluentui/issues/25150
if (process.env.NODE_ENV === 'development') {
// eslint-disable-next-line no-console
console.warn(/** #__DE-INDENT__ */ `