From 2ff2783e7815ff3ab88a62a533da766184b98960 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Tue, 22 Sep 2026 10:46:02 -0700 Subject: [PATCH 1/2] Fix FocusZone backward traversal from first-child containers Prevent Windows Fabric Shift+Tab from searching ancestor descendants. Add shared native regression coverage and document platform behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/quiet-zones-traverse.md | 5 + .../focus-zone/focus-zone.stories.tsx | 92 +++++++++++++++++++ packages/components/FocusZone/SPEC.md | 11 ++- .../FRNFocusZone/FocusZoneComponentView.cpp | 6 +- 4 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 .changeset/quiet-zones-traverse.md diff --git a/.changeset/quiet-zones-traverse.md b/.changeset/quiet-zones-traverse.md new file mode 100644 index 0000000000..3ff2072f21 --- /dev/null +++ b/.changeset/quiet-zones-traverse.md @@ -0,0 +1,5 @@ +--- +'@fluentui-react-native/focus-zone': patch +--- + +Fix Windows Fabric FocusZone Shift+Tab traversal when the zone is a first child so backward navigation cannot re-enter the zone or jump to a later sibling. diff --git a/packages/agentic/components/src/primitives/focus-zone/focus-zone.stories.tsx b/packages/agentic/components/src/primitives/focus-zone/focus-zone.stories.tsx index 8bf9505cbb..bb86787ff3 100644 --- a/packages/agentic/components/src/primitives/focus-zone/focus-zone.stories.tsx +++ b/packages/agentic/components/src/primitives/focus-zone/focus-zone.stories.tsx @@ -160,6 +160,40 @@ const FocusZoneExample = ({ columns = 3, defaultToCenter = false, itemCount = 9, ); }; +const FirstChildTabExitExample = (props: FocusZoneProps) => { + const [focusedItem, setFocusedItem] = React.useState(''); + const renderButton = (id: string, content: string) => ( + setFocusedItem(id)} + style={styles.outsideButton} + testID={id} + /> + ); + + return ( + + + {renderButton('focus-zone-earlier', 'Earlier outside')} + {renderButton('focus-zone-previous', 'Previous outside')} + + + + + + {renderButton('focus-zone-first-child-first', 'First in zone')} + {renderButton('focus-zone-first-child-last', 'Last in zone')} + + + {renderButton('focus-zone-later-sibling', 'Later sibling')} + + {renderButton('focus-zone-later-ancestor-sibling', 'Later ancestor sibling')} + + + ); +}; + const meta: Meta = { title: 'Native/FocusZone', component: FocusZone, @@ -210,6 +244,19 @@ export const Default: Story = { await browser.keys('\uE004'); await expectNativeState(browser, 'focus-zone-after', 'focused', true); }, + 'Shift+Tab exits to a preceding sibling': async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser } = context; + await (await browser.$('~focus-zone-item-2')).click(); + await expectNativeState(browser, 'focus-zone-item-2', 'focused', true); + try { + await browser.keys(['\uE008', '\uE004']); + } finally { + await browser.releaseActions(); + } + await expectNativeState(browser, 'focus-zone-before', 'focused', true); + }, }, parameters: { docs: { @@ -291,6 +338,51 @@ export const TabNavigation: Story = { }, }; +export const FirstChildTabExit: Story = { + tags: ['desktop-e2e'], + args: { + tabKeyNavigation: 'None', + }, + render: (args) => , + wdio: { + 'Shift+Tab skips the first-child zone and later siblings while climbing ancestors': async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser } = context; + for (const item of ['focus-zone-first-child-first', 'focus-zone-first-child-last']) { + await (await browser.$(`~${item}`)).click(); + await expectNativeState(browser, item, 'focused', true); + try { + await browser.keys(['\uE008', '\uE004']); + } finally { + await browser.releaseActions(); + } + await expectNativeState(browser, 'focus-zone-previous', 'focused', true); + await expectNativeState(browser, item, 'focused', false); + await expectNativeState(browser, 'focus-zone-later-sibling', 'focused', false); + await expectNativeState(browser, 'focus-zone-later-ancestor-sibling', 'focused', false); + } + }, + 'Tab exits a first-child zone to its next sibling': async (context) => { + const { requireDesktopFocus, expectNativeState } = await import('../../common/desktopFocus.wdio.ts'); + if (!requireDesktopFocus(context)) return; + const { browser } = context; + await (await browser.$('~focus-zone-first-child-first')).click(); + await expectNativeState(browser, 'focus-zone-first-child-first', 'focused', true); + await browser.keys('\uE004'); + await expectNativeState(browser, 'focus-zone-later-sibling', 'focused', true); + }, + }, + parameters: { + docs: { + description: { + story: + 'The zone is the first native child of nested non-focusable containers with later siblings. Shift+Tab must climb past the containers to the last focusable element in the preceding subtree, never re-enter the zone or move forward. This shared desktop contract guards a Windows Fabric traversal regression.', + }, + }, + }, +}; + export const Disabled: Story = { args: { disabled: true, diff --git a/packages/components/FocusZone/SPEC.md b/packages/components/FocusZone/SPEC.md index e6be5902b6..54ce2640a9 100644 --- a/packages/components/FocusZone/SPEC.md +++ b/packages/components/FocusZone/SPEC.md @@ -17,6 +17,10 @@ because it owns native macOS code and CocoaPods integration. forwarded without theme tokens or appearance defaults. - `isCircularNavigation` maps to the native `NavigateWrap` end behavior; otherwise navigation stops at the ends. +- With `tabKeyNavigation="None"`, Tab and Shift+Tab leave the zone. Backward + traversal considers preceding elements and focusable ancestors, not the zone's + descendants or later siblings, including when the zone is its parent's first + child. ## Platform behavior @@ -27,7 +31,8 @@ because it owns native macOS code and CocoaPods integration. moves to item 5. - Windows includes a package-owned Fabric component view that coordinates directional, Home/End, Tab, and focus-restoration behavior through RNW - `ComponentView` focus APIs. + `ComponentView` focus APIs. Backward zone exit walks reverse preorder and tests + each visited node itself rather than searching an ancestor's entire subtree. - Win32 continues to use its platform-provided native FocusZone implementation. Windows/Win32 use linear movement by default and opt into geometric movement with `use2DNavigation`. @@ -45,3 +50,7 @@ types. Interactive directional, circular, Tab, disabled, and default-focus scenarios live with the agentic primitive stories in `packages/agentic/components/src/primitives/focus-zone/focus-zone.stories.tsx`. +The `FirstChildTabExit` native tests cover nested first-child containers with +later siblings and a preceding focusable subtree on all desktop endpoints. +They guard a Windows Fabric traversal defect; macOS uses its independent +AppKit key-view loop and shares the same outside-zone navigation requirement. diff --git a/packages/components/FocusZone/windows/FRNFocusZone/FocusZoneComponentView.cpp b/packages/components/FocusZone/windows/FRNFocusZone/FocusZoneComponentView.cpp index 91f6f83c82..f75854d11e 100644 --- a/packages/components/FocusZone/windows/FRNFocusZone/FocusZoneComponentView.cpp +++ b/packages/components/FocusZone/windows/FRNFocusZone/FocusZoneComponentView.cpp @@ -452,8 +452,10 @@ struct FocusZoneComponentView } } else { for (auto node = PreOrderPrevious(container); node; node = PreOrderPrevious(node)) { - auto candidate = winrtComp::FocusManager::FindLastFocusableElement(node); - if (candidate && candidate.TryFocus(winrtRN::FocusState::Keyboard)) { + // Reverse preorder already visits descendants. Only focus the node itself, + // since searching an ancestor's subtree can re-enter the zone or move forward. + auto candidate = winrtComp::FocusManager::FindFirstFocusableElement(node); + if (candidate && candidate.Tag() == node.Tag() && candidate.TryFocus(winrtRN::FocusState::Keyboard)) { return true; } } From 279722f8fbf368b0040b240e0586eafc02ad2c7b Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Tue, 22 Sep 2026 11:13:33 -0700 Subject: [PATCH 2/2] Use Yarn-managed Appium drivers for Windows E2E Keep Windows and Win32 setup on the lockfile-managed driver instead of installing an unversioned extension. Validate driver discovery and dynamic import before launching E2E tests, and scope Appium loading to the Windows driver. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/E2E/README.md | 18 ++++++++++++++++++ apps/E2E/appium.test.cjs | 36 +++++++++++++++++++++++++++++++++++ apps/E2E/package.json | 11 ++++++----- apps/E2E/wdio.conf.win32.js | 1 + apps/E2E/wdio.conf.windows.js | 1 + 5 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 apps/E2E/appium.test.cjs diff --git a/apps/E2E/README.md b/apps/E2E/README.md index 36a10c20fc..7302135c77 100644 --- a/apps/E2E/README.md +++ b/apps/E2E/README.md @@ -32,6 +32,24 @@ and drag & drop the **XCode Helper** app to **Security & Privacy -> Privacy -> A # Running E2E Tests +## Windows and Win32 Appium drivers + +After the repository's `yarn` install, run `yarn e2eprep:windows` or +`yarn e2eprep:win32` from `apps/E2E`. These commands verify that Appium discovers +and imports the Windows driver already installed from the Yarn lockfile. +They do not download another driver from npm. + +Windows and Win32 test commands use `APPIUM_HOME=.` to discover the workspace's +Yarn-managed drivers and load only the Windows driver. Do not change this to +`.appium` or run an unversioned `appium driver install windows`: that creates a +separate dependency tree outside the lockfile and can leave the driver unable +to import its Appium peer. Existing `.appium` installations are ignored by these +two endpoints; the mobile and macOS commands retain their existing setup. + +Run `yarn test:appium` to check driver discovery, the locked package path and +version, the actual driver import, and the Windows/Win32 runner configuration +without launching a native app. + ## Win32 Steps 1. Install node packages, build JS diff --git a/apps/E2E/appium.test.cjs b/apps/E2E/appium.test.cjs new file mode 100644 index 0000000000..7c3feaa856 --- /dev/null +++ b/apps/E2E/appium.test.cjs @@ -0,0 +1,36 @@ +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const { realpathSync } = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); +const { pathToFileURL } = require('node:url'); + +const { scripts } = require('./package.json'); + +test('Appium discovers and loads the Yarn-managed Windows driver', async () => { + const output = execFileSync(process.execPath, [require.resolve('appium/index.js'), 'driver', 'list', '--installed', '--json'], { + cwd: __dirname, + encoding: 'utf8', + timeout: 60000, + }); + const { windows } = JSON.parse(output); + assert.ok(windows?.installed, 'The Windows driver must be registered before E2E starts.'); + + const packagePath = require.resolve('appium-windows-driver/package.json'); + const driverPackage = require(packagePath); + assert.equal(windows.version, driverPackage.version, 'Appium must use the driver version installed by Yarn.'); + assert.equal(realpathSync(windows.installPath), realpathSync(path.dirname(packagePath))); + + // Match Appium's dynamic import so missing peer dependencies fail during preparation. + const driver = await import(pathToFileURL(require.resolve(windows.installPath)).href); + assert.equal(typeof driver[windows.mainClass], 'function'); +}); + +for (const platform of ['windows', 'win32']) { + test(`${platform} E2E uses the workspace Appium home and only the Windows driver`, () => { + assert.match(scripts[`e2etest:${platform}`], /\bAPPIUM_HOME=\.\s/); + const { config } = require(`./wdio.conf.${platform}.js`); + const appiumService = config.services.find(([name]) => name === 'appium'); + assert.equal(appiumService[1].args['use-drivers'], 'windows'); + }); +} diff --git a/apps/E2E/package.json b/apps/E2E/package.json index 7c1f79f3ca..76847f9322 100644 --- a/apps/E2E/package.json +++ b/apps/E2E/package.json @@ -29,14 +29,15 @@ "e2eprep:android": "cross-env APPIUM_HOME=.appium yarn exec appium driver install uiautomator2", "e2eprep:ios": "cross-env APPIUM_HOME=.appium yarn exec appium driver install xcuitest", "e2eprep:macos": "cross-env APPIUM_HOME=.appium yarn exec appium driver install mac2", - "e2eprep:win32": "cross-env APPIUM_HOME=.appium yarn exec appium driver install windows", - "e2eprep:windows": "cross-env APPIUM_HOME=.appium yarn exec appium driver install windows", + "e2eprep:win32": "yarn test:appium", + "e2eprep:windows": "yarn test:appium", "e2etest:android": "cross-env APPIUM_HOME=.appium wdio run wdio.conf.android.js", "e2etest:ios": "cross-env APPIUM_HOME=.appium wdio run wdio.conf.ios.js", "e2etest:macos": "cross-env APPIUM_HOME=.appium wdio run wdio.conf.macos.js", - "e2etest:win32": "cross-env APPIUM_HOME=.appium wdio run wdio.conf.win32.js", - "e2etest:windows": "rimraf errorShots reports && cross-env APPIUM_HOME=.appium wdio run wdio.conf.windows.js", - "lint": "fluentui-scripts lint" + "e2etest:win32": "cross-env APPIUM_HOME=. wdio run wdio.conf.win32.js", + "e2etest:windows": "rimraf errorShots reports && cross-env APPIUM_HOME=. wdio run wdio.conf.windows.js", + "lint": "fluentui-scripts lint", + "test:appium": "cross-env APPIUM_HOME=. node --test appium.test.cjs" }, "dependencies": { "@office-iss/rex-win32": "0.81.1" diff --git a/apps/E2E/wdio.conf.win32.js b/apps/E2E/wdio.conf.win32.js index 5d07b5ba1f..c2f180a404 100644 --- a/apps/E2E/wdio.conf.win32.js +++ b/apps/E2E/wdio.conf.win32.js @@ -49,6 +49,7 @@ exports.config = { 'appium', { command: 'appium', + args: { 'use-drivers': 'windows' }, logPath: './reports/', }, ], diff --git a/apps/E2E/wdio.conf.windows.js b/apps/E2E/wdio.conf.windows.js index d8e22f2bfb..997c2a24d6 100644 --- a/apps/E2E/wdio.conf.windows.js +++ b/apps/E2E/wdio.conf.windows.js @@ -55,6 +55,7 @@ exports.config = { 'appium', { command: 'appium', + args: { 'use-drivers': 'windows' }, logPath: './reports/', }, ],