Skip to content

fix(core): on iOS a touch reaches what is drawn on a moving element - #8293

Closed
vjeux wants to merge 1416 commits into
meta:mainfrom
vjeux:uikit/f2-presented-hit-testing
Closed

vjeux wants to merge 1416 commits into
meta:mainfrom
vjeux:uikit/f2-presented-hit-testing

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8320. Review only this pull request's change: https://github.com/vjeux/astryx/compare/cfaad0342b..c1186e4f63

Why

On an iPhone, a tap on a sheet that is still moving lands where the sheet started. After an upward flick, a tap aimed at the sheet's Done button hits the scrim behind it and closes the sheet; a tap on the spot Done just left presses Done.

What led to it

This came up in the UIKit-parity work on Astryx's touch components, which are meant to feel as they do on iOS. iOS WebKit keeps the transform it resolved when an animation moved to the compositor: hit testing, elementFromPoint and getBoundingClientRect all stay at the starting position until a style write. Chromium and desktop WebKit hit what is drawn, so the tests passed. UIKit hit-tests an interruptible animation where it is drawn.

Options and tradeoffs

  • Animate layout instead of a compositor transform: hit testing would be right, but every frame would cost layout, and motion would stutter under load.
  • Write the animated style on every frame: correct, but it puts main-thread work on every frame of every animation.
  • At touch-down only, on iOS WebKit only, resolve the elements the animator reports as moving (one custom-property write each), then ask the engine what is under the finger.

Why this choice

The third costs one write per touch, only on the engine that is wrong, and only while something moves. A tap that lands on the drawn element clicks it once: the engine's own click is cancelled and a late duplicate is dropped. Blocking touchmove stays registered while a claiming element moves, so a drag that starts on the drawn sheet still claims (spec:AST-094 FR14, FR19).

How to check it

  1. In iOS Safari, open a bottom sheet with a Done button and flick it upward.
  2. While it slides, tap Done. Expected: Done clicks once, and the sheet does not close through the scrim.
  3. Tap the spot Done just left. Expected: the scrim gets it, not Done.
  4. Off iOS, or with nothing moving, nothing changes: touches keep the engine's own target.

Evidence

  • iOS 26.2 Safari, 21 taps per build, before and after: Before, taps on the moving Done miss or close the sheet; after, Done clicks once
  • Unit cases mimic iOS (the drawn sheet gets the finger, its tap clicks once, the old spot goes to the scrim) and fail before the change. Chromium and WebKit check the reported offset against what is drawn within a frame.
  • Limits: translation only; a scaled or rotated element, and CSS transitions the animator did not start, keep the engine's answer.

@vercel

vercel Bot commented Oct 24, 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 Oct 27, 2026 2:47pm UTC

Request Review

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

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 27, 2026, 3:05 PM 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.

Approval is on hold for an owner decision: draft spec:AST-094 has no claim that states presented hit testing, so an engineering owner needs to provide or identify the governing spec for it and settle the record's conflicts with this branch (theme-tunable touch motion against its FR4 and DEC-1, scrollHandoff against its FR14, and its proposed amendment to AST-025 FR14). Any engineering owner listed in .github/ENGOWNERS, the author included if listed, records that decision by commenting /approve-spec <full head SHA> on a head whose record states it. Whatever that decision, three changes are needed:

  • Land or retarget onto the predecessor stack so this pull request carries only its own 1,069-line commit instead of 29,253 authored lines.
  • On iOS, a drag that starts on a retargeted contact has no cancelable touchmove listener Safari honors (packages/core/src/gestures/GestureHost.ts:753, measured in the branch's own packages/core/src/gestures/README.md), so keep a document-level listener while something moves or limit retargeting to taps.
  • .changeset/touch-presentation.md says an iPad mini in portrait keeps the popover although the old query gave it the bottom sheet, and it ships this change to released presentation="adaptive" behavior as a patch [feat] with no compatibility classification or migration (spec:AST-017 FR1, FR3).

[Automated review]

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/AlertDialog 56.9KB 57.2KB +294B 12.4KB 12.4KB 0B
core/AppShell 57.9KB 58.1KB +242B 11.6KB 11.6KB 0B
core/AvatarGroupOverflow 39.9KB 40.2KB +303B 6.4KB 6.4KB 0B
core/Banner 47.8KB 48.1KB +289B 9.9KB 9.9KB 0B
core/BaseTypeahead 54.3KB 54.6KB +251B 9.9KB 9.9KB 0B
core/BreadcrumbItem 66.4KB 66.6KB +297B 11.0KB 11.0KB 0B
core/Button 43.3KB 43.5KB +273B 9.2KB 9.2KB 0B
core/Calendar 54.9KB 55.2KB +290B 10.0KB 10.0KB 0B
core/Carousel 50.2KB 50.5KB +316B 10.2KB 10.2KB 0B
core/ChatComposer 60.6KB 60.9KB +270B 10.8KB 10.8KB 0B
core/ChatComposerInput 55.7KB 56.0KB +264B 10.1KB 10.1KB 0B
core/ChatComposerTokenElement 55.7KB 56.0KB +264B 10.1KB 10.1KB 0B
core/ChatDictationButton 46.5KB 46.8KB +264B 9.5KB 9.5KB 0B
core/ChatLayout 48.8KB 49.1KB +273B 9.9KB 9.9KB 0B
core/ChatLayoutScrollButton 46.4KB 46.6KB +255B 9.6KB 9.6KB 0B
core/ChatSendButton 45.8KB 46.1KB +265B 9.2KB 9.2KB 0B
core/CheckboxInput 49.9KB 50.2KB +273B 9.3KB 9.3KB 0B
core/CheckboxListItem 54.0KB 54.3KB +244B 10.5KB 10.5KB 0B
core/ClickableCard 12.8KB 13.1KB +289B 6.9KB 6.9KB 0B
core/CodeBlock 60.5KB 60.8KB +278B 10.7KB 10.7KB 0B
core/Collapsible 11.7KB 12.0KB +291B 4.2KB 4.2KB 0B
core/CommandPalette 50.9KB 51.2KB +281B 11.8KB 11.8KB 0B
core/CommandPaletteItem 8.4KB 8.7KB +280B 3.5KB 3.5KB 0B
core/ComplexSelector 55.8KB 56.1KB +318B 10.6KB 10.6KB 0B
core/ContextMenu 77.7KB 78.0KB +275B 14.6KB 14.6KB 0B
core/ContextMenuItem 16.6KB 16.8KB +280B 5.2KB 5.2KB 0B
core/DateInput 99.9KB 100.1KB +243B 14.8KB 14.8KB 0B
core/DateRangeInput 69.4KB 69.6KB +266B 11.2KB 11.2KB 0B
core/DateTimeInput 110.1KB 110.3KB +248B 15.2KB 15.2KB 0B
core/DialogHeader 48.8KB 49.1KB +280B 9.8KB 9.8KB 0B
core/DrawerHeader 48.9KB 49.2KB +249B 11.2KB 11.2KB 0B
core/DropdownMenu 72.7KB 73.0KB +278B 14.7KB 14.7KB 0B
core/DropdownMenuCheckboxItem 16.1KB 16.4KB +284B 5.6KB 5.6KB 0B
core/DropdownMenuItem 15.4KB 15.7KB +287B 5.2KB 5.2KB 0B
core/DropdownMenuRadioItem 16.2KB 16.5KB +275B 5.6KB 5.6KB 0B
core/DropdownMenuSubMenu 58.1KB 58.3KB +270B 9.5KB 9.5KB 0B
core/FileInput 54.1KB 54.4KB +262B 10.3KB 10.3KB 0B
core/IconButton 43.3KB 43.6KB +273B 9.2KB 9.2KB 0B
core/Item 11.4KB 11.6KB +273B 4.7KB 4.7KB 0B
core/LayerProvider 63.3KB 63.6KB +288B 10.1KB 10.1KB 0B
core/Lightbox 51.2KB 51.4KB +249B 9.8KB 9.8KB 0B
core/Link 43.5KB 43.8KB +288B 7.6KB 7.6KB 0B
core/ListItem 53.5KB 53.7KB +268B 10.5KB 10.5KB 0B
core/Markdown 144.5KB 144.7KB +247B 17.4KB 17.4KB 0B
core/MobileNav 51.0KB 51.3KB +264B 10.1KB 10.1KB 0B
core/MobileNavToggle 46.2KB 46.5KB +259B 9.4KB 9.4KB 0B
core/MoreMenu 72.9KB 73.2KB +278B 14.7KB 14.7KB 0B
core/MultiSelector 76.2KB 76.4KB +270B 14.6KB 14.6KB 0B
core/NumberInput 55.7KB 56.0KB +278B 10.7KB 10.7KB 0B
core/Outline 28.6KB 28.9KB +290B 4.0KB 4.0KB 0B
core/Pagination 87.7KB 88.0KB +263B 15.3KB 15.3KB 0B
core/Popover 49.8KB 50.1KB +238B 12.1KB 12.1KB 0B
core/PowerSearch 155.1KB 155.3KB +263B 18.6KB 18.6KB 0B
core/RadioListItem 52.1KB 52.4KB +286B 9.7KB 9.7KB 0B
core/SegmentedControlItem 8.6KB 8.9KB +285B 3.9KB 3.9KB 0B
core/SelectableCard 12.8KB 13.1KB +294B 7.1KB 7.1KB 0B
core/Selector 79.1KB 79.4KB +268B 15.0KB 15.0KB 0B
core/SelectorOption 14.5KB 14.8KB +251B 5.1KB 5.1KB 0B
core/SideNav 58.0KB 58.3KB +272B 10.6KB 10.6KB 0B
core/SideNavCollapseButton 46.6KB 46.9KB +242B 9.5KB 9.5KB 0B
core/SideNavHeading 54.7KB 55.0KB +248B 10.1KB 10.1KB 0B
core/SideNavItem 54.5KB 54.8KB +266B 10.1KB 10.1KB 0B
core/Step 32.4KB 32.7KB +279B 5.3KB 5.3KB 0B
core/Stepper 47.4KB 47.7KB +265B 9.6KB 9.6KB 0B
core/Switch 48.4KB 48.7KB +285B 9.3KB 9.3KB 0B
core/Tab 9.7KB 10.0KB +294B 4.1KB 4.1KB 0B
core/TabMenu 51.6KB 51.9KB +276B 10.0KB 10.0KB 0B
core/Table 84.0KB 84.2KB +253B 15.1KB 15.1KB 0B
core/TableCell 78.8KB 79.1KB +267B 14.8KB 14.8KB 0B
core/TableHeaderCell 78.6KB 78.8KB +272B 14.8KB 14.8KB 0B
core/TableSelectionToolbar 52.4KB 52.6KB +278B 11.2KB 11.2KB 0B
core/TextInput 53.5KB 53.8KB +281B 10.5KB 10.5KB 0B
core/Thumbnail 50.7KB 50.9KB +274B 11.0KB 11.0KB 0B
core/TimeInput 86.8KB 87.1KB +266B 13.8KB 13.8KB 0B
core/Timestamp 27.5KB 27.5KB -1B 9.5KB 9.5KB 0B
core/Toast 59.8KB 60.1KB +258B 9.7KB 9.7KB 0B
core/ToggleButton 44.1KB 44.4KB +243B 9.3KB 9.3KB 0B
core/Token 29.6KB 29.9KB +289B 4.5KB 4.5KB 0B
core/Tokenizer 64.8KB 65.1KB +306B 11.7KB 11.7KB 0B
core/TopNav 51.9KB 52.1KB +277B 10.4KB 10.4KB 0B
core/TopNavHeading 54.2KB 54.4KB +231B 9.9KB 9.9KB 0B
core/TopNavItem 9.0KB 9.3KB +283B 3.8KB 3.8KB 0B
core/TopNavMegaMenu 54.2KB 54.4KB +268B 10.8KB 10.8KB 0B
core/TopNavMegaMenuItem 9.1KB 9.4KB +281B 4.1KB 4.1KB 0B
core/TopNavMenu 53.6KB 53.8KB +258B 10.1KB 10.1KB 0B
core/TreeList 32.5KB 32.8KB +274B 4.7KB 4.7KB 0B
core/Typeahead 61.5KB 61.7KB +268B 11.3KB 11.3KB 0B

Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.

Bundle Size Summary

Gzip figures measure each package's entry file, not the code an import pulls in.

Package Entry (ESM) Entry gzip before Entry gzip after Delta
@astryxdesign/core 4.9KB 1.2KB 1.2KB 0B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 34.6KB 34.6KB 0B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

55 of 502 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,072
RichTextEditor Markdown parity: side by side probe dark 558,840
RichTextEditor Markdown parity: side by side probe light 540,756
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
AppShell With Mobile Nav probe light 217,010
AppShell With Mobile Nav probe dark 216,960
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
AppShell Playground neutral dark 65,198
AppShell Playground neutral light 63,788
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232

and 35 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

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

Approval is on hold for an owner decision: draft spec:AST-094 has no claim that states where a touch on a moving element lands, so an engineering owner needs to provide or identify the governing spec for presented hit testing, and settle the record's conflict with the branch's theme-tunable motion roles (its FR4 and DEC-1). Any engineering owner listed in .github/ENGOWNERS, the author included if listed, records that decision by commenting /approve-spec <full head SHA> on a revision whose record states it. Whatever that decision, these changes are needed:

  • Land or retarget onto the predecessor stack so this pull request carries only its own 1,069-line commit instead of 34,869 authored lines.
  • In iOS Safari a drag that starts on a retargeted contact has no cancelable touchmove listener Safari honors once the pan claims (packages/core/src/gestures/GestureHost.ts:747 and :1167-1171, with the README's own measurement at packages/core/src/gestures/README.md:488-490), so keep a document-level listener while something moves or limit retargeting to taps.
  • The selection lock added to the stable press-feedback controller (packages/core/src/utils/pressFeedback.ts:306) changes released components' touch behavior, so it needs its own Changeset under spec:AST-017 FR1 to FR8 rather than [experimental] (FR22), or should stay in the experimental gesture host.
  • packages/cli/assets/theme.template.ts:362 documents a platform adaptation axis that defineTheme now rejects, and the VelocityStrategy release note, the spec:AST-100 and AST-094 OQ4 citations and two untagged experimental constants need correcting.

[Automated review]

this.touchIds.set(touch.identifier, id);
this.lockSelection(id, recognizers, consumerElement, target);
// The finger's events keep going to what the engine hit.
this.watchTarget(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

On iOS, when presentedTarget retargets a touch from the scrim to a moving sheet, this blocking listener lands on the engine's target during touchstart, which README.md:488-490 records Safari ignoring for that touch, and the sheet's own non-passive listener (:747) is off the touch's event path. Without a page hold, a pan that claims a retargeted drag cannot cancel a move Safari honors. Consider a non-passive document-level touchmove listener installed while presentation.ts reports a mover, or limiting retargeting to taps.

@@ -283,6 +301,9 @@ function onPointerDown(event: PointerEvent): void {
gesture: idlePressGesture(),
pointerId: event.pointerId,
timer: null,
// A held finger never starts text selection on the surface (UIKit's
// controls and cells never do), on any coarse pointer.
unlock: lockSelection([element], event.target),

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 lock now applies to every touch or pen press on released usePressFeedback surfaces (Button, Link, Item and others), turning off text selection and the iOS callout while the finger is held. .changeset/touch-selection-lock.md files it as [experimental], but spec:AST-017 FR22 keeps stable runtime behavior out of that category, so it needs its own Changeset under FR1 to FR8, or should stay in the experimental gesture host.

Comment thread packages/cli/assets/theme.template.ts Outdated
* `from` includes its point; `below` excludes it. The other supported axes
* are primary-pointer precision, contrast preference, and motion preference.
* are primary-pointer precision, contrast preference, motion preference, and
* platform (`ios` for the browsers of iPhone and iPad, `other` for the rest).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ThemeAdaptationCondition no longer has a platform axis, and defineTheme throws …platform is not supported. for a rule that follows this line, so the platform clause should go.

'@astryxdesign/core': patch
---

[experimental] Two experimental subpaths for building touch presentations, outside the stable compatibility promise (they may change or be removed in a patch release). `@astryxdesign/core/motion/experimental` exports the compositor animator (`createAnimator`, `useAnimator`), UIKit's spring, rubber-band and cubic-bézier math, `resolveMotion` (a motion role's value under the element's theme), and the UIKit constants table as the read-only `uikit` namespace. `@astryxdesign/core/gestures/experimental` exports UIKit-style gesture recognizers (tap, long press, pan, screen-edge pan, swipe, pinch, rotation, hover), `useGestureRecognizer`, `usePressTracker`, `useTouchPresentation`, `readTouchPresentation`, `playHaptic` and `attachTapHaptic`, with the `GestureArena` and `VelocityStrategy` types their options reach. Nothing is added to the package root or to any other subpath.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

VelocityStrategy is no longer exported: .changeset/gesture-recognizer-fixes.md removes it and packages/core/src/gestures/experimental.ts has no such export, so this list should drop it.

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

Approval is on hold for an owner decision: draft spec:AST-094 has no claim that states where a touch on a moving element lands, so an engineering owner needs to provide or identify the governing spec for presented hit testing and settle the record's conflict with the branch's theme-tunable motion roles (its FR4 and DEC-1). Any engineering owner listed in .github/ENGOWNERS, the author included if listed, records that decision by commenting /approve-spec <full head SHA> on a revision whose record states it. Whatever that decision, these changes are needed:

  • Land or retarget onto the predecessor stack so this pull request carries only its own 1,070-line commit instead of 34,986 authored lines.
  • In iOS Safari a drag that starts on a retargeted contact has no cancelable touchmove listener Safari honors once the pan claims (packages/core/src/gestures/GestureHost.ts:746-750 and :1050-1054, with the README's own measurement at packages/core/src/gestures/README.md:491-493), so keep a document-level listener while something moves or limit retargeting to taps.
  • The press-feedback selection lock (packages/core/src/utils/pressFeedback.ts:306) changes released components as a [fix] with no current authority (spec:AST-017 FR33) and also locks pens, so it needs an owner decision, touch-only scope or fine-pointer evidence (FR59), and a [feat] Changeset.
  • Pointer-path retargeting also reaches a mouse, trackpad or pen on iPad without fine-pointer evidence (FR59), the theme template documents a platform axis defineTheme rejects, and the VelocityStrategy release note, the spec:AST-100 and AST-094 OQ4 citations and two untagged experimental constants need correcting.

[Automated review]

Comment thread .changeset/touch-selection-lock.md Outdated
'@astryxdesign/core': patch
---

[fix] Under a finger or pen, holding a control or a cell that shows the touch press (the press feedback surfaces) no longer starts text selection or the iOS callout, as on UIKit controls. Selection and the callout come back when the finger lifts. Text fields keep their selection, and a mouse keeps desktop selection.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[fix] needs pre-existing current authority that the released behavior violated (spec:AST-017 FR33: "Without pre-existing authority, the proposal is a new contract decision for the direct owner, not a fix"), and no current claim requires press-feedback surfaces to block selection or the callout. The lock also applies to pens, since the controller skips only mouse (packages/core/src/utils/pressFeedback.ts:270), which current FR59 allows only with fine-pointer before-and-after evidence. Once an owner decides the behavior, a touch-only (or pen-evidenced) [feat] fits.

if (event.pointerType === 'mouse' && event.button !== 0) {
return;
}
const target = this.presentedTarget(

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 runs for mouse and pen presses too, so in iPadOS Safari a trackpad click or a Pencil press on a moving element is retargeted and its click replaced (:1652-1664). spec:AST-017 FR59 asks for fine-pointer before-and-after evidence when touch work reaches those pointers; otherwise keep the engine's target for mouse and pen here.

vjeux added 6 commits October 27, 2026 00:56
…xposes no ontouchstart is taken once

Chromium fixes ontouchstart when the page is created, so turning touch
emulation on later sends Touch Events without it: the host reads Pointer
Events for the finger and must not read its Touch Events too. A one-finger
long press fails here, the finger tracked twice.
…from their pointer events

install() reads a finger from its pointer events when the window has no
ontouchstart, but the four touch handlers still took the Touch Events a
browser sends anyway (Chromium after touch emulation turns on), tracking
the finger twice, so a one-finger long press never recognized. Each touch
handler now returns while usesTouchEvents is false, after noting the
fingers down, as install()'s comment promised.

@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 review is waiting on an owner decision: current spec:AST-094 covers moving content only for a catch (FR6), so before presented hit testing can be approved an engineering owner has to update it to say where a tap or an iPad mouse, trackpad or Pencil press on moving content lands, whether a retargeted tap may activate through a script click although FR11 keeps activation on the browser's click, and whether a document-level blocking touchmove listener is acceptable although FR19 keeps it on the recognizer's element; your proposed FR14 sentence covers only the first. Any engineering owner listed in .github/ENGOWNERS, you included, records that decision by landing the amendment on main, or by commenting /approve-spec <full head SHA> once this pull request carries it. These changes are needed either way:

  • Land the predecessors first and drop the #8340 merge, so this pull request shows its own change instead of 26,765 lines, and move out the astryxStylex fix and the harness additions as announced.
  • Make a retargeted tap act once when only watchers are attached; today nothing cancels or swallows the engine's click (packages/core/src/gestures/GestureHost.ts:1930-1939).
  • Let a drag retargeted onto a claiming recognizer outside every moving element claim (packages/core/src/gestures/GestureHost.ts:1108-1152).
  • Add a removal condition for the -webkit-touch-callout gate, plus real-device iOS Safari, real-touch drag and fine-pointer evidence, and correct the two notes that still say the inline style holds the end.

[Automated review]

vjeux added 7 commits October 27, 2026 03:09
…xperimental touch exports

The motion README's Files table is the union: main's rows, with meta#8663's
resolveSnapPoint.ts and selectPage.ts, plus this branch's, in main's
order.

# Conflicts:
#	packages/core/src/motion/README.md
…; this pull request's presentation.ts rows kept beside them) into meta#8293

@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 pull request is waiting on an owner decision: current spec:AST-094 covers moving content only for a catch (FR6), so before presented hit testing can be approved an engineering owner has to settle where a tap or an iPad mouse, trackpad or Pencil press on moving content lands, whether a retargeted tap may activate through a script click although FR11 keeps activation on the browser's click, and whether a document-level blocking touchmove is acceptable although FR19 keeps it on the recognizer's element. Any engineering owner listed in .github/ENGOWNERS, you included, records that decision by landing the AST-094 amendment on main, or by adding it to this pull request and commenting /approve-spec <full head SHA>. These changes are needed either way:

  • Land the predecessors first and drop the #8340 merge, so this pull request shows its own change instead of 26,815 lines, and move out the astryxStylex fix and the harness additions as announced.
  • Make a retargeted tap act once when no claiming recognizer is attached (packages/core/src/gestures/GestureHost.ts:1936-1944).
  • Let a drag retargeted onto a claiming pan outside every moving element claim (packages/core/src/gestures/GestureHost.ts:1118-1152).
  • Add a removal condition for the -webkit-touch-callout gate, real-device iOS Safari, real-touch drag and fine-pointer evidence, and correct the two notes that say the inline style holds the end.

[Automated review]

// A tap sent to what is drawn: the engine's click would hit the layout
// position, so it is cancelled and the tap clicks what it touched.
for (const end of retargetedEnds) {
if (this.clickRetargetedTap(end.landed, end, dispositions.get(end.id))) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With no claiming recognizer or press tracker attached anywhere, hasClaimer() is false: the document and target touchend listeners are passive and the window click swallower is not installed (:1329-1359, :2094-2098). This click is then followed by the engine's own click, so the tap activates twice. Retarget only contacts a claiming participant takes, or keep a cancelable touchend and the swallower installed while a retargeted contact is down, with a watcher-only test.

Comment on lines +1118 to +1122
const needed =
isWatching &&
presentedElements().some(mover =>
this.hasClaimingRecognizerAround(mover),
);

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 covers a claiming recognizer in or around a mover only. A contact retargeted onto a claiming pan outside every mover (:682-686) keeps only the listener watchTarget adds during touchstart, which AST-094 FR19 says “would hand diagonal drags to the scroll” and the README says Safari does not honour, so that pan cannot claim. Keep the document listener while any mover moves and a claiming recognizer is attached, or count claimers whose element intersects a mover's start box.

* the compositor where the animation STARTED: it keeps the transform it
* resolved when the animation went to the compositor, so event targets,
* `elementFromPoint` and `getBoundingClientRect` stay there for the whole
* curve, while the inline style already holds the end and the computed

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

At this head the animator writes the curve's start inline while it runs (motion/animator.ts:45-47), so “the inline style already holds the end” states the opposite of the code; the same clause is in gestures/README.md:568-569.

* utils/touchPresentation.ts, which this module cannot import: it brings
* React).
*/
function isIosWebKit(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

spec:AST-013 FR6 allows a narrow engine workaround only with a review or removal condition; none is stated here, in motion/presentation.ts or in the README section, and utils/touchPresentation.ts:88-90 reserves this check “For a style iOS WebKit requires, never a behaviour”.

vjeux added 4 commits October 27, 2026 05:05
… a mouse press

At the document, an event from inside an open shadow root is retargeted to
the shadow host, and so is a touch's target, so the host looked for
participants from the shadow host and found none inside the root: the
browser then sent its own mousedown and click. Both cases, a finger and a
mouse, attach a tap to an element in the shadow root. Fail until the host
resolves targets through the composed path.
…composed path

Inside an open shadow root the document's listeners see the shadow host,
in event.target and in a touch's target alike, so a recognizer on an
element in the root never got its finger, and nothing claimed it. landedOn
takes the composed path's first element for the event's own target and for
a touch whose target is the event's (another finger keeps its own): a
touch's start, a pointer's press and its pairing, the last touch a scroll
reads, a cancelled pointer's touch and hover.
…he host resolves targets through the composed path) into meta#8320
…s finger: the host resolves targets through the composed path) into meta#8293

# Conflicts:
#	packages/core/src/gestures/GestureHost.ts

@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 pull request is on hold for an engineering owner's decision: current spec:AST-094 covers moving content only for a catch (FR6), so an owner needs to amend it to state where a tap or an iPad mouse, trackpad or Pencil press on moving content lands, whether a retargeted tap may activate through a script click although FR11 keeps activation on the browser's click, and whether a document-level blocking touchmove is acceptable although FR19 keeps it on the recognizer's element. Any engineering owner listed in .github/ENGOWNERS, you included, records that decision by landing the amendment on main, or by adding it here and commenting /approve-spec <full head SHA> on the pull request. These changes are needed either way:

  • Land the predecessors first and drop the #8340 merge, so this pull request carries its own change instead of 26,870 authored lines.
  • presentedTarget hit-tests with the document's elementFromPoint, which returns the shadow host, so on iOS a control inside an open shadow root on moving content never activates (packages/core/src/gestures/GestureHost.ts:695-703).
  • With only watchers attached a retargeted tap activates twice (GestureHost.ts:1963-1967), and a drag retargeted onto a claiming pan outside every moving element cannot claim (GestureHost.ts:1135-1139).
  • Add a review or removal condition for the -webkit-touch-callout gate, plus real-device iOS Safari, real-touch drag and fine-pointer evidence.

[Automated review]

// drawn now; the hit test below flushes them. The engine then gets
// z-order, pointer-events and clipping right, which no box math would.
resolvePresented(movers);
const hit = this.doc.elementFromPoint(x, y);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

document.elementFromPoint returns the shadow host for anything inside an open shadow root, and mover.contains does not cross the shadow boundary, so on iOS a contact on moving content inside a shadow root is retargeted to the host: recognizers inside it take no part and the tap's click is dispatched on the host, never reaching the control inside, while the engine's click is cancelled. That undoes the composed-path handling landedOn adds for moving content. Descend through shadowRoot.elementFromPoint from this hit and compare containment across shadow roots; a shadow-root case in presentedHitTesting.test.ts would cover it.

Comment on lines +1963 to +1967
if (cancelsClick) {
if (event.cancelable) {
event.preventDefault();
}
this.armSwallower();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With only watchers attached, this touchend listener is passive (syncClaimingListeners, lines 1346-1376) and the click swallower is not installed, so after clickRetargetedTap clicks the drawn element the preventDefault here is ignored and the engine's click still lands where the animation started: two activations on two elements. Retarget only while the click can be cancelled, or keep the swallower installed while a retargeted contact is in flight.

Comment on lines +1135 to +1139
const needed =
isWatching &&
presentedElements().some(mover =>
this.hasClaimingRecognizerAround(mover),
);

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 blocks only while a claiming recognizer lives in or around a mover. A contact whose engine target is inside a mover but whose drawn target is a claiming pan outside every mover (a swipeable row a sliding toast is leaving) gets no touchmove listener Safari honours, so the pan begins but cannot claim (spec:AST-094 FR19).

Comment on lines +2929 to +2933
function isIosWebKit(
view: (Window & {CSS?: Partial<typeof CSS>}) | null,
): boolean {
return view?.CSS?.supports?.('-webkit-touch-callout', 'none') === true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

spec:AST-013 FR6 allows a narrow engine workaround only "with a regression fixture and a review or removal condition". No condition sits here, in motion/presentation.ts or in the README section, and utils/touchPresentation.ts documents this check for styles iOS WebKit requires, never a behaviour.

* the compositor where the animation STARTED: it keeps the transform it
* resolved when the animation went to the compositor, so event targets,
* `elementFromPoint` and `getBoundingClientRect` stay there for the whole
* curve, while the inline style already holds the end and the computed

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With #8340's start-value write merged into this branch, the inline style holds the curve's start while it runs (animator.ts:45-47, motion/README.md:178-179); this note and gestures/README.md:568-569 say the end. Correct them whichever way the #8340 split goes.

vjeux added 4 commits October 27, 2026 07:08
…adow root; a claimed touch's moves are cancelled where fingers come from pointer events

With targets from the composed path, a finger inside an open shadow root
sits below the scroller only along the flat tree, which Node.contains does
not follow: a scroll under it did not take it, and its flick did not arm
the scroll brake. And where the host reads fingers from pointer events
(inputMode 'auto' with no ontouchstart), the blocking touchmove found no
finger for a claimed pan's touch, so its moves went to the page. Three
cases fail until the host compares along the flat tree and finds that
finger; a fourth pins a finger scroll inside a shadow root.
… tree; a claimed touch finds its pointer finger wherever fingers come from pointer events

containsAlongFlatTree walks parentAcrossShadow, so a finger inside an open
shadow root, now that targets come from the composed path, counts as under
a scroller outside it: a scroll there takes it, its flick arms the scroll
brake, and a touch there brakes. cancelIfClaimed matches a touch to its
pointer finger whenever the host reads fingers from pointer events, not
only under inputMode 'pointer-events', so a claimed pan's moves are
cancelled where a browser that exposes no ontouchstart still sends Touch
Events.
… flat tree, and a claimed touch finds its pointer finger wherever fingers come from pointer events) into meta#8320
…r along the flat tree, and a claimed touch finds its pointer finger wherever fingers come from pointer events) into meta#8293

@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 pull request is on hold for an engineering owner's decision. Current spec:AST-094 covers moving content only for a catch (FR6), so an owner needs to amend it to say three things:

  • where a tap, or an iPad mouse, trackpad or Pencil press, on moving content lands;
  • whether a retargeted tap may activate through a script click, although FR11 keeps activation on the browser's click;
  • whether a document-level blocking touchmove is acceptable, although FR19 keeps that listener on the recognizer's element.

Any engineering owner listed in .github/ENGOWNERS, you included, records that decision by landing the amendment on main, or by adding it here and commenting /approve-spec <full head SHA> on the pull request. These changes are needed either way:

  • Land the predecessors first and drop the #8340 merge, so this pull request carries its own change instead of 26,990 authored lines.
  • With only watchers attached, a retargeted tap activates twice (GestureHost.ts:1976-1979), because the touchend listener is passive and the click swallower is not installed.
  • presentedTarget hit-tests with the document's elementFromPoint, which returns the shadow host, so a control in the open shadow root of a moving element never activates while the element moves (GestureHost.ts:708-715).
  • A drag retargeted onto a claiming pan outside every moving element gets no blocking listener and cannot claim (GestureHost.ts:1167-1182).
  • Add a review or removal condition for the -webkit-touch-callout gate (spec:AST-013 FR6) and real-device iOS Safari evidence.

[Automated review]

cancelsClick = true;
}
}
if (cancelsClick) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With no claiming recognizer or press tracker attached anywhere, this document touchend listener is passive and the window click swallower is removed (syncClaimingListeners, lines 1359-1388). After clickRetargetedTap clicks the drawn element, the preventDefault here is ignored, and the engine's own click still lands where the animation started: two activations on two elements. Retarget only while the click can be cancelled, or keep a cancelable touchend and the swallower installed while a retargeted contact is down, and add a watcher-only test.

// drawn now; the hit test below flushes them. The engine then gets
// z-order, pointer-events and clipping right, which no box math would.
resolvePresented(movers);
const hit = this.doc.elementFromPoint(x, y);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

document.elementFromPoint returns the shadow host for anything inside an open shadow root, and mover.contains (line 714) does not cross the boundary. A contact on shadow content of a moving host is therefore retargeted to the host: recognizers inside it get no finger, and the tap's click is dispatched on the host while the engine's click is cancelled. Descend through shadowRoot.elementFromPoint while the hit is a host with an open root, and compare with containsAlongFlatTree (line 344). A shadow-root case in presentedHitTesting.test.ts would cover it.

}
for (const [element, registration] of this.registry) {
if (
(mover.contains(element) || element.contains(mover)) &&

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 counts only claiming recognizers in or around a mover. Take a contact whose engine target is a moving element but whose drawn target is a claiming pan outside every mover, such as a swipeable row a sliding toast is leaving. It keeps only the listener watchTarget adds at touchstart, which spec:AST-094 FR19 says "would hand diagonal drags to the scroll", so the pan cannot claim. Keep the document listener while any mover moves and any claiming recognizer is attached, or include claimers whose box meets a mover's start box.

function isIosWebKit(
view: (Window & {CSS?: Partial<typeof CSS>}) | null,
): boolean {
return view?.CSS?.supports?.('-webkit-touch-callout', 'none') === true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

spec:AST-013 FR6 allows a narrow engine workaround only "with a regression fixture and a review or removal condition". No condition is stated here, in motion/presentation.ts or in the README section, and utils/touchPresentation.ts reserves this check "For a style iOS WebKit requires, never a behaviour".

* the compositor where the animation STARTED: it keeps the transform it
* resolved when the animation went to the compositor, so event targets,
* `elementFromPoint` and `getBoundingClientRect` stay there for the whole
* curve, while the inline style already holds the end and the computed

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With the merged start-value write, the animator holds the curve's start inline while it runs (animator.ts lines 45-47), so "the inline style already holds the end" states the opposite. gestures/README.md lines 568-569 repeat it.

/**
* Per document or shadow root: the values it has a rule for, their rules'
* text, and where that text goes (an adopted sheet, or a style element where
* constructable sheets are missing). Rules are never removed: there are only

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Because rules are never removed, the adopted sheet or style element stays after the last recognizer detaches, which spec:AST-094 FR24 rules out ("nothing touch-related remains after the last instance unmounts"). Dropping the sheet when the host uninstalls would conform.

@vjeux

vjeux commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Closing: carried by #8360 (this head, c1186e4, is in it by hash). The review items:

This branch was successfully deployed

1 active deployment
Preview — c1186e4f Deployed Oct 27, 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. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants