Skip to content

perf(Popover): the touch presentation starts in the next frame, not in the task that opens it - #8662

Draft
vjeux wants to merge 2064 commits into
meta:mainfrom
vjeux:uikit/c26-popover-present-next-frame
Draft

vjeux wants to merge 2064 commits into
meta:mainfrom
vjeux:uikit/c26-popover-present-next-frame

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Review only this PR's change: vjeux/astryx@3f21fd4...3f3923a

Depends on #8223 (base 3f21fd4), which now carries #8342's input hold.

Owner question: FR35's hold starts in the presentation's first frame, about 16 ms after the opening tap lifts. Starting it at the open costs 2 forced layouts in every touch opening (a top-layer cover's showPopover()), which this pull request exists to remove. A tap in that frame still doesn't dismiss, but its pointerdown reaches the page. Is that window acceptable, or should the hold start at the open?

The popover's two springs play as motion roles once #8258 lands; that part changes then.

This pull request shows a merge conflict with main in the CLI's icon-set search goldens; it comes from the keyboard module below it (#8559), which resolves it, and this branch takes that fix with its next push.

Why

Opening a Popover with a finger did twice the layout work of opening it with a mouse, all inside the tap's own task: the page was laid out 8 times instead of 4 (about 21 ms against 11). On a busy phone that task crosses the 50 ms long-task line, which is when a tap visibly hitches; #8187's layout proof caught it in about 1 run in 50.

What led to it

We are bringing Astryx's touch components to the behavior of native iOS controls, and each presentation has a proof that nothing in it blocks the main thread from the key press to the end of the dismissal (spec:AST-094 M1). The touch presentation wrote styles, read the popover's boxes, and wrote again, all in the opening task; on an anchored element, each read after a write lays the page out again.

Options and tradeoffs

  • Batch the reads before the writes in the same task: fewer layouts, but all still inside the tap's task.
  • Start the presentation in the next animation frame (this pull request): the frame runs before the browser paints, so nothing visible changes, and the work leaves the tap's task. A close before that frame cancels it, so nothing paints at all.

Why this choice

It moves the work out of the tap with no visual difference: the popover still grows from the first painted frame. A presentation during a dismissal reads nothing and still starts at once.

How to check it

  1. In Storybook's Popover stories with touch, tap a trigger: the popover grows from the first frame after the tap, never shown at rest first. Tap outside: it closes as before.
  2. Open and close it in the same task (a programmatic open then close): nothing paints.
  3. With a mouse: unchanged.
  4. The Popover opening proof and two unit cases (nothing read or written in the opening task; an early close presents nothing and leaves no will-change or timer) fail without this change and pass here.

Evidence

iOS 26.2 simulator (Safari, a grey dot per finger):

Touch opening Closed before its first frame

vjeux and others added 30 commits October 26, 2026 21:51
# Conflicts:
#	docs/architecture/layer-runtime.md
…ter the browser takes the touch

A host that stopped feeding the finger at the takeover and still delivered
the lift would have passed. Each transition now records the list's
scrollTop, and the test expects a 'changed' while the list is scrolled
(spec:AST-094 FR20: it follows the scroll to the lift).
…her browser test) into the arena fixes

The host-owner changeset keeps this PR's own listeners for the scroll lock
and finger-scroll watch, with meta#8336's lone-finger scroll-edge hold.
…d, as on iOS

UIKit ignores every touch while a popover is presented: a tap outside 60 ms
into the presentation neither dismisses it nor reaches the page, and the
presentation finishes. Here the browser's light dismiss closed the popover
mid-presentation, and it cannot be cancelled from an event.

For the length of the present spring, the touch presentation places a
transparent manual popover nested in the layer over the viewport
(data-astryx-popover-hold). In the top layer it is above everything, and as
a descendant of the layer a tap on it is a tap inside the popover, which the
browser leaves open; it lets no pan start. It goes when the spring settles,
when the popover is dismissed, and on reset. The mouse presentation is
unchanged.

The ignored tap leaves no trace: the hold cancels the compatibility
mousedown and the touch's end, so the control the popover focused keeps
focus and no click fires (before, the tap on the hold moved focus to the
body).

The hold's touch-action is pinch-zoom: no pan starts on it, and a pinch
still zooms the page, as the draft touch-presentation record keeps it.

(cherry picked from commit 25db2b9)
spec:AST-094 FR35 keeps a page pinch available while a Popover grows, and
its verification lists a pinch during growth. A browser case now pinches
with two fingers 60 ms into the presentation: both land on the hold, the
page's visual viewport scales past 1.1, and the popover finishes opening
and stays open. With the hold set to touch-action: none, the case fails
(the scale stays 1).

(cherry picked from commit 8828783)
…dule's own hold

The popover placed its own hold: a manual popover inside the layer that
swallowed the compatibility mouse events. The Layer module now owns one
(`holdPresentation`, from the presentation hold), for every component
whose layer presents under a finger, so Popover takes the shared one with
scope `viewport` for the length of the present spring and keeps its
release for a dismissal that cuts it short.

What a tap meets is unchanged: the shield covers the viewport from inside
the layer, so the browser's light dismiss counts it as inside the popover
and leaves it open, no pan starts on it, a pinch still zooms the page,
and the control the popover focused keeps focus. It goes further than
the popover's own hold did: pointer and touch events are swallowed at
the shield, not only the mouse pair, and the gesture host holds the
layer's touches away from every recognizer.

`POPOVER_HOLD_ATTRIBUTE` goes with the private hold. The unit tests and
the touch proof read the shield through `isPresentationShield` and its
shape. Where the browser has no popovers the shield is still placed, now
outside the top layer, instead of no hold at all.

(cherry picked from commit 79539c1)
…akes no input (AST-094 FR35)

A tap outside 17 ms after the popover opens leaves it open with focus
inside; once it has grown, the same tap light-dismisses it; a tap on the
trigger during the growth does not close it; and a one-finger drag during
the growth scrolls nothing. Chromium dismisses a touch on the gesture tap,
so only a cancelled touchend (which the presentation hold's shield does)
stops it.

Co-authored-by: Christopher Chedeau <vjeux@users.noreply.github.com>
(cherry picked from commit c5ef889)
meta#8342 renamed `Layer/presentationHold` to `Layer/inputHold` (`holdInput`,
`isInputShield`). Popover's sheet motion, its test and its Chromium spec
take the new names; the comments that mean the runtime's shield say
"input hold". Behavior is unchanged.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
(cherry picked from commit 5d3830f)
…ne's tap

In WebKit the test failed before the hold was ever reached: it opened the
popover with a scripted touch sequence, and Playwright's WebKit synthesises
no click from one (the harness's documented engine limit), so the popover
never opened and the tap outside landed on the page. In Chromium the same
script opened it and the hold kept it open.

The test now opens it through the engine's tap, as the test's other steps
do, and records at each press whether the hold's shield was up: the
trigger's press found none, and the tap outside landed while the popover
presented. It passes in both engines, and with the hold disabled it fails
in both, at that record and at the popover's being open.

(cherry picked from commit ee457bd)
…sture system (spec:AST-094 FR24, FR43)

utils/pressFeedback.ts reaches gestures/coarsePointer.ts, so a page whose
only touch work is press feedback carries a gesture module, and a bundle
check that counts gestures/ code finds it in the root and hooks entries.
The coarse-pointer query belongs with the environment queries. This test
walks the controller's import graph and fails until it reaches no module
under gestures/.
…ries in utils/ (spec:AST-094 FR43)

gestures/coarsePointer.ts becomes utils/coarsePointer.ts, beside
utils/touchQueries.ts, with its test; the gesture host's selection lock,
the press controller and the pointer test helper import it from there. The
press controller's import graph now reaches no module under gestures/, so
a page whose only touch work is press feedback carries none of the gesture
system. No behavior changes.
CI's lint rejected the three Array<T> types the previous commit added
(@typescript-eslint/array-type); the test now names its record once
(WatchTransition) and reuses it.
…e arena's reset

The arena resets a recognizer to possible after it ends, as UIKit does,
and the transition listener records that too, so the last transition is
possible: the test now asserts the ended-then-possible tail.
…finger, meta#8589's watcher test, its lint fix; meta#8076 3bfd7c3; the coarse-pointer query in utils/) into meta#8293
…nter query in utils/) into meta#8356

# Conflicts:
#	packages/core/src/gestures/GestureHost.ts
#	packages/core/src/gestures/fingerScroll.ts
#	packages/core/src/gestures/pageHold.ts
#	packages/core/src/gestures/selectionLock.ts
#	packages/core/src/utils/pressFeedback.ts
… layer at once

Once the copy's dismissal started, exit() put the hidden element at rest with a set() that promotes, so the animator kept it promoted until PROMOTION_LINGER_MS (500 ms) after its last motion and then rewrote its will-change about 490 ms into the dismissal: an UpdateLayoutTree and a Layout inside the frames the dismissal proof counts, on an element that no longer paints. The element is now put at rest without promotion and detached from its animator in the exit's task, which demotes it there; the next presentation attaches it again.
… FR19, FR24)

Under a fine primary pointer: each element that uses a gesture keeps one
passive pointerdown listener and the page none while idle; a mouse or a
pen pans, its page listeners lasting only the press; a press consumer
takes a mouse press with no feedback-only mode (DEC-21); a pan writes its
touch-action for a touchscreen laptop's finger (FR18); perEventPointer is
taken as any attach, with no warning; a secondary button starts nothing.
All but the last fail until the host attaches for every pointer.
…ode out of the document) into the Popover touch presentation
… the scroll brake for a finger only (spec:AST-094 FR11, DEC-21)

A mouse or a pen press paints at once, inside a scroller too; a mouse
press on a scroller a flick set moving still presses, as the brake is a
finger's. The brake case fails until the tracker brakes only a touch.
vjeux added 17 commits October 29, 2026 12:28
…'s selection

An action group's press is a long press with no minimum duration, so it
claims at the press's pointerdown, before any movement. The drag
selection hold then cleared the page's selection, so a plain click on a
group's button left its Copy or Cut with nothing to act on. The case
fails on this head: the selection is gone after the pointerdown.
meta#8360 carries the gesture foundation this branch took from meta#8076. The only
conflict was the shared motion tables list in tableImports.test.ts, where
this branch adds the keyboard table and meta#8360 the long-press table; both
entries are kept, meta#8360's text unchanged.
Folds this pull request onto the canonical gesture foundation, meta#8360's
head, which carries meta#8076, and onto main, in place of the old meta#8076 and
harness copies it carried. No conflicts.
…age's selection

The drag selection hold cleared the page's selection when any mouse or
pen contact was claimed, but an action group's press claims at its
pointerdown, before it has moved or selected anything, so a plain click
on a group's button left its Copy or Cut with nothing to act on. A claim
before any movement now keeps the page's selection: the hold still
refuses a new one, and puts the page's back over a selection that
appears while it runs. A claim after movement clears what its drag
selected, as before (spec:AST-094 FR41).

The case before the new one now waits for its hold to end a task after
the lift, so no hold outlives that test.
…t every pointer does

- GestureHost's header, the gestures README and coarsePointer.ts no
  longer describe the coarse-only host: a finger or a pen takes the
  touch-down lock under any primary pointer, a kept press's finger is
  kept during a fine pointer's press too, and recognizers attach for
  every pointer while the page listeners last a press (spec:AST-094 FR19,
  FR24, FR41, FR43).
- The fine-pointer selection case asserted the replaced rule and passed
  only because it read iOS WebKit's inline lock without iOS WebKit: it
  now asserts the lock a fine pointer's finger and pen take, and that a
  mouse takes none.
- FR41's sentence and its verification row say that a press claimed at
  its own pointerdown, before it moves, keeps the page's selection, while
  a drag that moved before its claim loses what it selected.
… guide

The keyboard layout guide (this pull request) reads where focus moves to tell
a keyboard that comes up from one that stays: meta#8559 removed the store's focus
reports because nothing there read them, so they return here with their first
reader. One pair of capture listeners on the document, attached while
anything subscribes, reports each focusin and focusout with the current
height; BottomSheet's reveal still acts on viewport reports only.
Conflicts were in this stack's own keyboard files: the browser-support
sentence keeps ChatLayout's composer and takes meta#8559's page wording,
BottomSheet's test imports follow the sheetKeyboard split, and the keyboard
notes keep both the sheet's and ChatLayout's Chrome 124 findings. The search
goldens are regenerated after the merge.
…op-level page

The composer's tap route, its scroll-free focus and Done's park attach only
where the keyboard is measurable, which excludes a page inside an iframe
(spec:AST-099 FR7); there the browser's own focus applies. The docs and
changeset now say so.
…ion, in the present tense

The sentence said a drag that moved before its claim loses the selection
it started, but the hold clears the page's selection then, whatever made
it; and an unclaimed mouse drag kept "today's" behavior, a phrase that
ages. It now says a claim after movement clears the page's selection, a
press claimed at its own pointerdown keeps it, and an unclaimed mouse
drag keeps its released behavior.
This pull request now sits on meta#8665, so a stack carrying it and meta#8665's
other dependants (meta#8676) needs no second GestureHost resolution. Two
hunks in GestureHost.ts's pointerdown, both this pull request's:
- the second-finger check where fingers come from pointer events stays,
  followed by meta#8665's rule that a right click, a pen's barrel button or
  a macOS ctrl-click starts no press;
- the check that runs again once the new finger is delivered stays,
  followed by meta#8665's selection lock call, which no longer takes the
  window.
…e Popover touch presentation

AST-057 takes the dependencies' text, which is main's (meta#8646): the tuning bullet this branch once restored is superseded, and the Popover touch presentation does not touch row actions.
… ChatLayout

BottomSheet's useSheetKeyboard moved to keyboard/sheetKeyboard.ts, and it
subscribes to the keyboard layout guide and rides Gboard with rideKeyboard
as ChatLayout does, but the two modules' headers named only ChatLayout, and
BottomSheetPanel's related-files list and the keyboard notes still pointed
at keyboardHeight.ts. They now name the hook and its file.
@vjeux
vjeux marked this pull request as draft October 29, 2026 22:38
vjeux added 10 commits October 29, 2026 15:55
Once a component in the root entry holds its layer's input (a popover's
presentation hold, meta#8223), Layer/inputHold's static imports of
gestures/GestureHost and gestures/nativeScroll put eight gesture modules
(about 47 KB) in the root entry, against
architecture:react-component-runtime INV13. Bundle a component that imports
the input hold and require no gesture bytes. Fails while the input hold
imports from gestures/.
…e host asks it

Layer/inputHold registered a touch gate with the gesture host
(getGestureHost(doc).addTouchGate(isInHeldLayer)) and walked the flat
tree through gestures/nativeScroll, so any component that holds its
layer statically carried the gesture system into its bundle and the root
entry (architecture:react-component-runtime INV13). Invert it: the host
asks isInHeldLayer for every touch itself (gestures may import Layer),
the hold registers nothing, and it walks the flat tree through
utils/flatTree, where parentAcrossShadow already lives. addTouchGate stays
for other gates. A host test holds a held layer's touches with no gate
(it fails if the host stops asking the hold).
…e input hold imports nothing from gestures/
…ut hold imports nothing from gestures/) into the Popover presentation hold
…thing from gestures/) under the next-frame presentation

@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 waiting on an engineering owner: against main this branch adds new normative text to three current records (architecture:layer-runtime INV13 and its Exit copy section, spec:AST-094 FR41's rule for claimed mouse and pen drags, and spec:AST-057's motion-role line), and it starts the FR35 input hold one frame after the open, which the description asks the owners to accept (no browser case yet shows what a tap that starts in that frame does). An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, or says what must change; the author may do so as an engineering owner. Whatever that decision is, these need changes before merge:

  • The diff still carries #8223 and its stack (44,673 authored lines against main), so please land or retarget the dependencies until only this change's 286 lines remain.
  • Popover's two springs play as literals (popoverSurfaceMotion.ts:373, :440), which spec:AST-094 FR4 forbids, so register them as motion roles or sequence this after #8258.
  • .changeset/layer-input-hold.md says no component uses the input hold although Popover does, and the no-requestAnimationFrame fallback returns 0, which exit() later reads as a pending frame.

[Automated review]

Comment on lines +241 to +244
// The popover's two springs are this component's measured values, not
// motion roles (touch motion stays internal, spec:AST-094 DEC-11): they
// play as given, so no table is registered and no other row is bundled.
const animator = createAnimator(element, animatorOptions);

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-094 FR4 says a component “MUST read touch motion through these roles and MUST NOT play a literal”, and that a theme's knob values reach the motion; FR31 has a Popover grow and shrink “on its motion roles”. DEC-11 keeps roles internal without exempting a component, yet POPOVER_PRESENT_SPRING and POPOVER_DISMISS_SPRING play as given here (:373, :440) and stay out of registerSpringsTable's roles (motion/uikit/springs.ts:521-534), so a theme's tempo and character never reach this motion. Registering them through a Popover motion table (DEC-5), or sequencing this after #8258, would conform.

((callback: () => void) =>
typeof view?.requestAnimationFrame === 'function'
? view.requestAnimationFrame(() => callback())
: (callback(), 0));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Without requestAnimationFrame this runs the callback at once (it clears pendingFrame and presents) and then returns 0, which line 335 stores; exit() then reads 0 as a pending frame (line 391) and takes the early-close branch, so the shrink never plays. Returning no handle here, or storing the handle only while the callback has not run, avoids it.

Comment on lines +328 to +335
// The input hold starts with the presentation too, in that frame: a touch
// that starts in the one frame between the open and it (about 16 ms after
// the opening tap lifts) reaches the page, though its tap does not
// dismiss, since its lift lands on the hold. Holding from the open would
// cost two forced layouts in every touch opening (a top-layer cover's
// `showPopover()` lays the page out), the work this frame keeps out of
// the opening task.
pendingFrame = scheduleFrame(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

As this comment says, a touch that starts in the frame before the presentation is not held, while spec:AST-094 FR35 says a tap or a drag during the entrance must not scroll the page or reach anything beneath, and component:Popover FR9 says a touch during the growth is ignored. The claim here that such a tap still does not dismiss has no case, and under a finger the lift's pointerup goes to the element the pointerdown hit (implicit pointer capture), not to a shield shown afterwards, and the popover's light dismiss reads that pointerup. The description asks the owners to accept the window; if they do not, the hold moves back to the open, where #8223 placed it, and if they do, FR35 needs to say so.

'@astryxdesign/core': patch
---

[chore] Internal: one input hold for every layer (`Layer/inputHold`). While a layer presents, a touch, a press or a click anywhere reaches nothing (keys and assistive technology still act, and a pinch still zooms the page); while it dismisses, the same, and the layer's content is inert. The gesture host holds the layer's touches through its touch gates, and `isInputShield` lets a hit test see past a hold's shield. Not exported from the package entry point; no component uses it yet.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Popover holds through this module at this head (popoverSurfaceMotion.ts:380), and .changeset/popover-touch-input-hold.md announces that hold, so “no component uses it yet” contradicts the release's own notes.

This branch was successfully deployed

1 active deployment
Preview — 3f3923a2 Deployed Oct 30, 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 needs:spec-owner-review Current knowledge records await owner approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants