Skip to content

perf(core): a touch hit-area pass keeps the page's interactive elements until the DOM changes - #10242

Merged
cixzhang merged 2 commits into
mainfrom
perf/hit-area-candidate-list
Nov 6, 2026
Merged

cixzhang merged 2 commits into
mainfrom
perf/hit-area-candidate-list

Conversation

@cixzhang

@cixzhang cixzhang commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Maintainer impact

Under a coarse primary pointer, every control that grows its touch target toward 44 px (CheckboxInput, RadioList, Switch, List rows, InfoTip, action groups) is measured in one shared pass per frame. While a finger scrolls a page, each control that enters the viewport schedules a pass. Each pass ran document.querySelectorAll with the full interactive selector over the whole page and walked each match up to its watched owner, before it read any box. On a page with 240 interactive elements, at 4× CPU slowdown, the selector match and owner walk took about two thirds of the time spent finding and reading the candidates.

Intended invariant

  • architecture:react-component-runtime INV18 (layout is read as little as possible; pending in docs(react-component-runtime): read layout as little as possible #10219) and INV16 (a shared concern is extended in its owner: the change uses sharedMutationObserver, extended with a flush).
  • spec:AST-094 FR42: a touch target grows toward 44 × 44 px, up to halfway to a neighbouring target, and never overlaps a neighbouring interactive element's own target. Unchanged: every neighbour's box is still read in every pass.

No behavior change: the same elements are neighbours, their boxes are read in the same passes, and the insets are the same.

Change

  • The pass keeps the list of interactive elements (after the watched-owner mapping) until something can change it: an element added, removed or moved anywhere in the document, or a change to an attribute the selector reads (href, type, for, contenteditable, role, tabindex), observed through the shared mutation observer; or a control that starts or stops its watch, which changes the owner mapping.
  • A mutation is delivered in a microtask, so a pass in the same task as the mutation would not see it yet. sharedMutationObserver gets flushMutations(options), which delivers the pending records at once; the pass calls it before it uses the kept list.
  • Without MutationObserver, nothing is kept.
  • Each pass still reads every listed element's box (getBoundingClientRect). Reading only nearby boxes would change which neighbours count while one is clipped or off-screen, so it is not part of this change.

Evidence

  • Failure before the change: the new hitArea.test.ts case "matches the page against its selector once, and again only after a mutation can change what it matches" fails on main (the selector runs in every pass). The second new case ("counts a control watched after the last pass by its own box") passes on main and pins the owner mapping that the kept list must follow.

  • Success after the change, measured in Chromium (Playwright's headless Chromium, phone viewport, coarse pointer, 4× CPU slowdown) with the shipped hitArea from main and from this branch: a 1,366-node page with 240 interactive elements and 80 watched controls, scrolled for 250 frames (20 passes):

    mean pass time before after
    run 1 2.31 ms 0.65 ms
    run 2 3.23 ms 0.95 ms
    run 3 3.53 ms 1.26 ms
  • Product/runtime behavior verified unchanged: both implementations watched the same 80 controls through the same scroll, with a button inserted beside a watched control, a role added to a span, a link removed and a window resize on the way. Both delivered the same 33 inset changes with the same values, and ended with the same insets (6 of 6 runs).

  • Existing hitArea.test.ts, touchHitArea.test.tsx, ListItem.hitArea.test.tsx and touchTarget.test.tsx pass unchanged.

  • Resources (spec:AST-094 FR24): a new case counts the page observer: none under a fine pointer, one with two watches, released when the pointer turns fine and after the last watch stops. Removing the release from stopObserving fails it.

Scope

  • No intended public API, product behavior, visual direction, or policy change.
  • Product changes discovered during the work were removed or split.
  • Public text and artifacts contain no internal Meta context.

Testing

  • vitest run --project ui packages/core/src/utils/hitArea.test.ts packages/core/src/utils/sharedMutationObserver.test.ts packages/core/src/utils/touchHitArea.test.tsx packages/core/src/List/ListItem.hitArea.test.tsx packages/core/src/Indicator/touchTarget.test.tsx (81 tests at the first head; hitArea + sharedMutationObserver again at this head, 54 tests)
  • pnpm review:pre and pnpm specs:for --diff origin/main: AST-094 FR42 keeps its wording.
  • Chromium measurement as above.

…ts until the DOM changes

Each hit-area pass matched the whole page against the interactive selector
and walked every match up to its watched owner before it read any box. The
pass now keeps that list until a mutation can change it (an element added,
removed or moved, or an attribute the selector reads) or a control starts or
stops its watch. A shared mutation observer reports the changes, and the new
flushMutations delivers a mutation of the same task before the list is used.
Every neighbour's box is still read in every pass, so the insets are the same.
@vercel

vercel Bot commented Nov 6, 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 6, 2026 9:44am UTC

Request Review

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

astracat-bot Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 6, 2026, 9:47 AM UTC

@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · 73c6405

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC
richtext/RichTextEditor 290.8 kB (+248 B) · 2.6 kB CSS (0) —
MultiSelector 88.4 kB (+239 B) · 16.2 kB CSS (0) —
lab/Schedule 98.9 kB (+227 B) · 3.9 kB CSS (0) —
107 more components
Component Size LoC
lab/CommentThread 276.1 kB (+224 B) · 2.4 kB CSS (0) —
ContextMenu 90.3 kB (+223 B) · 16.5 kB CSS (0) —
Table 99.7 kB (+213 B) · 17.2 kB CSS (0) —
DateTimeInput 193.5 kB (+209 B) · 18.0 kB CSS (0) —
RadioListItem 63.1 kB (+209 B) · 9.7 kB CSS (0) —
lab/Comments 281.9 kB (+209 B) · 2.5 kB CSS (0) —
charts/Chart 173.9 kB (+208 B) · 494 B CSS (0) —
TableCell 91.5 kB (+203 B) · 16.9 kB CSS (0) —
Markdown 161.5 kB (+199 B) · 19.5 kB CSS (0) —
ListItem 65.3 kB (+195 B) · 10.9 kB CSS (0) —
Switch 94.9 kB (+195 B) · 9.5 kB CSS (0) —
TableHeaderCell 91.1 kB (+195 B) · 16.7 kB CSS (0) —
CheckboxListItem 66.3 kB (+193 B) · 10.9 kB CSS (0) —
InfoTip 43.2 kB (+193 B) · 5.3 kB CSS (0) —
charts/ChartTooltip 151.3 kB (+189 B) · 423 B CSS (0) —
richtext/RichTextView 271.1 kB (+188 B) · 2.3 kB CSS (0) —
CheckboxInput 60.1 kB (+185 B) · 9.0 kB CSS (0) —
SegmentedControlItem 16.2 kB (+180 B) · 4.3 kB CSS (0) —
ButtonGroup 40.0 kB (+161 B) · 3.7 kB CSS (0) —
DropdownMenu 85.8 kB (+67 B) · 16.6 kB CSS (0) —
charts/ChartLegend 35.7 kB (+59 B) · 224 B CSS (0) —
PowerSearch 264.2 kB (+56 B) · 23.1 kB CSS (0) —
MoreMenu 86.0 kB (+54 B) · 16.6 kB CSS (0) —
Slider 97.0 kB (+26 B) · 8.3 kB CSS (0) —
DateRangeInput 81.3 kB (+18 B) · 11.8 kB CSS (0) —
lab/ChatEmojiPicker 74.6 kB (+16 B) · 773 B CSS (0) —
Toast 89.0 kB (+13 B) · 9.6 kB CSS (0) —
lab/ChatReactionBar 75.1 kB (+13 B) · 1.1 kB CSS (0) —
lab/CommentComposer 115.3 kB (+13 B) · 1.9 kB CSS (0) —
ChatComposer 68.2 kB (+12 B) · 11.2 kB CSS (0) —
DropdownMenuSubMenu 64.5 kB (+12 B) · 9.4 kB CSS (0) —
Link 48.5 kB (+12 B) · 6.9 kB CSS (0) —
SideNav 81.5 kB (+12 B) · 10.7 kB CSS (0) —
Button 51.6 kB (+11 B) · 9.3 kB CSS (0) —
ChatMessageList 23.9 kB (+11 B) · 6.9 kB CSS (0) —
DateInput 167.1 kB (+11 B) · 16.1 kB CSS (0) —
MobileNav 61.4 kB (+11 B) · 9.9 kB CSS (0) —
MobileNavToggle 51.9 kB (+11 B) · 9.3 kB CSS (0) —
Timestamp 23.0 kB (+11 B) · 9.6 kB CSS (0) —
ToggleButton 52.4 kB (+11 B) · 9.4 kB CSS (0) —
lab/TransferListSelector 92.3 kB (+11 B) · 1.9 kB CSS (0) —
Banner 53.5 kB (+10 B) · 9.8 kB CSS (0) —
ChatDictationButton 52.1 kB (+10 B) · 9.4 kB CSS (0) —
ChatSendButton 52.0 kB (+10 B) · 9.3 kB CSS (0) —
CheckboxList 50.0 kB (+10 B) · 7.5 kB CSS (0) —
DrawerHeader 54.6 kB (+10 B) · 12.2 kB CSS (0) —
FileInput 60.2 kB (+10 B) · 10.3 kB CSS (0) —
NumberInput 62.7 kB (+10 B) · 10.7 kB CSS (0) —
Pagination 98.8 kB (+10 B) · 17.0 kB CSS (0) —
Popover 60.7 kB (+10 B) · 13.4 kB CSS (0) —
Selector 88.8 kB (+10 B) · 16.6 kB CSS (0) —
SideNavCollapseButton 52.4 kB (+10 B) · 9.4 kB CSS (0) —
Stepper 53.3 kB (+10 B) · 9.5 kB CSS (0) —
TextArea 53.8 kB (+10 B) · 8.9 kB CSS (0) —
Timer 9.6 kB (+10 B) · 5.9 kB CSS (0) —
lab/EmojiPicker 77.6 kB (+10 B) · 751 B CSS (0) —
AppShell 81.5 kB (+9 B) · 12.8 kB CSS (0) —
Calendar 62.4 kB (+9 B) · 9.9 kB CSS (0) —
Carousel 58.5 kB (+9 B) · 10.3 kB CSS (0) —
ChatComposerTokenElement 66.2 kB (+9 B) · 10.6 kB CSS (0) —
ChatLayout 54.9 kB (+9 B) · 9.8 kB CSS (0) —
ChatToolCalls 27.7 kB (+9 B) · 8.3 kB CSS (0) —
CodeBlock 66.1 kB (+9 B) · 10.6 kB CSS (0) —
DialogHeader 54.4 kB (+9 B) · 10.8 kB CSS (0) —
HoverCard 24.3 kB (+9 B) · 4.3 kB CSS (0) —
SideNavHeading 66.6 kB (+9 B) · 10.6 kB CSS (0) —
Spinner 20.3 kB (+9 B) · 6.1 kB CSS (0) —
Thumbnail 55.9 kB (+9 B) · 10.9 kB CSS (0) —
Typeahead 69.9 kB (+9 B) · 11.8 kB CSS (0) —
lab/SelectableCardGroup 56.1 kB (+9 B) · 162 B CSS (0) —
AlertDialog 95.5 kB (+8 B) · 13.7 kB CSS (0) —
BaseTypeahead 61.9 kB (+8 B) · 10.3 kB CSS (0) —
ChatComposerInput 66.2 kB (+8 B) · 10.6 kB CSS (0) —
ChatLayoutScrollButton 52.1 kB (+8 B) · 9.5 kB CSS (0) —
CommandPaletteInput 27.9 kB (+8 B) · 7.5 kB CSS (0) —
ComplexSelector 62.4 kB (+8 B) · 11.0 kB CSS (0) —
Drawer 44.5 kB (+8 B) · 8.2 kB CSS (0) —
Field 46.8 kB (+8 B) · 6.7 kB CSS (0) —
InputGroup 48.5 kB (+8 B) · 7.3 kB CSS (0) —
RadioList 48.2 kB (+8 B) · 6.8 kB CSS (0) —
SideNavItem 61.5 kB (+8 B) · 10.4 kB CSS (0) —
StackItem 16.1 kB (+8 B) · 219 B CSS (0) —
TableSelectionToolbar 61.1 kB (+8 B) · 12.4 kB CSS (0) —
TopNavHeading 66.0 kB (+8 B) · 10.4 kB CSS (0) —
TopNavMegaMenu 60.9 kB (+8 B) · 11.2 kB CSS (0) —
TopNavMenu 65.8 kB (+8 B) · 10.5 kB CSS (0) —
lab/DiffViewer 77.9 kB (+8 B) · 2.1 kB CSS (0) —
lab/TourStep 66.8 kB (+8 B) · 590 B CSS (0) —
richtext/RichTextEditorToolbar 239.7 kB (+8 B) · 454 B CSS (0) —
BottomSheet 57.0 kB (+7 B) · 7.5 kB CSS (0) —
Dialog 22.4 kB (+7 B) · 6.7 kB CSS (0) —
LayoutContent 17.4 kB (+7 B) · 1.7 kB CSS (0) —
LayoutPanel 20.5 kB (+7 B) · 4.9 kB CSS (0) —
Lightbox 59.7 kB (+7 B) · 9.7 kB CSS (0) —
Text 7.5 kB (+7 B) · 5.3 kB CSS (0) —
TimeInput 144.0 kB (+7 B) · 15.1 kB CSS (0) —
BreadcrumbItem 75.7 kB (+6 B) · 11.5 kB CSS (0) —
CommandPalette 59.3 kB (+6 B) · 12.4 kB CSS (0) —
TopNav 63.1 kB (+6 B) · 10.2 kB CSS (0) —
lab/TransferList 85.3 kB (+6 B) · 1.7 kB CSS (0) —
TabMenu 62.9 kB (+5 B) · 10.4 kB CSS (0) —
Tokenizer 80.3 kB (+5 B) · 17.8 kB CSS (0) —
Heading 8.0 kB (+4 B) · 5.3 kB CSS (0) —
TextInput 59.3 kB (+4 B) · 10.5 kB CSS (0) —
IconButton 51.6 kB (+3 B) · 9.3 kB CSS (0) —
SegmentedControl 89.0 kB (0) · 6.8 kB CSS (0) —
lab/ListInput 90.9 kB (−1 B) · 1.6 kB CSS (0) —

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

API changes: none in this pull request

Committed API report behind this build: cli 19 rows, core 21 rows; run pnpm api:report.

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

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested: the cached candidate list looks correct, but one test the touch spec requires is missing.

Blocking

  • Add a resource test for the new document-wide mutation subscription, as spec:AST-094 FR24 requires: removing stopWatchingInteractive() from stopObserving (packages/core/src/utils/hitArea.ts:895) leaves every hit-area test passing, so one case beside "removes its document listener when the last watch stops" should count the page observer under a fine pointer (none), with two watches (one), after the pointer turns fine (released) and after the last stop (released).

Non-blocking

  • Nothing pins dropping the kept list when a watch stops (hitArea.ts:1037); a step in the new group case that stops the group's watch and expects its input to count again would cover it.
  • Two new comments are long or passive; see the inline suggestions.

CI has not run on this head yet because the pull request is a draft.

[Automated review]

resize = null;
scopeTimers.forEach(timer => clearTimeout(timer));
scopeTimers.clear();
stopWatchingInteractive();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Removing this call leaves all 81 hit-area tests passing, so nothing guards the release of the document-wide subscription. spec:AST-094 FR24 requires that nothing touch-related stays active under a fine pointer or after the last instance unmounts, verified by resource tests counting observers. Please add a case beside "removes its document listener when the last watch stops" that counts the page observer: none under a fine pointer, one with two watches, released when the pointer turns fine, released after the last stop.

Comment thread packages/core/src/utils/hitArea.ts Outdated
Comment on lines +370 to +372
* The page's interactive elements, as a pass last found them. They are kept
* until a mutation or a change of the watched set can change them, so a pass
* does not match the selector against the whole page again.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style): the second sentence is 29 words and passive. A shorter form that keeps the reason:

 * The page's interactive elements, as the last pass found them. A pass keeps
 * them until a mutation or a watch change can change them.

Comment on lines +142 to +143
* but has not yet delivered. A mutation is delivered in a microtask, so a
* read in the same task as the mutation calls this first to see it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment style): "A mutation is delivered in a microtask" is passive and the sentence joins two facts. Suggested: "The observer delivers a mutation in a microtask. A read in the same task calls this first to see it."

@cixzhang
cixzhang marked this pull request as ready for review November 6, 2026 09:13
@cixzhang
cixzhang marked this pull request as draft November 6, 2026 09:13
…-094 FR24); a stopped watch drops the kept list

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI has not run on this head yet.

The kept candidate list delivers the same touch targets as before, and the requested resource test is in place: the new case fails when stopObserving stops releasing the page observer, and the group case now also covers dropping the kept list when a watch stops.

CI skips every job while the pull request is a draft; it runs once the pull request is marked ready for review.

[Automated review]

@cixzhang
cixzhang marked this pull request as ready for review November 6, 2026 10:13
@cixzhang
cixzhang merged commit b4fc684 into main Nov 6, 2026
115 checks passed
@github-actions
github-actions Bot deleted the perf/hit-area-candidate-list branch November 7, 2026 06:03

This branch was successfully deployed

1 active deployment
Preview — 73c6405c Deployed Nov 6, 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