Skip to content

fix(core): a call from rest creates its animations in the next frame - #8266

Closed
vjeux wants to merge 31 commits into
meta:mainfrom
vjeux:uikit/f1-start-in-frame
Closed

vjeux wants to merge 31 commits into
meta:mainfrom
vjeux:uikit/f1-start-in-frame

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8076 (and its predecessors); review the last commit.

User impact

On iOS, a motion started right after a React commit (a row removed, a node
expanded) often appeared most of the way through: the first frame after the
commit's work already showed 36–76 % of the travel.

Problem and solution fit

  • WebKit dates a pending animation created before busy work in the same task
    to the frame before that work, so the curve runs while nothing paints.
  • The user sees a jump instead of the spring that explains the change.
  • Nearly every component motion starts right after a commit.

A call from rest without {at} now promotes in the calling task (as before)
and creates its animations in the next animation frame, which starts them.

Expected behavior and authority

spec:AST-094 FR5: a motion starts at its commit, and a busy main thread
delays it rather than skipping into it.

Smallest restoration

  • animate, play and decay from rest create in the next frame;
    handle.started reports that start; the curves read from the call time
    until then, as before.
  • Anything else that changes the animator before that frame (set, stop, an
    explicit start, rebase, attach, dispose) creates the held animations
    first, in call order.
  • Retargets of moving motion and {at} stay immediate.
  • Also: the promotion timer no longer rereads the clock when it fires (a test
    faking timers but not the clock kept it rescheduling).

Evidence

  • Reproduction before: iOS 26.2 simulator, Mobile Safari, 60 fps; a 250 px
    spring, then 600 ms of work in the same task. The first frame after
    the work shows 36–76 % of the travel in 6 of 17 runs.
  • Result after: at most 2.8 % in 12 of 13 runs, 12 % in one.

Before (top): the box jumps most of the way. After (bottom): the whole spring plays

The small square flashes while the main thread runs and freezes during the
work.

  • Unchanged path: an idle task starts on the same frame as before.
  • Regression proofs: the browser proof "a spring started right before a block
    plays in full after it" now passes in WebKit too (first moving frame 1 px in
    both engines); unit tests cover next-frame creation, the flush, and the
    promotion timer.

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

  • pnpm test:interaction packages/core/src/motion: Chromium 8 passed; WebKit (container) 7 passed, 1 skipped (tracing is Chromium-only).
  • pnpm exec vitest run --project ui packages/core/src/motion packages/core/src/gestures: 34 files, 594 tests passed.
  • Strict lint and prettier: clean.
  • Size: the last commit is 4 files, 193 lines added, 43 removed.

Stack (merge in this order): #8316 spec → #8053 constants table → #8058 → #8059 → #8060 → #8061 gesture recognizers → #8054 math → #8257 theme motion roles → #8055 animator → #8056 animator curves → #8067 browser proofs → #8258 theme motion in the animator → #8071 → #8072 → #8073 → #8074 gesture system → #8076 experimental subpaths → #8266 start in frame (#8067 also follows the interaction harness, #8043 → #8044 → #8045; #8062 merges any time after #8316).

vjeux added 30 commits October 23, 2026 20:05
Adds internal/interaction-harness (@astryxdesign/interaction-harness) and
playwright.interaction.config.ts: phone-sized touch projects in Chromium and
WebKit for *.interaction.browser.spec.ts specs.

Touch scripts are data (tap, drag with hold and rest, flick with an exact
release velocity and velocity profiles, hold-then-drag, pinch, rotate, any
path, a second finger mid-gesture), played as real engine input: CDP
Input.dispatchTouchEvent with scripted timestamps in Chromium, WebKit's own
input command in WebKit. The harness's self-test proves the timestamps, a
native fling and multi-touch delivery; a pr-interaction CI job runs the
proofs against each PR's Storybook build, and a container script runs the
WebKit project where host libraries are missing.
Springs, scroll physics, timing curves, navigation values and gesture
thresholds for the touch presentations of core components, each with its
source (Apple documentation, a WWDC session, WebKit's UIKit-matched source,
or a measurement of UIKit on the iOS 26.2 simulator) or labelled a guess
with its measurement task. A test enforces the source rules on every domain
file, in their JSDoc and typed forms, and checks the list of simulator
measurements still owed.
Pure functions for the touch presentations: a closed-form damped spring
with every Apple parameterization and Core Animation's settling duration,
a spring chasing a moving target, UIScrollView's coast, rubber band and
release projection. Tests hold them to an RK4 integration, WebKit's spring
solver, the WWDC conversion equations and values read from UIKit, and play
the constants table through them.
…rena

An internal gesture system for touch presentations, not exported from the
package. GestureRecognizer carries UIKit's states and allowed transitions,
discrete and continuous recognizers, require-to-fail, simultaneous
recognition on request, shouldBegin and shouldReceiveTouch,
cancelsTouchesInView and the touch delays. GestureArena settles each touch
sequence without the DOM: participants, resolution order, claims, delivery
to the view, click dispositions (allow, suppress, defer and replay) and
timers as deadlines on the event clock. Its rules are tested with scripted
recognizers, independent of any real one.
…sholds

Tap recognizes at the lift within 45 pt of movement, taps 0.35 s and 45 pt
apart, and a 1.5 s hold; long press begins at exactly touch-down + 0.5 s on
the event clock within 10 pt, even when the main thread was blocked, and a
zero duration begins on the touch itself. The arena tests with these real
recognizers cover tap against double tap, delegate requirements, deferred
clicks and cancellation.
Touch behaviours key on a coarse primary pointer, layout behaviours on a
compact viewport, and presentations that need both (a sheet in place of a
popover) ask for both. utils/touchPresentation.ts holds the three queries,
SSR-safe hooks (useTouchPresentation; useMotionPresentation for components
with no interaction to infer from: UIKit motion under a finger, theme
tokens under a mouse) and a one-moment read. Styles write the same queries
as StyleX media keys, and a test keeps core's compact-touch keys spelled as
the shared query.

A compact viewport is narrower than 744 px or shorter than 500 px, the
closest a size gets to UIKit's compact size class: an iPad mini in portrait
keeps the anchored popover and a phone in landscape gets the sheet; a
Max-size iPhone in landscape is the known miss. The adaptive popover-or-
sheet presentation reads the shared query; its docs, stories and tests
follow.
Pure functions for the touch presentations: a closed-form damped spring
with every Apple parameterization and Core Animation's settling duration,
a spring chasing a moving target, UIScrollView's coast, rubber band and
release projection. Tests hold them to an RK4 integration, WebKit's spring
solver, the WWDC conversion equations and values read from UIKit, and play
the constants table through them.
createAnimator plays springs and UIScrollView coasts as Web Animations
keyframes on translate, rotate, scale and opacity, on one explicit clock, so
they keep running while JavaScript is busy. Presentation values and
velocities come from the curves, so a finger can catch any animation and a
new target keeps the velocity; the end state is committed inline and held by
the keyframes through a main-thread block, and a finished animation retires
when its own time reaches the curve's end, so a sampler can pause it; Reduce
Motion substitutes UIKit's cross-fade.
… Chromium

The Chromium driver sent a lift of one finger of several as a touchMove
listing the fingers still down, assuming CDP releases a point a move
omits. It does not: the lifted finger stayed down. A touchEnd that lists
the lifting fingers releases exactly those while the others keep moving,
so the driver sends that, and a self-test lifts one of two fingers and
checks the release, the other finger's moves and the touchend's
remaining touches.

Playwright's WebKit sends that touchend with no remaining touches, which
iOS never does. The harness does not work around it: drivers expose
partialLifts, and PARTIAL_LIFT_SKIP is the reason a proof cites when it
skips; the README lists it with the other engine limits.
Pause every Web Animation, seek it to a time and read the presented
transform and opacity, then compare the curve with oracles that share no
code with the motion under test: cubic-bezier, the SwiftUI spring
(response, dampingFraction, initial velocity), UIScrollView deceleration
and UIKit's rubber band. Storyboards photograph seeked animations at
t = 0, 16.7, 33.3 ... ms or each step of a scripted touch, and write a PNG
contact sheet plus the JSON trace a spec asserts, under the gitignored
test-results/interaction-evidence. A timer gate holds a component's own
timers while a filmstrip is taken. pnpm storyboards runs the tagged tests.
…sented values

An animation that keeps its end value in a run of identical final
keyframes (held so a later interruption can read it) reported its whole
duration as the curve: storyboards filmed the hold, waitForAnimation's
interval reached past the landing, and waitForRest, which also required
every animation to leave the running state and read only transform, timed
out. The curve now ends where that run starts (HeldAnimation.curveEndMs,
waitForAnimation's to, the default storyboard times), and rest is no
animation pending or before its curve's end plus the presented transform,
translate, rotate, scale and opacity (and ::backdrop opacity) still for 3
frames, whatever the playState. sampleCurve composes translate, rotate and
scale with transform, so curves of the individual properties are sampled.
Timestamped composited frames (Chromium's screencast and WebKit's own),
a marker the frames can find, and the busy-main-thread proof: block the
page's main thread mid-flight and show the element still moving every
frame, with a requestAnimationFrame animation that freezes as the
negative control. Chromium trace counts of Layout, Paint and style work
inside an animation window, and long tasks. The exemplar spec proves the
BottomSheet drag to a detent with all of it: the drag follows the
finger, a flick dismisses, the settle matches its timing function, keeps
advancing through a blocked main thread in Chromium and WebKit, does no
Layout or Paint, and gets a drag and settle storyboard.
…ed machine

The proof asserted a stall under 40 ms and near-exact frame counts, so a
dropped screencast frame or two on a loaded runner failed it, and frames
delivered out of order read as the element stepping backwards.
busyWindowProof now returns a verdict that tolerates what load does to a
screencast and nothing a frozen animation does: a median frame interval
of at most 20 ms, no stall longer than three screencast intervals, and
at least 90% of the window's whole frame intervals presenting a new
position (one miss always allowed), with the raw numbers in a summary.
Screencast frames are sorted by presentation time, the cadence comes
from the frames around the window, and blockMainThread can block from
inside the page in the frame an animation has run, so a short settle is
still judged at length on a busy machine. The exemplar judges its settle
until it is within 2 px of rest, since the screencast rounds to whole
pixels. A unit test covers the limits; the rAF negative control still
fails every criterion.
The pan begins past 10 pt on either axis and leaves that travel out of its
translation, as UIPanGestureRecognizer does; axis and one-way directions
with axisRatio, axis lock, finger changes without jumps, scrollEdge, and an
opt-in non-UIKit scrollHandoff. VelocityTracker defaults to UIKit's filter
(0.2 x the newest event's velocity + 0.8 x the previous one's), which
reproduces every value UIKit reported on recorded streams.
…zers

The rest of UIKit's recognizer set on the gesture system: swipe recognized
during the motion, pinch measured from the starting span moved by its
hysteresis, unwrapped rotation, edge pans resolved against the writing
direction that fail at touch-down outside their band, and hover for mice
and hovering pens.
The touch-action an element gets from its recognizers (pinch-zoom stays
unless a pinch or rotation recognizer is attached), what the scrollers
under a touch could still do along the scroll chain, and UIKit's haptic
feedback kinds on the web: navigator.vibrate where it exists, the switch
tap haptic in iOS Safari.
…s takeover

One host per document routes fingers (touch events, paired with their
pointer events), mice and pens to the recognizers on the hit path. A
recognizer that owns a touch cancels its touchmove through a non-passive
listener; pointercancel, an uncancelable move or a scroll mean the browser
took the touch; a claimed touch's touchend is cancelled and its click
swallowed, a deferred click replayed when its recognizer fails.
pointerIdOf names the pointer behind a tracked contact, so a held finger
can be handed to code that follows pointers.
… and hooks

PressTracker applies UIControl's events and its 70 pt tracking margin, the
content-touch delay inside scrollers, the quick-tap flash and the scroll
brake, with two profiles: control (UIButton) and cell (a row: delayed
highlight everywhere, cancelled past the pan hysteresis, no flash).
paintPress is the one writer of data-astryx-press and never clobbers a held
row. installPressAttribute paints every marked surface with one tracker
per touch, picks the profile from the marker's value and releases on its
clock. useGestureRecognizer attaches a recognizer with a stable identity
and no render per move.
The same touch scripts run in Chromium (CDP touches with timestamps) and
WebKit (its own touch input): touch-action per element, claims over a
scroller, no ghost click, scroll edge, pinch on its element only, a second
finger, and a long press at exactly 500 ms under a blocked main thread.
The README documents the system for component authors.
…tarts at the commit

play() bakes curves given as value and velocity functions of time (a spring
chasing a moving target, a fitted UIKit curve) like a spring.
createAnimator(dialog, {pseudoElement: '::backdrop'}) drives a
pseudo-element: a static filling animation holds its end value.
animatorFor(element) shares one animator per element, so callers animating
different axes compose in one translate track; atRest() waits out every
curve on its properties, and rebase() moves them by a layout shift keeping
their springs. A call from rest gets no start time, so the browser starts it
at its commit, as Core Animation does, and handle.started reports that
start. Every animation and set() promotes its channels to a compositing
layer in the same task (will-change, or a host custom property for a
pseudo-element) until 500 ms after the last motion: Mobile Safari only runs
a script-started curve through a block on an element that already has a
layer. Under Reduce Motion the substituted cross-fade can follow a
presentation's own spring, and crossfadeAs gives a slide that changes no
opacity its direction. The header lists the iOS Safari limits measured on
the simulator.
…eraction harness

useAnimator wraps the animator for React without re-rendering per frame.
Browser proofs on the interaction harness, in Chromium and WebKit on the
phone profile: a spring keeps advancing on every screencast frame through a
1 s main-thread block and every frame sits on its analytic curve; a spring
ending inside the block keeps its end state; no Layout or Paint runs while
it animates; the engine plays the curve when seeked; a finished animation
leaves within a frame or two of the curve's end; a dialog's ::backdrop
follows the curve.
A touch presentation is now also an idiom: ios for WebKit on iOS or iPadOS
under a coarse pointer (CSS.supports('-webkit-touch-callout: none'), which
no other engine implements), default everywhere else, Android included,
which keeps today's behaviour until a Material variant exists.
useTouchPresentation and readTouchPresentation report it, useTouchIdiom
reads it alone, and useMotionPresentation now chooses UIKit motion only in
the iOS idiom. Styles gate the same way with two nested StyleX keys, the
media query outside and the supports query inside (IOS_TOUCH_STYLE_KEYS),
as core's 16px input floor already does; a test keeps every such key in
core spelled and nested that way. A real-engine spec reads Chromium as an
Android phone and WebKit as default.
Every spring, duration (a key ending in Ms) and curve of a motion table is
a motion role named by its table path (springs.sheetPresent,
timing.curves.easeInOut, sheet.present). defineTheme({motion}) takes
springs, durations and curves keyed by role, at the root or in an
adaptation rule, and writes one CSS variable per role it sets
(--motion-springs-sheet-present: 0.6s 0.1); a role it leaves out keeps the
table's measured value. The duration scale becomes all-or-nothing so a
theme can set roles alone. Adaptation rules gain a platform condition (ios
or other) that lowers to @supports (-webkit-touch-callout: none) around the
media block. Gesture thresholds and the arbitration timings (the
content-touch delay, the scroll brake) are not roles.

The motion docs carry a table of every role with its CSS variable and
default, generated from the tables (pnpm generate:motion-roles, checked in
check:repo).
# Conflicts:
#	packages/core/src/motion/README.md
…me sets it

resolveMotion(element, role) reads a motion role's CSS variable from the
element's theme scope (the nearest [data-astryx-theme], or the root) when a
motion starts: a style read, no layout, cached per scope until a theme
attribute changes, theme CSS is inserted or edited, the window resizes or a
pointer, contrast or reduced-motion preference changes. An empty or
malformed value, a detached element or a value that is not a role gives the
table's value back. The animator resolves every spring it is handed, the
Reduce Motion fade's duration, curve and spring, and a decay's edge bounce,
so call sites keep passing table entries. A browser proof seeks two boxes
animated with springs.sheetPresent, one inside a theme that sets it: each
plays its own spring within 0.05 px in Chromium and WebKit.
…tations

@astryxdesign/core/motion/experimental exports the compositor animator
(createAnimator, useAnimator), the spring, rubber-band and cubic-bezier math,
and the UIKit constants table as the read-only uikit namespace.
@astryxdesign/core/gestures/experimental exports the gesture recognizers,
useGestureRecognizer, usePressTracker, the touch presentation resolver and
playHaptic. Both follow spec:AST-017's experimental boundary: never
re-exported from the package root or an ordinary subpath, @experimental on
every exposed declaration, stability: 'experimental' docs. The internal
gestures barrel gets no subpath.
WebKit dates a pending animation created before busy work in the same task
(a React commit, layout effects) to the frame before that work, so the
first frame after it already shows most of the curve. A call from rest
without an explicit start now promotes its element in the calling task and
creates its animations in the next animation frame, which starts them;
handle.started reports that start and the curves read from the call time
until then. A set, stop, explicit start, rebase, attach or dispose before
that frame creates the held animations first, in call order. The browser
proof of a spring started right before a block now runs, and passes, in
WebKit too.

The promotion timer no longer rereads the clock when it fires: a test that
fakes timers but not the clock could keep it rescheduling forever.
@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 24, 2026 4:55pm UTC

Request Review

@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 24, 2026, 5:26 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.

This change is on hold for an engineering owner's decision on draft spec:AST-094, the only spec it and its stack cite: the record is unapproved, its FR5 does not state the start-at-first-frame rule this fix implements, and its FR4 and DEC-1 say touch motion is not themable while the stack ships theme motion roles. An engineering owner records that decision by amending the record as needed and commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner. Independently of that decision, two changes are needed:

  • Reduce the diff to this pull request's own commit once its predecessors land; it currently carries about 28,500 authored lines from the whole stack.
  • Drop the platform adaptation condition, or amend spec:AST-012 first: its FR2 limits when to width, pointer, contrast and motion.

[Automated review]

* is everything else. Pair `ios` with `pointer: 'coarse'` for a touch
* profile, such as touch motion tuned per platform.
*/
platform?: 'ios' | 'other';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A platform condition adds a fifth adaptation field, but spec:AST-012 FR2 limits when to width, pointer, contrast and motion and says future environment fields need an explicit addition to that closed vocabulary. Please drop this axis (and its @supports output) or land an AST-012 amendment first.

measures (0.55 for a scroll view and a sheet), and a release MUST pick its
rest point by projecting its velocity the way its UIKit counterpart does:
with a deceleration rate, or with a measured projection time.
- **FR4 — Physics are not themable in touch presentations.** A touch

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FR4 says touch motion is not themable, and DEC-1 rejects tuning touch motion per theme, yet the stack ships defineTheme({motion: {springs, durations, curves}}) for exactly these roles. One of the two needs to change before this record can be approved.


### Motion

- **FR5 — Motion runs on the compositor.** Every release and every

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 pull request cites FR5 for a motion starting at its commit, with a busy main thread delaying it rather than skipping into it, but FR5 as written covers compositor playback through a main-thread block, not when a motion from rest starts. If that rule is intended, it needs its own requirement.

expect(element.style.translate).toBe('300px 0px');
});

it('a set, a stop or an explicit start before that frame creates the held call first', async () => {

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 test is named for a set, a stop or an explicit start, but it exercises only the explicit start, and it also passes against the previous animator. The flushes in set, stop, rebase, attach and dispose have no test.

@github-actions

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Modified Components

ContextMenu (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 639 -
Complexity N/A Very High (84) -
MoreMenu (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 87 -
Complexity N/A Low (4) -
MultiSelector (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 2626 -
Complexity N/A Very High (338) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.9KB 1.2KB

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

46 of 496 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
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
RichTextEditor Markdown parity: side by side probe dark 94,866
RichTextEditor Markdown parity: side by side probe light 89,762
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
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
ProgressBar With Target Mark probe dark 7,895
ProgressBar With Target Mark probe light 7,895
Markdown Default neutral dark 7,573
Markdown Default neutral light 7,566

and 26 more.


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

@vjeux

vjeux commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #8340, which makes a start on a presented frame an opt-in (start: 'presented') instead of a default deferral.

@vjeux vjeux closed this Oct 25, 2026
@vjeux
vjeux deleted the uikit/f1-start-in-frame branch October 25, 2026 01:34

This branch was successfully deployed

1 active deployment
Preview — d9b41740 Deployed Oct 24, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant