Skip to content

fix(core): a channel rebuilt after a late first frame carries on from the screen - #9637

Merged
cixzhang merged 1 commit into
meta:mainfrom
vjeux:uikit/f1-animator-carry-on
Nov 2, 2026
Merged

cixzhang merged 1 commit into
meta:mainfrom
vjeux:uikit/f1-animator-carry-on

Conversation

@vjeux

@vjeux vjeux commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

User impact

A follow-up to #9401 (merged) in the touch presentations' animator, which is internal and drives no shipped component yet, so the changeset is [chore]. When a call's first frame comes late and leaves its start unread, #9401 reads the call from that frame. A rebase, a set or a retarget that starts now could still rebuild one of that call's channels in that window: the rebuilt animation then started from its first keyframe at the next commit, so the screen stepped back and replayed the curve.

Problem and solution fit

  • Why it steps back. A curve that waits for the browser's start stays in its call, and a rebuild made while it waits joins that call: no start of its own, keyframes from the call's time, started at the next commit. Before the call's first frame, that is right, since nothing has moved on screen. After a first frame that left the start unread, the compositor already has the channel moving, so starting it again from its first keyframe steps back.
  • The fix. Such a rebuild carries the channel on from the screen. Its curves leave the waiting call and take a start of their own, dated from that first frame as the browser's start would date them. The rebuilt animation starts now, from the value on screen, so the screen does not step back.
    • A rebase() builds its new spring from the value on screen at its own time, shifted by its delta, and starts it then. Another curve, such as a coast, keeps its course, dated from that frame.
    • A set() of another property rebuilds the channel, and the carried curve continues.
    • A call made while such a channel is on screen counts as continuing motion, as for a resolved call, so it starts now instead of waiting for its own commit. That covers a retarget of the other axis, which before took the moving curve into its own new call and started it again.
    • A curve on screen ends where that frame dates it, so a call that still waits past the end counted from its own time keeps the curve instead of dropping it to its target.
    • A waiting call whose last channel carries on starts at that frame, and its handle's started reports that time.
  • What stays. The call's other channels keep waiting for the browser's start, which still decides for them. A call that joins the waiting call by its clock still rebuilds the channel from its first keyframe at the next commit, as in fix(core): a retarget after a late first frame continues from the screen #9401, since it asks for that call's start; so does a call that asks for the browser's start (start: 'commit'), which no shipped caller passes.

Expected behavior and authority

spec:AST-094 FR6, as #9255 and #9401 hold it: a retarget made before the page can read a call's start begins where the screen shows the element, and the screen never steps back.

Smallest restoration

Within the animator: a new leaveWaitingCall takes a curve on screen out of its waiting call, re-dated from that call's first frame, and starts a call left with no channel at that frame; rebuild, for a call that starts now, and rebase use it; beginGroup treats such a channel as moving; and endOf dates a waiting curve's end from that frame for segmentAt, rebuild's settled curves and beginGroup. Nothing else changes: the first-frame recording, the reads of #9401, the explicit-clock join, resolveGroup and the start of a call from rest are as in #9401.

Evidence

  • Unit: three cases in animator.test.ts change from fix(core): a retarget after a late first frame continues from the screen #9401's restart to carrying on, and three are new; each fails on main with fix(core): a retarget after a late first frame continues from the screen #9401 (4a7a6f2) and passes here:
    • "a rebase after a late first frame carries the channel on from that frame, with a start of its own": {x: 300}, a first frame at 1070 with the start unread, and rebase({x: -16}) at 1170. The rebuilt animation starts at 1170 from the value on screen less 16; the read, the inline value and a zero presented offset agree; a frame later the read and a retarget follow the rebuilt animation.
    • "a set of the other axis after a late first frame carries x on from that frame": the same with set({y: 10}); x keeps its value and velocity.
    • "a retarget of the other axis without a clock after a late first frame carries x on from that frame": the same with animate({y: 50}); the call starts at 1170, x carries on and y starts from rest.
    • "a call whose only channel carries on after a late first frame reports that frame as its start": its handle's started resolves at 1070.
    • "a rebase of a coast after a late first frame keeps its course from that frame": a decay, rebased at 1170, starts then from the value on screen less 16 and follows its course.
    • "a channel whose call waits past its undated end after a late first frame still carries on from the screen": a set({y: 10}) 30 ms past the end counted from the call's own time keeps x on its curve, short of its target.
    • fix(core): a retarget after a late first frame continues from the screen #9401's other late-first-frame cases pass unchanged, including the explicit-clock join and the two-channel cases where the untouched channel keeps reading from its own first frame.
  • Known limit: the carried start is the first frame's time, within about a frame of the compositor's start (fix(core): a retarget after a late first frame continues from the screen #9401's measurement), so a carried channel can still step by up to a frame of motion where main replayed the whole curve.

Status: this head differs from df98ca7 only in one comment. On this head the animator's unit file (215), Prettier and strict lint pass. On df98ca7, on main with #9401, the type check and the unit suites of the motion, Switch and Slider modules (915) pass. On the same commit on #9401's last head (fbaec3e): build, type check, test:ui (17,809), check:repo, ASTRYX_STRICT_LINT=1 pnpm lint (0 errors), check:changesets and changeset coverage pass; the node suites pass but for one CLI test (doctor --built) that timed out at 30 s under load and passes 3 of 3 alone; the animator browser spec passes ×3 in Chromium pinned to 2 cores (54 of 54) and ×10 under the CI WebKit job's command (140 passed, 40 skipped by design).

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 exec vitest run --project ui packages/core/src/motion/animator.test.ts
pnpm test:interaction packages/core/src/motion/__tests__/animator.interaction.browser.spec.ts --project chromium --workers=1

@vercel

vercel Bot commented Nov 2, 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 Nov 2, 2026 10:12pm UTC

Request Review

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

astracat-bot Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 2, 2026, 10:18 PM UTC

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · baa77fe

Storybook · Sandbox · built from this PR's head

Package size: @astryxdesign/core 5.82 MB packed (+1.4 kB); no new dependencies.

Not run on this PR: add ci:run-visual, ci:run-a11y or ci:run-rtl to run them.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The animator fix itself holds up: the six new and changed cases fail against the base animator and pass here, and the change restores the no-jump retarget that spec:AST-094 FR6 requires. Before approval, please correct the description and the commit message: on this branch and on main no component uses the animator yet, so the Switch snap-back, its P14 reproduction and the Switch.interaction.browser.spec.ts command belong to #9636, not to this change. Say that the animator drives no shipped component yet, as #9401 did, and write beginGroup where the description says findGroup; the title and changeset can stay as they are.

[Automated review]

Comment thread packages/core/src/motion/animator.ts Outdated

/**
* When a waiting curve's call had its first frame, which put the curve on
* screen. Null for a curve that does not wait or whose frame has not run.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: shownSince returns undefined, not null. Suggest: "Undefined for a curve that does not wait, or whose call has had no first frame."

… the screen

After a call's first frame came late and left its start unread, the
animator records that frame's time per channel and reads the curve from
it. A rebase, a set or a retarget that starts now could still rebuild
one of those channels: the rebuilt animation joined the waiting call and
started at the next commit from its first keyframe, so the screen
stepped back and replayed the curve. The animator drives no shipped
component yet.

Such a rebuild now carries the channel on. Its curves leave the waiting
call and take a start of their own, dated from that frame as the
browser's start would date them, and the rebuilt animation starts now
from the value on screen. A rebase builds its new spring from that value
at the rebase's own time; another curve keeps its course. A call that
starts while such a channel is on screen counts as continuing motion, so
it starts now instead of waiting for its own commit. A curve on screen
also ends where that frame dates it, so a call that waits past its own
time's end keeps it. A waiting call left with no channel starts at that
frame, which its handle reports. The call's other channels keep waiting
for the browser's start, which still decides for them. A call that joins
the waiting call by its clock still rebuilds the channel from its first
keyframe at the next commit.
@vjeux
vjeux force-pushed the uikit/f1-animator-carry-on branch from df98ca7 to baa77fe Compare November 2, 2026 22:00
@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in baa77fe: the description and the commit message now say the animator drives no shipped component yet, as #9401 did. The Switch snap-back, its P14 reproduction and the Switch.interaction.browser.spec.ts command are gone; they belong to #9636. The description says beginGroup. shownSince's comment now reads "Undefined for a curve that does not wait, or whose call has had no first frame", as you suggested.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The carry-on fix holds up: the six new and changed animator cases fail against the base and pass here, the Chromium animator browser spec passes, and the change restores the no-jump retarget that spec:AST-094 FR6 requires. The description and commit message now match what the change reproduces. Two optional comment suggestions are inline, one noting that attach() of a new node takes the same branch without a test; it looks ready once it leaves draft.

[Automated review]

}
const moving = properties.filter(p => segments.has(p));
if (callGroup == null) {
// A call that starts now rebuilds a channel the compositor has moving

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: this branch runs for every rebuild with no call group, so attach() of a new node takes it too (line 3382); the comment names only a call that starts now. Suggest: "A rebuild outside a call from rest can meet a channel that moves on screen since a first frame with no known start. That channel carries on from the frame, with its own start." A unit case for the attach path would pin it.

// A curve still waiting for the browser's start stays in its call:
// it starts with that call's commit.
// it starts with that call's commit. A curve whose call had its first
// frame is on screen already, so it leaves the call (leaveWaitingCall).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: "(leaveWaitingCall)" repeats the name of the function this block calls below. Suggest: "A curve whose call had its first frame is on screen already, so it leaves the call."

@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 2, 2026
@cixzhang
cixzhang marked this pull request as ready for review November 2, 2026 22:42
@cixzhang
cixzhang merged commit 240e8c5 into meta:main Nov 2, 2026
107 checks passed
@vjeux
vjeux deleted the uikit/f1-animator-carry-on branch November 2, 2026 23:05

This branch was successfully deployed

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

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) 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.

2 participants