Repository navigation
perf(core): a touch hit-area pass keeps the page's interactive elements until the DOM changes - #10242
Conversation
…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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
PR Analysis Report🔎 Preview ·
|
| 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
There was a problem hiding this comment.
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-094FR24 requires: removingstopWatchingInteractive()fromstopObserving(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(); |
There was a problem hiding this comment.
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.
| * 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. |
There was a problem hiding this comment.
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.| * 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. |
There was a problem hiding this comment.
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."
…-094 FR24); a stopped watch drops the kept list
There was a problem hiding this comment.
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]
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.querySelectorAllwith 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-runtimeINV18 (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 usessharedMutationObserver, extended with a flush).spec:AST-094FR42: 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
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.sharedMutationObservergetsflushMutations(options), which delivers the pending records at once; the pass calls it before it uses the kept list.MutationObserver, nothing is kept.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.tscase "matches the page against its selector once, and again only after a mutation can change what it matches" fails onmain(the selector runs in every pass). The second new case ("counts a control watched after the last pass by its own box") passes onmainand 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
hitAreafrommainand from this branch: a 1,366-node page with 240 interactive elements and 80 watched controls, scrolled for 250 frames (20 passes):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
roleadded 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.tsxandtouchTarget.test.tsxpass unchanged.Resources (
spec:AST-094FR24): 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 fromstopObservingfails it.Scope
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+sharedMutationObserveragain at this head, 54 tests)pnpm review:preandpnpm specs:for --diff origin/main: AST-094 FR42 keeps its wording.