Skip to content

fix(core): a touch press's release lasts until its fade has played - #8890

Draft
vjeux wants to merge 1661 commits into
meta:mainfrom
vjeux:uikit/c11-press-release-end
Draft

vjeux wants to merge 1661 commits into
meta:mainfrom
vjeux:uikit/c11-press-release-end

Conversation

@vjeux

@vjeux vjeux commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Review only this PR's change: vjeux/astryx@540299d...78f233b (depends on #8360).

User impact

Under a finger, a pressed control's highlight (a button, switch, checkbox, card, menu row or list row) should fade out after the lift. A tap usually starts work, so the page's script often delays the fade's first frame; the release then ended mid-fade, and the highlight cut off.

Problem and solution fit

What led to it: this is part of a program to make Astryx's list components behave like iOS tables on phones. Moving a list row's release onto the compositor (#8077) showed it being cut short whenever script delayed its first frame.

Why: the touch press controller removed fading on a clock started at the lift. The release animation's end moves later with its first frame; the clock didn't. Why it matters: the press's last frames are the feedback that the tap registered, and on a busy tap they vanished.

Now, when the clock runs out, the controller looks for the press's finite release animation: on the surface (or its ::before or ::after), or on a part inside it whose nearest press owner is the surface, for a component that draws its release there (#8233's checkbox draws it on its box). A press owner nested inside keeps its own release. While one runs or still waits for its first frame, the release ends when that animation finishes or is cancelled; with none, it ends at the clock. Under reduced motion the clock is capped by the Reduce Motion cross-fade's duration (200 ms, read from its constant), and the overlay's release animation is capped the same way.

Size (gzip, base → head, the repository's size report run against #8360): the 86 components that load the touch press controller +334–465 B of import (Button +411 B); every other core component +38–83 B, the shared Reduce Motion constants the overlay now reads; component CSS +22–46 B, the package stylesheet +23 B.

Expected behavior and authority

usePressFeedback ("fades the release over 200 ms"); spec:AST-094 FR5 (the release plays the press model's duration and curve); design:user-states DR1/DR5; design:reduced-motion DR2 (a touch transition finishes in 200 ms or less).

Smallest restoration

  • A longer clock: still a guess, and a long block outlasts it.
  • An animationend listener per surface: every surface wires it, and a surface that unmounts mid-release never fires it.
  • Read the release animation off the element when the clock runs out (chosen): one place, every surface, no new API.

Evidence

Scope

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

Testing

  1. pnpm vitest run --project ui packages/core/src/utils/pressFeedback.test.ts: the eight cases under "the release ends with its animation, not its clock".
  2. pnpm vitest run --project node scripts/build-css.test.mjs: the overlay's release, capped under reduced motion, on the controller's clock.
  3. With fix(Item): a list row's touch press release runs on the compositor #8077 on an iPhone or the iOS simulator: freeze the page for 0.7 s on the lift and press a list row; the highlight fades through the freeze to the end.

What was run: the unit tests of the press surfaces (utils, Button, Switch, Checkbox, Card, Item, List, RadioList, menus), the stylesheet test, pnpm check:repo and the core type check. These are local runs; no CI run is expected for this pull request.

Vercel's failed deployment on e2c10d2 was on Vercel's side: its build command passes locally on that commit, and #8077's head, which contains it, deployed.

cixzhang and others added 30 commits October 26, 2026 14:40
The README's busy-main-thread paragraph and busyWindowProof's JSDoc said an
undelivered frame is never a miss; when fewer than minJudgedFrames frames
were delivered in the judged part, its span counts every frame. One clause
in each says so.
…ose finds it

The unmount kept the node of the first attach(null) in the task, so after
attach(null), attach(next), attach(null) it landed a waiting Reduce Motion
move on the node already replaced, and next stayed at its dip's opacity.
A dispose right after attach(null) landed on no element, leaving the
detached node at opacity 0. The latest detached node is now kept while its
unmount is pending; the unmount and dispose both land there. Two tests
fail on the previous head.
…that Effect

The isEnabled sync's silence covered recognizer listeners but not
usePressTracker: a press-tracked control inside an element whose double tap a
rerender turns off heard touchUpInside (and its un-highlight) from that
layout Effect (react-component-runtime INV2). Silencing it would leave the
control highlighted, so the tracker's callbacks that arrive during a sync
run in a microtask just after it, in order: the press still ends and
un-highlights, outside the Effect.

Tests, failing before (the events arrived inside the Effect): a press
waiting on an ancestor double tap, alone and with a single tap waiting on
it, reports nothing from the Effect that turns the double tap off, then
touchUpInside and the un-highlight just after.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…ile it ran

A page opened after startCompositorTrace and closed before stop() was still
credited: its frames are in the browser-wide record, and neither check saw
it. The record now watches for new pages from start to stop, in the page's
own context and through the browser's target discovery in every context,
and stop() throws if one opened. Negative controls: a page opened and
closed mid-record, and one in another context, both make stop() refuse;
alone, it records.
…rns to joins that dip

Under Reduce Motion a call that names an opacity while an earlier dip's
move still waits to land took the dip branch. So a call naming the opacity
the dip already returns to, with no travel of its own, opened a second dip
from where the first had got to: mid fade out the element faded out twice,
and in the dip's own task the browser got a second, identical fade. A swipe
row's release does exactly that: its move's layer plays the travel as a
dip, then its state layer lands opacity 1 on the same element in the same
task. Such a call now joins the waiting dip: the planner returns joins, the
fade goes on as it was, the call takes the landing over and settles when
the dip ends, and the call it replaces lands with it, each value keeping
the call whose move it is. A call that names travel, or another opacity,
plans as before. Four tests (the planner's, mid dip, in the dip's task,
and a release played then landed) fail on the previous code.
…aiting dip returns to joins that dip; the internal changesets stop counting their users) into the presented start
@eslint-react/naming-convention-ref-name: the test component added in
07b3383 held its refs as outer and inner, two lint errors that failed
CI's ESLint step. Renamed outerRef and innerRef; no other change.
Inside a trace, waitForAnimation and watch.started() read the element through
handles, which find no element rather than wait for one, so a wait on an
element the page removed rejected with "no animation started on the
element" instead of "the element is not in the document", unlike the
untraced path. At its deadline, a traced wait with no element now rejects
as missing. Traced twin of the missing-element self-test; it fails on the
previous code.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…med or not

The planner's comment on the cross-fade's length said it is not a theme
role and no theme lengthens it. Under spec:AST-094 DEC-11 no motion role is
themeable: the fade's length is no role at all, and nothing lengthens it.
A promotion keeps the stylesheet's will-change reasons beside its own, and
read them with getComputedStyle as it started: in a drag's first move, that
forces a style recalculation inside the input handler. The stylesheet's
value is now read in a task of its own after the element is attached, and
again after each demotion, so a promotion uses what was read. One that
comes before that read (an element attached in this task that moves at
once) still reads it then. A test that counts style reads in the promoting
task fails on the previous code; one for the element that moves at once
passes on both.
…orts after that Effect

react-component-runtime INV2 keeps caller callbacks out of Effects, and the
isEnabled sync and a replaced node's detach did it by silencing every
listener for the arena pass they start. A single tap waiting on a double
tap recognizes in that pass when the double tap is turned off or its node
replaced, so its caller never heard the tap. The hook cases expect that
caller to hear it once, just after the Effect, reading the recognized
state; the arena case expects GestureArena.deferResolution to fail the
stopped recognizer at once and resolve the recognition waiting on it in a
pass a microtask later. Fail on this head.
… edges only in their edge zone, an off-zone edge pan decides at 10 px) into the experimental touch layer
…aims an axis only in its edge zone; the promotion's style read in its own task; the planner's DEC-11 line) into the presented start
The isEnabled sync and a replaced node's detach silenced every listener for
the arena pass they start (react-component-runtime INV2), so a single tap
waiting on a double tap they stopped recognized in that pass and was never
reported. Delaying the listener call alone would hand it a recognizer the
arena has already reset to possible.

GestureArena.deferResolution(body) runs code outside input that stops
recognizers without resolving what the stops unblock (the recognitions
waiting on them, held touches, resets); a pass a microtask later, at the
last event's time, resolves it, or the next input does first. Both hook
syncs run inside it, reaching the arena through recognizerInternals: the
stopped recognizer's own cancel stays silent, and a recognizer that
required it to fail recognizes outside the Effect, where its listener
reads the recognized state. The README and the isEnabled option say so.
…rred unmount runs

It read will-change right after attach(null), before the unmount's
microtask, so it passed while that microtask could still promote the
element and leave timers behind. It now awaits the microtask, then checks
will-change and that no timer is left. It fails on meta#8521's earlier head
(97436b2), before the unmount cleared what the landing rebuilt.
…ith one passive listener and press-time document listeners

family:action-groups FR5 (and the AST-094 FR19/FR24 exception proposed with
it): the behavior applies to every pointer, mouse and pen included, so a
group must hear a press where the primary pointer is fine, where the host
otherwise registers nothing. attach() takes { actionGroup: true }: under a
fine pointer the group element gets one passive pointerdown listener; a
press on it adds document pointermove, pointerup and pointercancel
listeners (and window blur, plus the click listener for the click the lift
owes), and they go at the release. Only the group's own recognizers and
consumer take that press. Under a coarse pointer the host's usual listeners
serve the group, as every other recognizer.

Tests (failing first): idle under a fine pointer, only the group's one
passive pointerdown and nothing for a recognizer that is not a group; a
press adds the document listeners and the release removes them; a
recognizer that is not a group on the press path takes no part; detach
removes the group listener, and a coarse pointer keeps the usual
listeners.
…tion) as a dependency

The action group's press filter reads acceptsTouchInput through internals(),
as the rest of the gesture host does now that the getter is protected.
…on) into the host line

The host keeps both fine-pointer exceptions: perEventPointer (this PR's
press-only tracker) and actionGroup (meta#8309's). Conflicts were the two
option docs, useGestureRecognizer's option read beside meta#8309's deliver
queue, and two test files' neighbouring additions.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested: against main this pull request still carries all of #8360 beside the press-release fix, 27,679 authored lines across 176 files instead of the fix's 461, so please land #8360 first and then update this branch so it contains only its seven files. The fix itself, including the controller now reading only the Reduce Motion fade's own value, matches the current press-profile, pressed-state and reduced-motion specs. Once the branch is updated it still needs the package size report the stylesheet spec (AST-080 FR8) asks for, which CI writes when it runs. Separately, #8360's PressTracker is still a class outside the recognizer family that INV13 of the React component runtime record allows; that is best settled in #8360.

[Automated review]

vjeux added 2 commits October 29, 2026 18:21
…e surface

releaseAnimationsOf counted only the press owner's own animations (on it,
or its ::before or ::after). A component that draws its release on a
descendant, such as CheckboxInput's release on its box wrapper's ::after,
then lost the end of it: fading went at the 200 ms clock while the 250 ms
release still ran, and the frame it went relaid and repainted the box.

It now also counts a CSS release animation on a descendant whose nearest
press owner (closest [data-astryx-pressable]) is this element, the same
nearest-owner rule the layers read by, so a press owner nested inside
plays its own press's release and never extends this one.

Red first: a unit case whose release runs on a box inside the row found
fading gone at the clock on the change before this one; it now holds to
the release's end. A second case keeps a nested press owner's release,
on it or inside it, out of the row's.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
meta#8106's current head: the Reduce Motion fade read on its own (meta#8890), a
divided-rows press case (meta#8077) and an interactive spacious case (meta#8085).
A clean merge.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
…ide lands when its retire runs first

Reported as a WebKit hang of the held-dip proof under an 8-worker run:
the proof's wait for the call's finished never settled. Instrumented,
the page kept drawing frames (about 60 a second) while the element sat at
opacity 0 where it started, with no animation left: the held fade out had
been finished from outside, then retired (cancelled) by its timer before
the frame that reads the call's start, so the call never started and its
landing was never armed. This case orders the retire timer before that
frame and fails on this head: the move never lands.
… that cannot come [skip ci]

The retire timer cancels a finished animation once the main thread runs
past its curve. When a sampler finished a call's animations before the
browser started them, a loaded page could run that timer before the
frame that reads the call's start: the cancel left only idle animations,
the start was never read, and the call's Reduce Motion landing was never
armed, so the element stayed hidden where it started and the call never
settled. The retire now reads such a call's start from the finished
animation before cancelling it, and with nothing left to wait on the
call keeps its own clock, so its landing still runs.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
…ase counts when this is its nearest press owner, and meta#8360 d0f5fd5) into the meta#8118 review base
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
…eta#8360 d0f5fd5) into the press release

meta#8890's release end now counts a descendant's release animation when
this element is its nearest press owner, so a control whose layer is a
child's (a checkbox's box wrapper) keeps `fading` until that layer's
release ends. [skip ci]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested: against main this pull request still carries all of #8360 beside the press-release fix, 27,825 authored lines across 176 files instead of the fix's 496, so please land #8360 first and then update this branch so it contains only its seven files. The fix itself, including the new wait for a release drawn inside the surface, matches the current press-profile, pressed-state and reduced-motion specs, but the changeset's "(a checkbox's box)" example describes a release no component draws yet (CheckboxInput's runs on its row), so please drop or correct it. Once the branch is updated, the size report should give each changed component's figures against main, as the stylesheet spec (AST-080 FR8) asks; the description's ranges name only Button. Separately, #8360's PressTracker is still a class outside the recognizer family, which INV13 of the React component runtime record reserves classes for; that is best settled in #8360.

[Automated review]

'@astryxdesign/core': patch
---

[fix] Under a finger, a press's release now lasts until its fade has played. The touch press controller ended the release on a clock started at the lift, so when the page's script delayed the fade's first frame, the release was cut short mid-fade. It now ends when the press's release animation ends, on the surface or on a part a component draws it on inside the surface (a checkbox's box), or on its clock where no release animation runs (an instant release, a surface without one, or an engine without Web Animations). This applies to every surface the touch press controller drives: buttons, switches, checkboxes, cards, menu rows and list rows.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The "(a checkbox's box)" example names a release that does not exist yet: CheckboxInput runs its release animation on the row (interactionOverlayStyles.pressedAlpha in CheckboxInput.tsx), and the box's ::after only paints the row's inherited strength. Dropping the parenthetical, or naming a component once one draws its release inside a surface, keeps the changelog accurate.

vjeux added 2 commits October 29, 2026 20:02
The changeset, the release lookup's comment and its unit case named a
checkbox's box as a part that draws its release inside the surface. No
component in this change does: the checkbox's release runs on the row
around it. The lookup still counts a release on a descendant whose
nearest press owner is the surface; the text now says that without the
example.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
…he descendant release) into the indicator press layer [skip ci]
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
…eta#8360 d0f5fd5) into the press release

meta#8890's release end now counts a descendant's release animation when
this element is its nearest press owner, so a control whose layer is a
child's (a checkbox's box wrapper) keeps `fading` until that layer's
release ends.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
…ndant release) into the fine-pointer press paint

The release clock's reduced-motion cap reads the Reduce Motion fade's
own export, as meta#8890 now does, beside this branch's role-read clock.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
…ming table

The flash and release clocks resolved their roles from the timing table's
press group, which bundled the whole group into the controller: meta#8890's
check that the controller ships the Reduce Motion fade and none of the
table's other rows failed. They now resolve the same keys from a local
table holding the two values the timing table does (100 ms, and the
stylesheet's 200 ms release), so the clocks read their roles and only
those values ship (architecture:component-style-authoring INV6). [skip ci]
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested: against main this pull request still carries all of #8360 beside the press-release fix, 27,875 authored lines across 176 files instead of the fix's 496, so please land #8360 first and then update this branch so it contains only its seven files. The fix itself matches the current press-profile, pressed-state and reduced-motion specs, and the changeset now describes it without the checkbox example. Once the branch is updated, the size report should give each changed component's figures against main, as the stylesheet spec (AST-080 FR8) asks; the description's ranges name only Button. Separately, #8360's PressTracker is still a class outside the recognizer family, which INV13 of the React component runtime record reserves classes for; that is best settled in #8360.

[Automated review]

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 31, 2026

This branch was successfully deployed

1 active deployment
Preview — 78f233bd 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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants