Skip to content

fix(core): one modality call per component, reading the component's own document - #9148

Merged
cixzhang merged 7 commits into
mainfrom
fix/modality-tracking-one-call
Nov 1, 2026
Merged

cixzhang merged 7 commits into
mainfrom
fix/modality-tracking-one-call

Conversation

@cixzhang

@cixzhang cixzhang commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

User impact

A keyboard user working in a component that an app renders into a same-origin iframe or a Document Picture-in-Picture window gets the page's last input instead of their own for keyboard rings and highlights. If their last click was on the page:

  • Slider shows no keyboard state on its thumb.
  • MultiSelector and DropdownMenu drop their keyboard highlight.
  • Panel search fields and the CommandPalette input lose their keyboard ring.
  • Focus a component moves itself (focusForLastInput) gets the ring of the page's last input.

Where an overlay returns focus on close (the opener is recorded from the page's document in DropdownMenu, Selector, useMenuHover and BottomSheet) is a separate defect, owned by the planned focus-return helper; this PR doesn't change it.

Only Dialog read its own document (#8381), and it needed an extra effect next to its hook call to do it.

Problem and solution fit

  • Why the wrong value? The tracking hook listened only on the page's document. Every read (getInteractionModality(), and focusForLastInput's default) asked the page.
  • Why does that harm the task? The ring and highlight are how a keyboard user sees where they are. Reading another document's input hides them, or shows them after a click.
  • Why does that matter? architecture:interaction-modality INV1 says the ring follows the last input. Per the new one-call rule, a concern Core owns in a shared hook is fixed there, and a component makes one call with no effect of its own. Dialog's hook-plus-effect pattern would otherwise spread to every component that needs a second document.

The fix moves per-document tracking into the hook. A component passes its element ref and makes one call that both tracks and reads its own document.

Expected behavior and authority

Change

Before (Dialog, the only per-document reader):

useInteractionModalityTracking();
useEffect(() => {
  const ownerDocument = dialogRef.current?.ownerDocument;
  if (ownerDocument == null || ownerDocument === document) return undefined;
  return trackInteractionModalityIn(ownerDocument);
}, [isInline]);
…
focusForLastInput(trigger, undefined, getInteractionModality(trigger.ownerDocument));

After (every component):

const {ref: modalityRef, readModality} = useInteractionModalityTracking();
<div ref={useMergedRefs(elementRef, modalityRef)} … />
…
readModality() === 'keyboard'      // or focusForLastInput(element), which now reads element's document
  • utils/interactionModality.ts:
    • useInteractionModalityTracking() keeps the page's document-lifetime listeners and returns {ref, readModality}.
      • ref is a callback ref the component attaches to its element. On attach it holds the element's document when that isn't the page's; detach or replacement releases it first, through React's ref cleanup or a null call, never twice.
      • readModality answers for that element's document.
    • useInteractionModalityTrackingFor(objectRef) serves only useMenuPress and useTouchTrigger, which receive a consumer's object ref. Both are public: useMenuPress from the package root, useTouchTrigger from the @astryxdesign/core/Layer subpath export. So their signatures stay unchanged. After every commit it holds the ref's current owner document, and the same Effect's cleanup releases it.
    • The tracking ref's cleanup forgets its node, so the reader never answers from a document whose listeners are gone.
    • getInteractionModality(target?) accepts a node or a document.
    • Listening sits behind one internal setListening, so a later move onto the shared input stream (fix(core): one click guard for the menu press model and the gesture host #8360) touches one place.
    • trackInteractionModalityIn is no longer exported; a test-only alias remains.
  • utils/focusReturn.ts: focusForLastInput defaults to the element's own document. All 11 callers get the right document with no callsite change.
  • Readers moved to the one call (16 reads):
    • Through the callback ref: Slider, MultiSelector (its built-in and caller-rendered triggers) and Selector (on their triggers), DropdownMenu (its trigger, in both the popover and bottom-sheet variants, where focus returns), ChatComposer, CommandPaletteInput, PanelSearchInput, BottomSheet, BreadcrumbItem's menu trigger and the menu sheet's action list.
    • useFocusReturnVisibility now takes the component's tracking, so a component still makes one call.
    • Through the object-ref form: useMenuPress (its menuRef) and useTouchTrigger.
  • Dialog: its effect and its trackInteractionModalityIn import are deleted; one call remains.
  • Not changed:
    • No public export.
    • Nothing on the page path: an element on the page reads the page's value exactly as before.
    • AppShell is a separate concern, a Tab-arrival bit for main's ring, so it stays out.
    • The overlay focus-return copies are a later useFocusReturn helper.

Original per-document fix by @vjeux (#8381).

Ownership (react-component-runtime INV3, INV5, INV6)

  • A component that renders its element attaches the hook's callback ref. Attach holds the element's document, and detach or replacement releases the old one before a new one is held (INV6). An unchanged commit doesn't touch the listeners, which fix(Dialog): closing returns focus to the opener in a second document #8381's Dialog test pins.
  • useMenuPress and useTouchTrigger receive a consumer's object ref, so they can't attach a ref. An Effect holds the ref's current document after every commit, and its own cleanup releases it (INV3).
  • The Specs & Decisions ruling that the object-ref half meets INV6's "other node owner" clause still describes that half. Both halves have one lifecycle owner, released on replacement and unmount (INV5).

Evidence

  • interactionModality.test.ts, callback ref (real components rendered in a same-origin iframe):
    • Two components hold the document once, and a re-render adds or removes no listeners.
    • Unmounting the last component in that document removes its listeners.
    • A replaced node releases the old document before the new one is held.
    • Under StrictMode, exactly one hold remains.
    • The reader answers for the page before attach and for the element's document after.
    • React's cleanup plus a null call release only once, and React's cleanup forgets the node so the reader answers for the page again.
  • interactionModality.test.ts, object-ref form: it holds and releases with its last consumer, re-targets a moved node, releases on null detach, holds one under StrictMode, and keeps a page element's document-lifetime listeners.
  • useFocusReturnVisibility.test.ts: the returned-focus ring follows the trigger's own document, not the page's.
  • MultiSelector.test.tsx: with a caller-rendered trigger in a same-origin iframe, after a click on the page and a key in the iframe, the highlighted action cell takes the ring. Fails without the fix.
  • PanelSearchInput.secondDocument.test.tsx (non-Dialog reader in a real iframe): rings after keyboard input in the iframe even when the page's last input was a pointer, and not the other way round. Both fail on main.
  • focusReturn.test.ts: focusForLastInput follows the element's own document. Fails on main.
  • fix(Dialog): closing returns focus to the opener in a second document #8381's Dialog tests pass unchanged: the second-document cases in Dialog.test.tsx and the interactionModality tests. DialogSecondDocument.a11y.browser.spec.ts runs in CI only with the ci:run-a11y label; CodeBunny's review ran it in Chromium on this head, and its iframe and Picture-in-Picture cases pass.
  • Suites: utils, hooks, Dialog, DropdownMenu, Slider, MultiSelector, Selector, Chat, CommandPalette, Field, Layer, BottomSheet and Breadcrumbs all pass. Two a11y files time out under heavy local load, and pass on a re-run on this branch as on main.
  • tsc and ESLint are clean.

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
  • Public text and artifacts contain no internal Meta context.

Testing

pnpm vitest run packages/core/src/utils packages/core/src/hooks packages/core/src/Dialog packages/core/src/DropdownMenu packages/core/src/Slider packages/core/src/MultiSelector packages/core/src/Selector packages/core/src/Chat packages/core/src/CommandPalette packages/core/src/Field packages/core/src/Layer packages/core/src/BottomSheet packages/core/src/Breadcrumbs; tsc --noEmit; ESLint on changed files.

…wn document

useInteractionModalityTracking now takes the component's element ref: it
tracks that element's current owner document when it is not the page's
(ref-counted, re-targeted when the node moves, released on unmount) and
returns a reader for that document. Dialog's own useEffect and its
trackInteractionModalityIn import are gone; trackInteractionModalityIn is
module-private. Every reader (Slider, MultiSelector, DropdownMenu and its
submenu and sheet list, ChatComposer, CommandPaletteInput, PanelSearchInput,
useTouchTrigger, useFocusReturnVisibility, useMenuPress, BottomSheet,
BreadcrumbItem) reads its element's own document, and focusForLastInput
defaults to the element's. Listening sits behind one internal function.
On the page nothing changes.

Builds on vjeux's per-document fix for Dialog (#8381).
@vercel

vercel Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 1, 2026 1:04am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 31, 2026
@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · 5d0e500

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC
Pagination 88.3 kB (+393 B) · 15.7 kB CSS (0) —
Selector 79.8 kB (+383 B) · 15.4 kB CSS (0) —
MultiSelector · Storybook 75.8 kB (+364 B) · 15.0 kB CSS (0) 2,704 (+3)
132 more components
Component Size LoC
ChatComposer 63.6 kB (+344 B) · 11.1 kB CSS (0) —
DateInput 103.3 kB (+334 B) · 15.1 kB CSS (0) —
MoreMenu 76.0 kB (+330 B) · 15.4 kB CSS (0) —
Table 87.5 kB (+326 B) · 15.9 kB CSS (0) —
TableHeaderCell 78.5 kB (+323 B) · 15.4 kB CSS (0) —
DropdownMenu 75.7 kB (+322 B) · 15.4 kB CSS (0) —
lab/Schedule 62.8 kB (+318 B) · 3.9 kB CSS (0) —
BreadcrumbItem 69.9 kB (+317 B) · 11.4 kB CSS (0) —
lab/ChatEmojiPicker 68.3 kB (+317 B) · 773 B CSS (0) —
Slider · Storybook 50.7 kB (+316 B) · 8.8 kB CSS (0) 1,048 (−3)
lab/CommentThread 220.5 kB (+311 B) · 2.4 kB CSS (0) —
CommandPaletteInput 33.6 kB (+309 B) · 8.4 kB CSS (0) —
Popover 52.3 kB (+306 B) · 12.4 kB CSS (0) —
Markdown 148.0 kB (+305 B) · 18.2 kB CSS (0) —
DateRangeInput 72.7 kB (+300 B) · 11.5 kB CSS (0) —
lab/CommentComposer 97.5 kB (+298 B) · 1.9 kB CSS (0) —
lab/TourStep 57.6 kB (+295 B) · 590 B CSS (0) —
lab/TransferList 70.2 kB (+295 B) · 1.7 kB CSS (0) —
lab/ChatReactionBar 68.9 kB (+294 B) · 1.1 kB CSS (0) —
Carousel 53.1 kB (+287 B) · 10.5 kB CSS (0) —
DateTimeInput 114.7 kB (+286 B) · 15.6 kB CSS (0) —
lab/TransferListSelector 75.9 kB (+286 B) · 1.9 kB CSS (0) —
TableCell 78.8 kB (+279 B) · 15.7 kB CSS (0) —
lab/ListInput 78.1 kB (+278 B) · 1.6 kB CSS (0) —
BottomSheet 34.1 kB (+275 B) · 7.4 kB CSS (0) —
ContextMenu 77.6 kB (+273 B) · 15.2 kB CSS (0) —
TimeInput 91.6 kB (+273 B) · 14.1 kB CSS (0) —
richtext/RichTextEditorToolbar 216.2 kB (+258 B) · 454 B CSS (0) —
PowerSearch 172.0 kB (+255 B) · 20.3 kB CSS (0) —
lab/Comments 225.0 kB (+231 B) · 2.5 kB CSS (0) —
charts/ChartTooltip 55.5 kB (+204 B) · 20 B CSS (0) —
Tokenizer 71.8 kB (+202 B) · 16.9 kB CSS (0) —
charts/Chart 66.1 kB (+201 B) · 20 B CSS (0) —
SegmentedControl 40.0 kB (+184 B) · 6.5 kB CSS (0) —
lab/InfoTip 22.7 kB (+183 B) · 458 B CSS (0) —
charts/ChartLegend 55.5 kB (+181 B) · 20 B CSS (0) —
lab/CommentPin 49.8 kB (+180 B) · 1.2 kB CSS (0) —
ContextMenuItem 55.5 kB (+175 B) · 9.9 kB CSS (0) —
lab/MobilePowerSearch 158.4 kB (+173 B) · 1.2 kB CSS (0) —
TopNavHeading 60.5 kB (+171 B) · 10.3 kB CSS (0) —
DrawerHeader 51.5 kB (+170 B) · 11.5 kB CSS (0) —
lab/EmojiPicker 66.1 kB (+168 B) · 722 B CSS (0) —
CommandPalette 54.0 kB (+167 B) · 12.1 kB CSS (0) —
FieldLabel 39.8 kB (+166 B) · 5.9 kB CSS (0) —
TopNavMenu 60.1 kB (+165 B) · 10.5 kB CSS (0) —
AppShell 71.2 kB (+163 B) · 12.0 kB CSS (0) —
SideNavHeading 61.1 kB (+163 B) · 10.5 kB CSS (0) —
Toast 62.6 kB (+162 B) · 10.0 kB CSS (0) —
lab/MobileTokenizer 95.7 kB (+162 B) · 880 B CSS (0) —
BaseTypeahead 57.1 kB (+161 B) · 10.2 kB CSS (0) —
SideNavCollapseButton 49.2 kB (+161 B) · 9.8 kB CSS (0) —
TopNavMegaMenu 56.9 kB (+161 B) · 11.1 kB CSS (0) —
AlertDialog 59.4 kB (+160 B) · 12.7 kB CSS (0) —
DialogHeader 51.4 kB (+160 B) · 10.1 kB CSS (0) —
StatusDot 19.8 kB (+160 B) · 6.3 kB CSS (0) —
MobileNav 53.8 kB (+159 B) · 10.4 kB CSS (0) —
MobileNavToggle 48.8 kB (+159 B) · 9.7 kB CSS (0) —
TabMenu 57.8 kB (+159 B) · 10.4 kB CSS (0) —
richtext/RichTextView 253.2 kB (+159 B) · 2.3 kB CSS (0) —
ChatLayoutScrollButton 49.0 kB (+158 B) · 9.9 kB CSS (0) —
Stepper 50.0 kB (+158 B) · 9.9 kB CSS (0) —
AvatarGroupOverflow 42.1 kB (+157 B) · 6.5 kB CSS (0) —
ChatDictationButton 49.0 kB (+157 B) · 9.8 kB CSS (0) —
NavHeadingMenu 13.2 kB (+157 B) · 3.4 kB CSS (0) —
SideNav 66.9 kB (+157 B) · 10.9 kB CSS (0) —
Switch 51.0 kB (+157 B) · 9.7 kB CSS (0) —
AvatarGroup 41.1 kB (+156 B) · 6.6 kB CSS (0) —
Banner 50.3 kB (+156 B) · 10.2 kB CSS (0) —
CheckboxListItem 57.2 kB (+156 B) · 11.0 kB CSS (0) —
Field 46.0 kB (+156 B) · 8.3 kB CSS (0) —
FileInput 57.1 kB (+156 B) · 10.7 kB CSS (0) —
Lightbox 54.1 kB (+156 B) · 10.1 kB CSS (0) —
richtext/RichTextEditor 272.8 kB (+156 B) · 2.6 kB CSS (0) —
HoverCard 17.9 kB (+155 B) · 5.0 kB CSS (0) —
ListItem 56.7 kB (+155 B) · 11.0 kB CSS (0) —
TextArea 51.2 kB (+155 B) · 9.7 kB CSS (0) —
ToggleButton 46.4 kB (+154 B) · 9.5 kB CSS (0) —
Calendar 57.7 kB (+153 B) · 10.3 kB CSS (0) —
ChatComposerInput 58.2 kB (+153 B) · 10.3 kB CSS (0) —
CodeBlock 62.7 kB (+153 B) · 11.0 kB CSS (0) —
Link 46.1 kB (+153 B) · 7.9 kB CSS (0) —
SideNavItem 57.2 kB (+153 B) · 10.4 kB CSS (0) —
Typeahead 64.6 kB (+153 B) · 11.6 kB CSS (0) —
ChatComposerTokenElement 58.2 kB (+152 B) · 10.3 kB CSS (0) —
ChatLayout 51.5 kB (+151 B) · 10.2 kB CSS (0) —
ChatSendButton 48.2 kB (+151 B) · 9.4 kB CSS (0) —
ComplexSelector 59.1 kB (+151 B) · 11.0 kB CSS (0) —
LayerProvider 66.7 kB (+151 B) · 10.4 kB CSS (0) —
TextInput 56.4 kB (+151 B) · 10.8 kB CSS (0) —
TopNav 54.7 kB (+151 B) · 10.7 kB CSS (0) —
CheckboxInput 52.7 kB (+150 B) · 9.6 kB CSS (0) —
DropdownMenuSubMenu 61.0 kB (+149 B) · 9.9 kB CSS (0) —
NumberInput 58.6 kB (+149 B) · 11.0 kB CSS (0) —
Avatar 38.7 kB (+148 B) · 6.5 kB CSS (0) —
RadioListItem 55.2 kB (+148 B) · 10.0 kB CSS (0) —
Thumbnail 53.3 kB (+148 B) · 11.3 kB CSS (0) —
IconButton 45.6 kB (+147 B) · 9.4 kB CSS (0) —
Tooltip 17.5 kB (+146 B) · 5.0 kB CSS (0) —
Button 45.5 kB (+145 B) · 9.4 kB CSS (0) —
RadioList 47.4 kB (+145 B) · 8.3 kB CSS (0) —
CheckboxList 47.8 kB (+143 B) · 8.5 kB CSS (0) —
InputGroup 46.6 kB (+143 B) · 8.3 kB CSS (0) —
TableSelectionToolbar 54.7 kB (+142 B) · 11.4 kB CSS (0) —
Dialog 15.9 kB (+134 B) · 6.6 kB CSS (0) —
DropdownMenuCheckboxItem 17.6 kB (+18 B) · 5.9 kB CSS (0) —
DropdownMenuRadioItem 17.7 kB (+16 B) · 5.9 kB CSS (0) —
DropdownMenuItem 16.9 kB (+13 B) · 5.5 kB CSS (0) —
BottomSheetSwitcher 14.9 kB (+9 B) · 3.4 kB CSS (0) —
Timer 9.5 kB (+1 B) · 6.9 kB CSS (0) —
Breadcrumbs 22.9 kB (0) · 3.4 kB CSS (0) —
ChatComposerDrawer 24.5 kB (0) · 4.7 kB CSS (0) —
ChatMessage 23.6 kB (0) · 3.7 kB CSS (0) —
ChatMessageBubble 7.1 kB (0) · 3.7 kB CSS (0) —
ChatMessageList 29.8 kB (0) · 7.9 kB CSS (0) —
ChatMessageMetadata 26.5 kB (0) · 4.1 kB CSS (0) —
ChatSystemMessage 6.8 kB (0) · 3.8 kB CSS (0) —
ChatTokenizedText 7.1 kB (0) · 4.0 kB CSS (0) —
CommandPaletteEmpty 6.2 kB (0) · 3.5 kB CSS (0) —
CommandPaletteFooter 23.8 kB (0) · 3.7 kB CSS (0) —
CommandPaletteGroup 3.5 kB (0) · 3.5 kB CSS (0) —
CommandPaletteItem 9.5 kB (0) · 3.6 kB CSS (0) —
CommandPaletteList 22.9 kB (0) · 3.4 kB CSS (0) —
DropdownMenuDivider 5.2 kB (0) · 548 B CSS (0) —
DropdownMenuGroup 6.2 kB (0) · 3.4 kB CSS (0) —
DropdownMenuRadioGroup 6.1 kB (0) · 3.3 kB CSS (0) —
Heading 7.9 kB (0) · 6.0 kB CSS (0) —
ProgressBar 7.9 kB (0) · 6.9 kB CSS (0) —
SelectorOption 15.9 kB (0) · 5.4 kB CSS (0) —
Spinner 25.5 kB (0) · 6.7 kB CSS (0) —
Text 7.4 kB (0) · 6.0 kB CSS (0) —
Timestamp 28.2 kB (0) · 9.9 kB CSS (0) —
ChatToolCalls 32.8 kB (−1 B) · 9.2 kB CSS (0) —

Package size: @astryxdesign/core 6.52 MB packed (+3.8 kB); no new dependencies.

Not run on this PR: add ci:run-visual, ci:run-a11y or ci:run-rtl to run them.


Generated by PR Enrichment workflow | View full report

@cixzhang
cixzhang marked this pull request as ready for review October 31, 2026 15:43
@astracat-bot

astracat-bot Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 1, 2026, 1:22 AM 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.

Changes requested: the per-document tracking works for the readers it moves, and the new iframe and focus-return tests fail on main and pass here. Two things need fixing before approval:

  • The changeset says MultiSelector's returned focus and every reader now follow their own document, but useSelectorPresentation still calls useFocusReturnVisibility() without a ref (line 87); pass triggerRef there with a second-document test, or narrow the changeset.
  • The second-document hold is taken in a dependency-less effect in useInteractionModalityTracking; a callback ref that tracks on attach and releases in its cleanup would give attach, detach and replacement one owner (architecture:react-component-runtime INV3 and INV6), with a StrictMode replay case to cover it.

[Automated review]

Comment thread .changeset/modality-one-call.md Outdated
'@astryxdesign/core': patch
---

[fix] Components rendered into a same-origin iframe or a Document Picture-in-Picture window now follow the last input in their own document: keyboard focus rings, keyboard highlights and returned focus (Slider, MultiSelector, DropdownMenu, ChatComposer, CommandPalette, panel search fields, menus, BottomSheet, Dialog) no longer read the page's last input. Each component tracks its document through its one modality call, with no effect of its own. On the page, behavior is unchanged. vjeux wrote the original per-document fix, for Dialog (#8381); this extends it to every reader.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Selector's and MultiSelector's returned focus still reads the page: useSelectorPresentation.ts:87 calls useFocusReturnVisibility() without a ref, so "returned focus (… MultiSelector …)" and "every reader" overstate this change. Passing triggerRef there (with a second-document test) would make the note true; otherwise narrow it.

Comment on lines +182 to +198
useEffect(() => {
const ownerDocument = elementRef?.current?.ownerDocument ?? null;
const secondDocument =
ownerDocument != null &&
(typeof document === 'undefined' || ownerDocument !== document)
? ownerDocument
: null;
if (secondDocument === trackedDocumentRef.current) {
return;
}
releaseRef.current?.();
releaseRef.current =
secondDocument == null
? null
: trackInteractionModalityIn(secondDocument);
trackedDocumentRef.current = secondDocument;
});

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 takes the per-document hold in an effect with no dependency list and no cleanup of its own (release lives in the unmount-only effect below), and it sees a node only on this component's commits. architecture:react-component-runtime INV6 gives attach, null detach and replacement to a callback ref; returning one from this hook, acquiring on attach and releasing in its cleanup, would remove both effects and satisfy INV3's "cleanup MUST release that resource".

… trigger's document

The hook's hold on a second document is one idempotent owner that handles
attachment, null detachment and replacement, and unmount releases it; a
StrictMode replay leaves one hold. useSelectorPresentation passes its trigger
to useFocusReturnVisibility.

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

Changes requested: keyboard rings and highlights now follow the iframe's own input, and the new iframe and focus-return tests fail on main and pass here. Two things remain before approval:

  • In a same-origin iframe, DropdownMenu still rings its trigger after a pointer pick, exactly as on main, because its hide path compares the page's document.activeElement (the iframe element) and returns before the new per-document focus return (DropdownMenu.tsx:1032; the same read sits in useSelectorPresentation.ts:99, useMenuHover.ts:281 and BottomSheet.tsx:537), so either read the trigger's own document there with an iframe test or narrow the changeset's "returned focus" claim.
  • The second-document hold in useInteractionModalityTracking is still acquired in a dependency-less effect with no cleanup, so it follows only the caller's commits (architecture:react-component-runtime INV3, INV6); a callback ref that holds on attach and releases in its cleanup would replace both effects.

[Automated review]

Comment on lines +189 to +192
useEffect(() => {
hold(elementRef?.current?.ownerDocument ?? null);
});
useEffect(() => () => hold(null), [hold]);

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 still acquires the second-document hold in an effect with no cleanup of its own (release is the unmount-only effect on the next line), and it only sees a node on this component's commits: a node another component attaches in its own commit stays untracked until this caller re-renders. architecture:react-component-runtime INV3 asks the acquiring cleanup to release, and INV6 gives attach, null detach and replacement to a callback ref; returning one from this hook (hold on attach, release in its cleanup) and passing it through the callback refs at Dialog.tsx:516, DropdownMenu.tsx:1680, MultiSelector.tsx:1192 and PanelSearchInput.tsx:222 would remove both effects.

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

Changes requested: keyboard rings and highlights in a same-origin iframe now follow the iframe's own last input, and the new iframe and focus-return tests fail on main and pass here. One thing remains: the second-document hold in useInteractionModalityTracking is acquired in an Effect with no cleanup and re-targeted only on the caller's commits (interactionModality.ts:189-192), which architecture:react-component-runtime INV3 and INV6 do not allow. Having the hook return a callback ref that holds the node's document on attach and releases it in its cleanup, passed through the components' existing refs, would replace both Effects; only the public useMenuPress and useTouchTrigger, which receive a consumer's object ref, still need an Effect, and there its own cleanup should release the hold.

[Automated review]

… ref

useInteractionModalityTracking() now returns {ref, readModality}: a component
attaches the callback ref to its element, which holds the element's second
document on attach and releases it on detach or replacement (React's ref
cleanup or a null call, never twice), so an unchanged commit touches no
listeners. Only useMenuPress and useTouchTrigger, which receive a consumer's
object ref, use useInteractionModalityTrackingFor(ref), whose Effect cleanup
releases the hold; their public signatures are unchanged.
useFocusReturnVisibility takes the component's tracking, and DropdownMenu,
Selector and MultiSelector attach it to their triggers.

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

Changes requested: components in a same-origin iframe now read their own document's last input, and the new iframe and focus-return tests fail on main and pass here. Two things remain:

  • A bottom-sheet DropdownMenu attaches its tracking ref to the sheet's action list, which unmounts on close, so in an iframe a keyboard user who tabs back to the trigger after a tapped pick gets no focus ring at all; attaching modality.ref to the sheet trigger, as the popover does, fixes it (DropdownMenu.tsx:634).
  • useTouchTrigger is internal and both of its callers already own the trigger through a callback ref (useTooltip.tsx:479, useHoverCard.tsx:528), so they can make the one useInteractionModalityTracking() call and pass readModality down, leaving the per-commit Effect in useInteractionModalityTrackingFor (interactionModality.ts:243) to the public useMenuPress alone.

[Automated review]

const actionListRef = useRef<HTMLDivElement>(null);
const openModalityRef = useRef<'keyboard' | 'pointer'>('pointer');
const modality = useInteractionModalityTracking();
const actionListComposedRef = useMergedRefs(actionListRef, modality.ref);

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 ref sits on the sheet's action list, which exists only while the sheet is open (MenuBottomSheet renders nothing at rest), but readModality also serves the trigger's focus (onFocusReturnTargetFocus at lines 812 and 838). After a tapped pick closes the sheet in an iframe, that document is released and its keys are no longer heard, so a keyboard user who tabs back to the trigger keeps focusOutlineStyles.suppressed: no ring, whatever the page's last input (in Chromium, main drew a 2px outline there when the page had seen no pointer input). Attaching modality.ref to the sheet trigger (triggerRef above and sheetTriggerRef), as the popover does, keeps the trigger's document tracked while the menu is mounted; with that change an iframe probe rings and the DropdownMenu suites pass.

Comment on lines +243 to +252
useEffect(() => {
const ownerDocument = elementRef.current?.ownerDocument;
if (
ownerDocument == null ||
(typeof document !== 'undefined' && ownerDocument === document)
) {
return undefined;
}
return trackInteractionModalityIn(ownerDocument);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

useTouchTrigger is the other caller of this per-commit Effect, but it is internal and both of its callers own the trigger node through a callback ref (interactionRef in useTooltip.tsx:479 and useHoverCard.tsx:528), so architecture:react-component-runtime INV6 puts the hold there: they can make the one useInteractionModalityTracking() call, call its ref from interactionRef, and pass readModality down. That leaves this form to the public useMenuPress alone.

…; the reader forgets a detached node

The sheet variant attached its tracking to the action list, which unmounts on
close, so focus returning to the trigger in a second document read a released
document. It now sits on the trigger, as in the popover variant. The tracking
ref's cleanup also forgets its node, so the reader never answers from a
document whose listeners are gone, and DropdownMenu's own trigger callback
calls it without returning the cleanup, so React keeps calling the popover's
ref with null.

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

Changes requested: keyboard rings in a same-origin iframe now follow the iframe's own input for the components this moves (in Chromium the Slider thumb and MultiSelector's action cell ring after keyboard input inside the iframe), and both earlier points are resolved. One thing remains: MultiSelector's renderTrigger branch builds its own mergeRefs without triggerModality.ref (MultiSelector.tsx:2535-2547), so with a caller-rendered trigger the action-cell ring still follows the page's last input, although the changeset lists MultiSelector as fixed. Passing triggerModality.ref to that mergeRefs fixes it with MultiSelector's tests still passing, and an iframe case for that mode would pin it.

[Automated review]

const readModality = triggerModality.readModality;
const triggerButtonRef = useMergedRefs<HTMLElement>(
triggerRef,
triggerModality.ref,

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 ref reaches only the built-in trigger (triggerButtonRef, attached at :2663). The renderTrigger branch builds its own mergeRefs at :2535-2547 without triggerModality.ref, so with a caller-rendered trigger readModality reads getInteractionModality(null), the page: in a same-origin iframe, arrowing onto an option's action cell after a click on the page draws no ring. Adding triggerModality.ref to that mergeRefs makes it ring, and MultiSelector's tests still pass.

…acking too

The renderTrigger branch built its own merged ref without the tracking ref,
so in a second document its action-cell ring still followed the page's
input.

@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 approve: components rendered into a same-origin iframe or a Document Picture-in-Picture window now read their own document's last input, and the caller-rendered MultiSelector trigger from the last round is fixed and pinned by an iframe test that fails without it. In Chromium, a Slider thumb and a panel search field inside an iframe now take their keyboard ring after a Tab there even when the page's last input was a click, and Dialog's second-document browser spec still passes in the iframe and Picture-in-Picture cases. One note on the description: DialogSecondDocument.a11y.browser.spec.ts runs in CI only with the ci:run-a11y label, so it did not run on this push.

[Automated review]

@cixzhang
cixzhang merged commit b56b6d4 into main Nov 1, 2026
53 checks passed
cixzhang added a commit that referenced this pull request Nov 1, 2026
…dalityTrackingFor

#9148 added the document-scoped hook that useTouchTrigger and useMenuPress
call; a change to those calls now routes interaction-modality INV1-INV3
and react-component-runtime INV16 like the other modality names.
@github-actions
github-actions Bot deleted the fix/modality-tracking-one-call branch November 1, 2026 06:05

This branch was successfully deployed

1 active deployment
Preview — 5d0e5005 Deployed Nov 1, 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant