From bdd157401d88914e1db55216b6f5b53155bbebfb Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Wed, 28 Oct 2026 23:19:32 -0700 Subject: [PATCH 1/2] feat(agents): add macOS accessibility knowledge topics Add verified a11y topics (appkit, roles, state, text-input, keyboard) and a testing.ax-probes procedure with an out-of-process AX probe. Route accessibility work from AGENTS.md. Fix the RNTester Metro command, since `yarn start` fails under the pnpm linker. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/knowledge/a11y/appkit.md | 67 +++++++++++ .agents/knowledge/a11y/keyboard.md | 45 ++++++++ .agents/knowledge/a11y/roles.md | 62 ++++++++++ .agents/knowledge/a11y/state.md | 55 +++++++++ .agents/knowledge/a11y/text-input.md | 60 ++++++++++ .agents/knowledge/index.md | 6 + .agents/knowledge/taxonomy.yaml | 8 +- .agents/knowledge/testing/ax-probes.md | 118 ++++++++++++++++++++ .agents/knowledge/testing/rntester-macos.md | 8 +- AGENTS.md | 1 + 10 files changed, 426 insertions(+), 4 deletions(-) create mode 100644 .agents/knowledge/a11y/appkit.md create mode 100644 .agents/knowledge/a11y/keyboard.md create mode 100644 .agents/knowledge/a11y/roles.md create mode 100644 .agents/knowledge/a11y/state.md create mode 100644 .agents/knowledge/a11y/text-input.md create mode 100644 .agents/knowledge/testing/ax-probes.md diff --git a/.agents/knowledge/a11y/appkit.md b/.agents/knowledge/a11y/appkit.md new file mode 100644 index 00000000000..15c75354999 --- /dev/null +++ b/.agents/knowledge/a11y/appkit.md @@ -0,0 +1,67 @@ +--- +id: a11y.appkit +summary: AppKit NSAccessibility behavior that macOS view code depends on, and rules for setting roles, state, and the accessibility element. +kind: rules +status: active +load_when: Writing or reviewing native macOS code that sets an NSAccessibility role, state, or element +sources: + - packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUIAccessibilityTraits.h#RCTAccessibilityRoleFromTraits + - packages/react-native/React/Views/RCTView.m#isAccessibilitySelectorAllowed + - packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm#accessibilityElement + - packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm#RCTCopyBackedTextInput + - packages/react-native/Libraries/Text/TextInput/Multiline/RCTWrappedTextView.m#forwardInvocation +verified: 2026-10-28 +rules: + - never-copy-unset-role + - default-role-from-cell + - gate-state-attributes + - target-the-ax-element + - verify-out-of-process +related: [a11y.roles, a11y.state, testing.ax-probes] +watch: [microsoft/react-native-macos#3136] +asserts: + - file: packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputUtils.mm + contains: 'toTextInput.accessibilityRole = fromTextInput.accessibilityRole;' +--- + + + +# AppKit accessibility behavior + +Use when: you write native macOS code that sets an NSAccessibility role, a state attribute, or the accessibility element. Not for: how JS props become roles, which is [a11y.roles](roles.md), or checking the result, which is [testing.ax-probes](../testing/ax-probes.md). + +## Rules + +- **a11y.appkit.never-copy-unset-role:** Never copy or restore a role you read from an `accessibilityRole` getter. Copy it only when it isn't `NSAccessibilityUnknownRole`. +- **a11y.appkit.default-role-from-cell:** Read an `NSControl`'s default role from `control.cell.accessibilityRole`. The control's own getter returns `AXUnknown`. +- **a11y.appkit.gate-state-attributes:** Set `accessibilityEnabled`, `accessibilitySelected`, or `accessibilityExpanded` only when JS provides that state, and gate the selectors in `isAccessibilitySelectorAllowed:` as Paper's `RCTView` does. +- **a11y.appkit.target-the-ax-element:** Set accessibility properties on the object AX clients query, and confirm which object that is with a probe. A Fabric TextInput's `accessibilityElement` isn't always its text view. +- **a11y.appkit.verify-out-of-process:** Prove AX behavior with an out-of-process client ([testing.ax-probes](../testing/ax-probes.md)). In-process getters miss cell roles and don't show which attributes AppKit advertises. + +## Facts + +Observed with an out-of-process AX client on macOS 27.2: + +| Element | Getter in process | AX client sees | +| --------------------------------------------------- | --------------------------------- | ------------------------------ | +| `NSView` with no role set | `AXUnknown` | `AXUnknown`, no `AXEnabled` | +| `NSTextField`, editable | `AXUnknown` (cell: `AXTextField`) | `AXTextField`, `AXEnabled=1` | +| `NSTextField`, not editable but selectable | — | `AXStaticText` | +| `NSTextView` | `AXTextArea` | `AXTextArea`, no `AXEnabled` | +| `NSView` after `setAccessibilitySelected:NO` | — | `AXSelected=0`, now advertised | +| `NSTextField` after its role is set to `AXUnknown` | — | `AXUnknown` | +| `NSTextField` after its role is set to `AXTextArea` | — | `AXTextArea` | + +- `NSTextView` isn't an `NSControl`, so it has no `enabled` property. +- UIKit's traits don't exist on macOS. RCTUIKit defines `RCTUIAccessibilityTraits` as a `uint64_t` bitmask with the iOS constants so shared code compiles, and `RCTAccessibilityRoleFromTraits` maps traits to an `NSAccessibilityRole`. +- A Fabric `View`'s `accessibilityElement` is the view itself. A Fabric TextInput's is `_backedTextInputView`: an `RCTUITextField` when single-line, or an `RCTWrappedTextView` when multiline on macOS. +- `RCTWrappedTextView` is an `NSView` that wraps an `RCTUITextView` in a scroll view. It forwards first-responder calls, and forwards through `forwardInvocation:` only selectors that `NSView` lacks, such as `setEditable:`. NSAccessibility setters stay on the wrapper. + +## Pitfalls + +- On `main`, `RCTCopyBackedTextInput` copies `accessibilityRole` unconditionally when `multiline` or `secureTextEntry` changes. The old view's getter returns `AXUnknown`, so the new `NSTextField` reports `AXUnknown` instead of `AXTextField`. +- The comment in `RCTUIAccessibilityTraits.h` says `RCTViewAccessibilityElement` does the role mapping. That class no longer exists; the view code maps roles. + +## Verify + +Run `xcrun swift ax.swift dump ` from [testing.ax-probes](../testing/ax-probes.md) against a running app. diff --git a/.agents/knowledge/a11y/keyboard.md b/.agents/knowledge/a11y/keyboard.md new file mode 100644 index 00000000000..21dccd1b116 --- /dev/null +++ b/.agents/knowledge/a11y/keyboard.md @@ -0,0 +1,45 @@ +--- +id: a11y.keyboard +summary: How focusable, first responder, and the key view loop decide Tab order on macOS, and why a TextInput takes two Tab stops on main. +kind: rules +status: active +load_when: Changing focus, first responder, Tab order, or the key view loop on macOS +sources: + - packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm#acceptsFirstResponder + - packages/react-native/Libraries/Components/TextInput/TextInput.js#focusable + - packages/react-native/Libraries/Text/TextInput/Multiline/RCTWrappedTextView.m#acceptsFirstResponder + - packages/react-native/Libraries/AppDelegate/RCTAppDelegate.mm#autorecalculatesKeyViewLoop + - packages/rn-tester/RNTester/AppDelegate.mm#NSWindow + - packages/react-native/Libraries/NativeComponent/BaseViewConfig.macos.js#enableFocusRing +verified: 2026-10-28 +rules: [one-tab-stop, test-recalculated-loop] +related: [a11y.text-input, testing.ax-probes] +watch: [microsoft/react-native-macos#3136] +--- + + + +# Keyboard focus and Tab order + +Use when: you change focus, first responder, Tab order, or the key view loop on macOS. Not for: TextInput `editable` semantics ([a11y.text-input](text-input.md)). + +## Rules + +- **a11y.keyboard.one-tab-stop:** Give each control one Tab stop. A host view that wraps a focusable AppKit view must not accept first responder itself. +- **a11y.keyboard.test-recalculated-loop:** Test Tab order in a window with `autorecalculatesKeyViewLoop = YES`, which apps built on `RCTAppDelegate` get. On `main`, RNTester's macOS window doesn't set it. + +## Facts + +- A Fabric view's `acceptsFirstResponder` returns `focusable || [super acceptsFirstResponder]`. On macOS, `focusable`, `enableFocusRing`, `keyDownEvents`, and `keyUpEvents` are `View` props. +- `TextInput.js` sends `focusable` as `true` unless the app passes `false`. The Fabric TextInput host doesn't override first-responder methods, so on `main` it becomes a Tab stop before its field. Each TextInput then takes two Tab presses, and an AX client reports the enclosing `AXScrollArea` as focused on the first one. #3136 makes the host refuse focus. +- `RCTWrappedTextView` forwards `acceptsFirstResponder`, `becomeFirstResponder`, and `resignFirstResponder` to its `RCTUITextView`. +- `RCTAppDelegate` creates its window with `autorecalculatesKeyViewLoop = YES`. RNTester's `AppDelegate` creates its own `NSWindow` and doesn't set it on `main`; #3136 adds it. + +## Pitfalls + +- A focused `AXScrollArea` between two fields is the extra host stop, not a scroll view taking focus. +- Test Shift-Tab (`key code 48 using shift down`) as well as Tab. + +## Verify + +Focus the first element, press Tab with `osascript -e 'tell application "System Events" to key code 48'`, and read the focus with `ax.swift focused` after each press ([testing.ax-probes](../testing/ax-probes.md)). diff --git a/.agents/knowledge/a11y/roles.md b/.agents/knowledge/a11y/roles.md new file mode 100644 index 00000000000..73672335bfd --- /dev/null +++ b/.agents/knowledge/a11y/roles.md @@ -0,0 +1,62 @@ +--- +id: a11y.roles +summary: How role and accessibilityRole become NSAccessibility roles in Paper and Fabric, where the tables differ, and rules for changing them. +kind: rules +status: active +load_when: Changing or debugging how role or accessibilityRole maps to an NSAccessibility role +sources: + - packages/react-native/React/Base/RCTConvert.mm#accessibilityRoleFromAriaRole + - packages/react-native/React/Base/RCTConvert.mm#traitOrRoleToAccessibilityRole + - packages/react-native/React/Views/RCTViewManager.m#updateAccessibilityRole + - packages/react-native/ReactCommon/react/renderer/components/view/AccessibilityProps.cpp#precedentRoleValue + - packages/react-native/ReactCommon/react/renderer/components/view/accessibilityPropsConversions.h#Unsupported + - packages/react-native/ReactApple/Libraries/RCTUIKit/RCTUIAccessibilityTraits.h#RCTAccessibilityRoleFromTraits + - packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm#RCTAccessibilityRoleFromTraits +verified: 2026-10-28 +rules: [role-wins, restore-default-role, no-paper-in-fabric] +requires: [a11y.appkit] +related: [a11y.state] +watch: [microsoft/react-native-macos#3136] +asserts: + - file: packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm + contains: 'self.accessibilityRole = RCTAccessibilityRoleFromTraits(traits);' +--- + + + +# Accessibility roles + +Use when: you change or debug how `role` or `accessibilityRole` becomes an NSAccessibility role. Not for: state attributes ([a11y.state](state.md)) or TextInput roles ([a11y.text-input](text-input.md)). + +## Rules + +- **a11y.roles.role-wins:** Resolve `role` first, then `accessibilityRole`, then the element's default role. Paper's `updateAccessibilityRole:` and the C++ traits parser both use this order. +- **a11y.roles.restore-default-role:** When both props are unset, restore the element's default role, read as [a11y.appkit](appkit.md) describes, not the last role you set. +- **a11y.roles.no-paper-in-fabric:** Don't import Paper's `RCTConvert` into Fabric component views to reuse its tables. Maintainers asked for Fabric-local code on #3136. + +## Facts + +How each architecture maps a role on `main`: + +| Step | Paper (`RCTViewManager`, `RCTConvert`) | Fabric (`RCTViewComponentView`) | +| ------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `role` | ARIA table, `accessibilityRoleFromAriaRole:`; unknown strings fall back to the legacy table | Parsed into traits only | +| `accessibilityRole` | Legacy table, `traitOrRoleToAccessibilityRole` | Parsed into traits only | +| Neither set | `defaultView.accessibilityRole` | Whatever `RCTAccessibilityRoleFromTraits` returns, often `AXUnknown` | + +- The two Paper tables disagree. `tab` maps to `AXRadioButton` in the ARIA table and to `AXButton` in the legacy table. `none` maps to `AXGroup` and to `AXUnknown`. +- The C++ traits parser (`fromString` for `AccessibilityTraits`) knows about 20 strings, such as `button`, `link`, `header`, `image`, and `switch`. Every other string, such as `menuitem`, `option`, `tab`, or `checkbox`, becomes no traits. +- `RCTAccessibilityRoleFromTraits` checks traits in a fixed order and returns `AXUnknown` when none match. On `main`, an AX client sees `AXUnknown` for `role="menuitem"`, `"option"`, and `"tab"`, while `role="button"` reports `AXButton`. +- Fabric stores `role` as the C++ `Role` enum and `accessibilityRole` as a string. Strings with no `Role` value, such as `textbox`, `listbox`, `image`, and `search`, log "Unsupported Role value" and become `Role::None`, so they never reach an ARIA table. The JS `Role` type doesn't allow them either. +- When a raw prop value is missing or `null`, the C++ parser keeps the previous value for `accessibilityRole` and the traits. +- With the `enableCppPropsIteratorSetter` flag (off by default), `role` doesn't update the traits. +- Fabric's only `RCTConvert.h` import is in `Fabric/Utils/RCTColorSpaceUtils.h`. + +## Pitfalls + +- Merging the two Paper tables silently changes `tab` and `none`. +- Testing only `button` hides the problem, because `button` works on `main` through traits. Include a role with no trait, such as `menuitem`. + +## Verify + +Give each element under test a `testID`, and dump its role with [testing.ax-probes](../testing/ax-probes.md). Each role must match the table its prop goes through. #3136 adds `macos-role-*` examples to RNTester. diff --git a/.agents/knowledge/a11y/state.md b/.agents/knowledge/a11y/state.md new file mode 100644 index 00000000000..c6a4844915a --- /dev/null +++ b/.agents/knowledge/a11y/state.md @@ -0,0 +1,55 @@ +--- +id: a11y.state +summary: How accessibilityState and accessibility actions reach macOS AX clients in Paper and Fabric, the gaps on main, and rules for exposing them. +kind: rules +status: active +load_when: Changing how accessibilityState, AXEnabled, AXSelected, AXExpanded, or AXPress behave on macOS +sources: + - packages/react-native/React/Views/RCTView.m#isAccessibilitySelectorAllowed + - packages/react-native/React/Views/RCTView.m#accessibilityPerformPress + - packages/react-native/React/Views/RCTViewManager.m#RCT_CUSTOM_VIEW_PROPERTY(accessibilityState + - packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm#accessibilityValue + - packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm#RCTUIAccessibilityTraitNotEnabled +verified: 2026-10-28 +rules: [clear-on-removal, gate-press] +requires: [a11y.appkit] +related: [a11y.roles, a11y.text-input] +watch: [microsoft/react-native-macos#3136] +--- + + + +# Accessibility state and actions + +Use when: you change how `accessibilityState` or accessibility actions reach AX clients on macOS. Not for: roles ([a11y.roles](roles.md)) or TextInput's `editable` ([a11y.text-input](text-input.md)). + +## Rules + +- **a11y.state.clear-on-removal:** When JS removes `accessibilityState` or one of its keys, stop advertising that attribute. Setting it to `NO` isn't enough, because AppKit keeps advertising it ([a11y.appkit](appkit.md)). +- **a11y.state.gate-press:** Advertise `AXPress` only when the element handles it, through `onAccessibilityTap` or an `activate` action, as Paper's `RCTView` does. + +## Facts + +What an AX client sees for each state on `main`: + +| JS | Paper (`RCTView`) | Fabric (`RCTViewComponentView`) | +| ----------------- | -------------------------------------------------------- | -------------------------------------------------- | +| `disabled` | `AXEnabled`, only when the key is set | Nothing; `AXEnabled` stays `1` | +| `selected` | `AXSelected`, only when the key is set | Nothing | +| `expanded` | `AXExpanded`, only when the key is set | The word "expanded" in `AXValue`, only when `true` | +| `checked`, `busy` | Words in `AXValue` | Words in `AXValue` | +| Press | `AXPress` when `onAccessibilityTap` or `activate` exists | No `AXPress` | + +- Paper gates the attributes and actions in `isAccessibilitySelectorAllowed:`. +- Fabric's state block in `updateProps` is iOS-only. On macOS, Fabric sets `accessibilityEnabled` only from the `NotEnabled` trait, inside the traits block, so any element whose traits change advertises `AXEnabled`. +- Both architectures build `AXValue` from localized words for `checked`, `unchecked`, `mixed`, `busy`, and `expanded`, and Fabric adds role words for `checkbox` and `radio`. +- On `main`, Paper's `accessibilityState` setter returns early when the prop is removed, so the previous state stays. #3136 clears it on macOS. + +## Pitfalls + +- An AX client can't press a Fabric view on `main`. Probes must click by position instead ([testing.ax-probes](../testing/ax-probes.md)). +- A test that only sets state once misses stale state. Toggle each key on, off, and then remove it. + +## Verify + +Give the element a `testID`, toggle its state, and dump it with [testing.ax-probes](../testing/ax-probes.md) after each change. diff --git a/.agents/knowledge/a11y/text-input.md b/.agents/knowledge/a11y/text-input.md new file mode 100644 index 00000000000..1f7dd1f61d3 --- /dev/null +++ b/.agents/knowledge/a11y/text-input.md @@ -0,0 +1,60 @@ +--- +id: a11y.text-input +summary: How TextInput's editable, readOnly, and accessibility props behave on macOS compared with other platforms, and the AX traps of its backing views. +kind: rules +status: active +load_when: Changing TextInput editable, readOnly, disabled, or accessibility behavior on macOS +sources: + - packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm#setEditable + - packages/react-native/Libraries/Text/TextInput/Multiline/RCTWrappedTextView.m#forwardInvocation + - packages/react-native/Libraries/Components/TextInput/TextInput.js#readOnly + - packages/react-native/Libraries/Components/TextInput/TextInput.flow.js#readOnly + - packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt#isEnabled +verified: 2026-10-28 +rules: [match-other-platforms, test-both-backings] +requires: [a11y.appkit] +related: [a11y.state, a11y.keyboard] +watch: [microsoft/react-native-macos#3136] +asserts: + - file: packages/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm + contains: 'self.enabled = editable;' +--- + + + +# TextInput accessibility + +Use when: you change how TextInput's `editable`, `readOnly`, or accessibility props behave on macOS. Not for: Tab order ([a11y.keyboard](keyboard.md)) or `View` state ([a11y.state](state.md)). + +## Rules + +- **a11y.text-input.match-other-platforms:** When macOS behavior is a choice, match iOS, web, and React Native unless desktop conventions call for something else, and record why. Maintainers gave this guidance on #3136, where read-only versus disabled is still open. +- **a11y.text-input.test-both-backings:** Test single-line and multiline inputs separately. They use different AppKit views and behave differently for AX clients. + +## Facts + +What `editable={false}` does on each platform: + +| Platform | Implementation | Result | +| ------------------ | ------------------------------------------------------------------------- | -------------------------------------------- | +| iOS, single-line | `RCTUITextField setEditable:` sets `enabled = editable` | Disabled field | +| iOS, multiline | `UITextView.editable = NO`; `RCTUITextView` doesn't override it | Read-only, still selectable | +| Android | `view.isEnabled = editable` | Disabled field | +| Web | react-native-web sets `readOnly` when `editable === false` (`dadf289dd9`) | Read-only | +| Windows | react-native-windows sets `TXTBIT_READONLY` (`e8599fb438`) | Read-only | +| macOS, single-line | The iOS code plus `[super setEditable:]` | `AXStaticText`, `AXEnabled=0`, not focusable | +| macOS, multiline | `RCTWrappedTextView` forwards `setEditable:` to its `NSTextView` | Read-only `NSTextView` (inferred) | + +- JS maps `readOnly` onto `editable`, and `readOnly` wins when both are set. TextInput has no `disabled` prop; `accessibilityState.disabled` changes only accessibility. +- On `main`, `accessibilityState={{disabled: true}}` on a TextInput leaves `AXEnabled=1`, and the field still takes focus. +- A multiline input's `testID` doesn't reach its `AXTextArea`, which has no `AXIdentifier`. The identifier stays on the `RCTWrappedTextView` wrapper. +- On `main`, single-line inputs that `RCTCopyBackedTextInput` rebuilt report `AXUnknown` ([a11y.appkit](appkit.md)). Both `secureTextEntry` inputs in RNTester's TextInput module do, and two of three identical inputs did in one run. With #3136's copy fix, all three identical inputs report `AXTextField`. + +## Pitfalls + +- An `NSTextField` that isn't editable reports `AXStaticText`, even when it's selectable. Don't expect `AXTextField` from a read-only single-line input. +- Probes can't find a multiline input by `testID`. Dump all elements and find its `AXTextArea` by value. + +## Verify + +Dump single-line and multiline inputs with [testing.ax-probes](../testing/ax-probes.md) in each `editable` and `accessibilityState` combination, including after the prop is removed. diff --git a/.agents/knowledge/index.md b/.agents/knowledge/index.md index 446ed9b1a62..2b85af651b9 100644 --- a/.agents/knowledge/index.md +++ b/.agents/knowledge/index.md @@ -12,11 +12,17 @@ Vocabulary, budgets, and routes are in [taxonomy.yaml](taxonomy.yaml). The table | Topic | Load when | Summary | | --- | --- | --- | +| [a11y.appkit](a11y/appkit.md) | Writing or reviewing native macOS code that sets an NSAccessibility role, state, or element | AppKit NSAccessibility behavior that macOS view code depends on, and rules for setting roles, state, and the accessibility element. | +| [a11y.keyboard](a11y/keyboard.md) | Changing focus, first responder, Tab order, or the key view loop on macOS | How focusable, first responder, and the key view loop decide Tab order on macOS, and why a TextInput takes two Tab stops on main. | +| [a11y.roles](a11y/roles.md) | Changing or debugging how role or accessibilityRole maps to an NSAccessibility role | How role and accessibilityRole become NSAccessibility roles in Paper and Fabric, where the tables differ, and rules for changing them. | +| [a11y.state](a11y/state.md) | Changing how accessibilityState, AXEnabled, AXSelected, AXExpanded, or AXPress behave on macOS | How accessibilityState and accessibility actions reach macOS AX clients in Paper and Fabric, the gaps on main, and rules for exposing them. | +| [a11y.text-input](a11y/text-input.md) | Changing TextInput editable, readOnly, disabled, or accessibility behavior on macOS | How TextInput's editable, readOnly, and accessibility props behave on macOS compared with other platforms, and the AX traps of its backing views. | | [evals.knowledge-checks](evals/knowledge-checks.md) | A knowledge check fails, or you are changing knowledge.mts or its checks | Catalog of the KC checks that knowledge.mts runs on agent knowledge files, with severity and how to fix each failure. | | [fork.diff-tags](fork/diff-tags.md) | Changing a file that upstream React Native also has, or adding a fork-only file | How to mark macOS changes in shared upstream files so merges and audits can find every fork diff. | | [fork.ownership](fork/ownership.md) | Adding a file, choosing where fork content goes, or editing configs that upstream also ships | Which paths the fork owns, which it shares with upstream, and which it must never edit. | | [fork.upstream-sync](fork/upstream-sync.md) | Reviewing a large upstream version merge or deciding which release fixes to forward-port to main | Classify local fixes from a version-sync branch before forwarding them to main. | | [process.pull-requests](process/pull-requests.md) | Writing a commit or PR title or body, adding a Changeset, or backporting to a stable branch | PR titles, the PR body template, Changesets, and backport conventions for react-native-macos. | +| [testing.ax-probes](testing/ax-probes.md) | Verifying a macOS accessibility, focus, or Tab-order change in a running app | Check what macOS accessibility clients see in RNTester-macOS with a small out-of-process AX probe, osascript keys, and testIDs. | | [testing.rntester-macos](testing/rntester-macos.md) | Building, running, or debugging RNTester-macOS, or validating a native macOS change | Install Pods for and build RNTester-macOS the way CI does, then run it with Metro. | | [tooling.commands](tooling/commands.md) | Choosing a command to install, build, test, lint, or format, or when a Yarn command fails | Yarn 4 setup and the root commands for building, testing, linting, and formatting react-native-macos. | | [tooling.format-lint](tooling/format-lint.md) | Formatting or linting a file, or writing Markdown with frontmatter | Prettier pragma, markdownlint scope, and ESLint and Flow gaps for react-native-macos files, including agent knowledge files. | diff --git a/.agents/knowledge/taxonomy.yaml b/.agents/knowledge/taxonomy.yaml index c8193ea2ba9..29743e56f27 100644 --- a/.agents/knowledge/taxonomy.yaml +++ b/.agents/knowledge/taxonomy.yaml @@ -90,5 +90,9 @@ retired_ids: [] yarn_allow: [] # KC-05 accepts these paths, which knowledge files mention on purpose even though they don't exist, -# such as discovery folders the fork chose not to create. -absent_paths: [.github/skills, .claude/rules] +# such as discovery folders the fork chose not to create, build output, and paths a tool expects but never creates. +absent_paths: + - .github/skills + - .claude/rules + - packages/rn-tester/Build/Products + - .yarn/__virtual__ diff --git a/.agents/knowledge/testing/ax-probes.md b/.agents/knowledge/testing/ax-probes.md new file mode 100644 index 00000000000..ef3c288bf6b --- /dev/null +++ b/.agents/knowledge/testing/ax-probes.md @@ -0,0 +1,118 @@ +--- +id: testing.ax-probes +summary: Check what macOS accessibility clients see in RNTester-macOS with a small out-of-process AX probe, osascript keys, and testIDs. +kind: procedure +status: active +load_when: Verifying a macOS accessibility, focus, or Tab-order change in a running app +sources: + - packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm#testId + - packages/rn-tester/js/components/RNTesterNavbar.js#apis-tab + - packages/rn-tester/js/components/RNTesterModuleList.js#explorer_search + - packages/rn-tester/js/components/RNTesterModuleContainer.js#example_search + - .ado/scripts/xcodebuild.sh#derivedDataPath +verified: 2026-10-28 +rules: [baseline-first, unique-testids] +requires: [testing.rntester-macos] +related: [a11y.appkit, a11y.keyboard] +--- + + + +# Accessibility probes + +Use when: you need to prove what VoiceOver and other AX clients see after a macOS accessibility, focus, or Tab-order change. Not for: building the app ([testing.rntester-macos](rntester-macos.md)) or deciding what the result should be (the `a11y` topics). + +## Rules + +- **testing.ax-probes.baseline-first:** Record the probe output on the base branch before your change, then on your branch, and report both with the macOS version. +- **testing.ax-probes.unique-testids:** Give every element under test a unique `testID`, which AX clients see as `AXIdentifier`. + +## Procedure + +Grant your terminal Accessibility permission once, in System Settings > Privacy & Security > Accessibility. Save the probe as `/tmp/ax.swift` and compile it with `xcrun swiftc -O /tmp/ax.swift -o /tmp/ax`: + +```swift +// ax.swift dump [testID-prefix] | focused | click +import ApplicationServices + +let args = CommandLine.arguments +guard AXIsProcessTrusted() else { print("Grant this terminal Accessibility permission."); exit(3) } +let app = AXUIElementCreateApplication(pid_t(args[1])!) + +func attr(_ e: AXUIElement, _ name: String) -> AnyObject? { + var value: AnyObject? + return AXUIElementCopyAttributeValue(e, name as CFString, &value) == .success ? value : nil +} + +func describe(_ e: AXUIElement) -> String { + var namesRef: CFArray? + AXUIElementCopyAttributeNames(e, &namesRef) + let names = namesRef as? [String] ?? [] + let show = { (a: String) in names.contains(a) ? attr(e, a).map { "\($0)" } ?? "nil" : "absent" } + return "\(attr(e, "AXIdentifier") as? String ?? "(no id)") role=\(show("AXRole"))" + + " enabled=\(show("AXEnabled")) selected=\(show("AXSelected")) expanded=\(show("AXExpanded"))" + + " value=\(show("AXValue").prefix(40))" +} + +func elements(_ e: AXUIElement, _ depth: Int = 0) -> [AXUIElement] { + let children = depth < 80 ? attr(e, "AXChildren") as? [AXUIElement] ?? [] : [] + return [e] + children.flatMap { elements($0, depth + 1) } +} + +let all = (attr(app, "AXWindows") as? [AXUIElement] ?? []).flatMap { elements($0) } +func withId(_ prefix: String) -> [AXUIElement] { + all.filter { prefix.isEmpty || ((attr($0, "AXIdentifier") as? String)?.hasPrefix(prefix) ?? false) } +} + +switch args[2] { +case "dump": withId(args.count > 3 ? args[3] : "").forEach { print(describe($0)) } +case "focused": print(attr(app, "AXFocusedUIElement").map { describe($0 as! AXUIElement) } ?? "(none)") +case "click": + guard let e = all.first(where: { attr($0, "AXIdentifier") as? String == args[3] }) else { print("not found: \(args[3])"); exit(1) } + var origin = CGPoint.zero, size = CGSize.zero + AXValueGetValue(attr(e, "AXPosition") as! AXValue, .cgPoint, &origin) + AXValueGetValue(attr(e, "AXSize") as! AXValue, .cgSize, &size) + let point = CGPoint(x: origin.x + min(size.width / 2, 20), y: origin.y + size.height / 2) + for type in [CGEventType.leftMouseDown, .leftMouseUp] { + CGEvent(mouseEventSource: nil, mouseType: type, mouseCursorPosition: point, mouseButton: .left)?.post(tap: .cghidEventTap) + } +default: print("unknown command \(args[2])"); exit(2) +} +``` + +Build RNTester-macOS, start Metro, then launch the app and drive it from the repository root: + +```sh +open -n packages/rn-tester/Build/Products/Debug/RNTester-macOS.app +PID=$(pgrep -n -f "RNTester-macOS.app/Contents/MacOS/RNTester-macOS") +until [ -n "$(/tmp/ax $PID dump explorer_search)" ]; do sleep 2; done +keys() { osascript -e "tell application \"System Events\" to $1"; } +keys "set frontmost of (first process whose unix id is $PID) to true" +/tmp/ax $PID click apis-tab # or components-tab +/tmp/ax $PID click explorer_search +keys 'keystroke "a" using command down' +keys 'keystroke "Accessibility"' +/tmp/ax $PID click Accessibility # a module's testID is its title +sleep 2 # let the module render +/tmp/ax $PID dump my-test-id # filter examples with example_search the same way +keys 'key code 48' # Tab; add "using shift down" for Shift-Tab +/tmp/ax $PID focused +``` + +## Facts + +- `absent` means AppKit doesn't advertise the attribute; `nil` means it's advertised with no value. +- RNTester-macOS ignores deep links, so navigate with the tabs and the search fields. +- Fabric views have no `AXPress`, so `click` posts a real mouse click at the element. The window must be frontmost and unobscured. +- The wrapper's `-derivedDataPath` puts the app under `packages/rn-tester/Build/Products`. + +## Pitfalls + +- A multiline TextInput has no `AXIdentifier` ([a11y.text-input](../a11y/text-input.md)). Run `dump` with no prefix and find it by value. +- `osascript` keystrokes go to the frontmost app. Activate the app again after anything else takes focus. +- On `main`, Tab doesn't move focus in RNTester-macOS, because its window doesn't recalculate the key view loop ([a11y.keyboard](../a11y/keyboard.md)). +- If the app shows "No script URL provided", Metro isn't serving `/status`. Start it as [testing.rntester-macos](rntester-macos.md) shows. + +## Verify + +`/tmp/ax $PID dump explorer_search` prints the search field once RNTester-macOS has loaded. diff --git a/.agents/knowledge/testing/rntester-macos.md b/.agents/knowledge/testing/rntester-macos.md index b80055bf3ec..aa8bd002f8a 100644 --- a/.agents/knowledge/testing/rntester-macos.md +++ b/.agents/knowledge/testing/rntester-macos.md @@ -9,6 +9,8 @@ sources: - .ado/scripts/xcodebuild.sh#derivedDataPath - packages/rn-tester/Podfile:6-28 - packages/rn-tester/js/utils/RNTesterList.macos.js + - .yarnrc.yml#nodeLinker + - packages/community-cli-plugin/src/commands/start/middleware.js#cli-server-api verified: 2026-10-28 rules: [build-like-ci, open-workspace] related: [tooling.commands] @@ -33,7 +35,8 @@ bundle install RCT_NEW_ARCH_ENABLED=1 bundle exec pod install cd ../.. .ado/scripts/xcodebuild.sh packages/rn-tester/RNTesterPods.xcworkspace macosx Debug RNTester-macOS build -yarn start # Metro, in a second terminal, before you launch the app +cd packages/rn-tester # then start Metro in a second terminal before you launch the app: +NODE_PATH="$PWD/node_modules" node ../react-native/cli.js start ``` ## Facts @@ -47,8 +50,9 @@ yarn start # Metro, in a second terminal, before you launch the app ## Pitfalls - After you change native dependencies or switch branches, run `pod install` again before building. +- `yarn start` fails under the repository's `nodeLinker: pnpm`: Yarn resolves the `react-native-macos` binary under `.yarn/__virtual__`, which the pnpm linker never creates. Running `cli.js` without `NODE_PATH` starts Metro without `/status`, and the app then reports "No script URL provided". - A green macOS build doesn't prove iOS or visionOS still compile. When you change shared Apple code, build the `RNTester` scheme with `iphonesimulator` too. ## Verify -The wrapper exits 0 when the build succeeds, and the app launches with Metro running. +The wrapper exits 0 when the build succeeds. Before you launch the app, `curl localhost:8081/status` prints `packager-status:running`. diff --git a/AGENTS.md b/AGENTS.md index a01aff07768..e1ac1ee0943 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,7 @@ This is Microsoft's macOS fork of React Native. Keep changes merge-friendly, and | Changing a file that upstream also has | [fork.diff-tags](.agents/knowledge/fork/diff-tags.md), [fork.ownership](.agents/knowledge/fork/ownership.md) | | Running builds, tests, linters, or formatters | [tooling.commands](.agents/knowledge/tooling/commands.md), [tooling.format-lint](.agents/knowledge/tooling/format-lint.md) | | Building or running RNTester-macOS | [testing.rntester-macos](.agents/knowledge/testing/rntester-macos.md) | +| Changing macOS accessibility, focus, or Tab order | [a11y.appkit](.agents/knowledge/a11y/appkit.md), then the matching `a11y` topic in the [index](.agents/knowledge/index.md) | | Writing a PR, Changeset, or backport | [process.pull-requests](.agents/knowledge/process/pull-requests.md) | | Doing anything else | [Knowledge index](.agents/knowledge/index.md) | From c64c6085e297f3dcd679e5c5fa0f9c5dc97466ce Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 29 Oct 2026 12:49:19 -0700 Subject: [PATCH 2/2] docs(a11y): check JS ref focus separately from keyboard traversal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/knowledge/testing/ax-probes.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.agents/knowledge/testing/ax-probes.md b/.agents/knowledge/testing/ax-probes.md index ef3c288bf6b..66e109d8b61 100644 --- a/.agents/knowledge/testing/ax-probes.md +++ b/.agents/knowledge/testing/ax-probes.md @@ -9,8 +9,8 @@ sources: - packages/rn-tester/js/components/RNTesterNavbar.js#apis-tab - packages/rn-tester/js/components/RNTesterModuleList.js#explorer_search - packages/rn-tester/js/components/RNTesterModuleContainer.js#example_search - - .ado/scripts/xcodebuild.sh#derivedDataPath -verified: 2026-10-28 + - https://github.com/microsoft/react-native-macos/pull/2824#discussion_r2905251918 +verified: 2026-10-29 rules: [baseline-first, unique-testids] requires: [testing.rntester-macos] related: [a11y.appkit, a11y.keyboard] @@ -99,12 +99,13 @@ keys 'key code 48' # Tab; add "using shift down" for Shift-T /tmp/ax $PID focused ``` +For a focus change, also test the JavaScript ref path independently. Use RNTester controls or a temporary test control that calls the affected component's `ref.current.focus()` and `ref.current.blur()`. After each call, run `/tmp/ax $PID focused` and record the `AXFocusedUIElement`; then test Tab and Shift-Tab separately. A working keyboard path does not prove that the JS ref path works, or vice versa. + ## Facts - `absent` means AppKit doesn't advertise the attribute; `nil` means it's advertised with no value. - RNTester-macOS ignores deep links, so navigate with the tabs and the search fields. - Fabric views have no `AXPress`, so `click` posts a real mouse click at the element. The window must be frontmost and unobscured. -- The wrapper's `-derivedDataPath` puts the app under `packages/rn-tester/Build/Products`. ## Pitfalls