Skip to content

chore(core): useAnimator and the animator's browser proofs on the interaction harness - #8067

Closed
vjeux wants to merge 429 commits into
meta:mainfrom
vjeux:wip/uikit-pr-m4
Closed

vjeux wants to merge 429 commits into
meta:mainfrom
vjeux:wip/uikit-pr-m4

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8056 and #8446 (on #8045, and their predecessors); review its own commits.

Why

Whether touch motion keeps moving while the page's JavaScript is busy can only be shown in real browser engines, and unit tests with a fake clock cannot catch a spring that freezes for a second on a phone. Components also need the animator without a React render on every frame.

What led to it

This comes from the work to make Astryx's touch components behave as native iOS ones do (sheets, menus, gestures and their motion). spec:AST-094 FR5 asks motion to start at its commit and play whole, delayed rather than skipped or flashing when the main thread or compositor is busy.

Options and tradeoffs

  • Prove it in Storybook stories: realistic, but every run needs a Storybook build.
  • Prove it on a blank fixture page with the real animator: no build, and nothing else on the page to blame.
  • Compare frames to the curve with a fixed tolerance per frame: simple, but WebKit's screencast clock is rebased, so absolute times differ.

Why this choice

The fixture page keeps the proofs fast enough for CI's interaction job, and the curve check inverts the curve at each frame and requires one time offset for all frames (in WebKit, only a small spread), which works in both engines.

What changes

  • useAnimator() returns a stable {ref, animator}, so a component drives motion with no re-render; unmounting settles its handles.
  • __tests__/animator.interaction.browser.spec.ts runs in Chromium and WebKit and proves that a slow spring advances on every frame through a 1 s main-thread block, a spring that ends inside the block keeps showing its end state, no Layout or Paint runs while it animates, and a dialog's ::backdrop follows the curve.

How to check it

  1. Run pnpm test:interaction animator.interaction.browser.spec.ts in Chromium, and in WebKit through internal/interaction-harness/scripts/webkit-container.mjs.
  2. Each proof reports the frames it read: every expected frame moved, the longest stall is one frame interval, and the seeked curve matches within 0.05 px.

Evidence

  • Chromium: 57 of 57 expected frames moved; curve offsets spread 3 to 6 ms around 0.
  • WebKit: 69 to 70 expected frames all moved; offsets spread 7 to 9 ms.
  • With the end-state hold removed, WebKit's frames show 301 → 16 → 301; with it, every frame stays at 300.
  • The hook is internal and not exported; the changeset is [chore].

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

@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 10:41am 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
🟡 Reviewed (for maintainers only) Oct 27, 2026, 10:59 AM UTC

@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

No new or modified components detected.

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 waits on an owner decision: the draft spec:AST-094 adds touch-presentation requirements (FR1–FR23) that no current record states and amends current claims in theme-tokens (INV3), user-states (DR1) and AST-025 (FR14), so an engineering owner needs to approve, narrow or reject it by commenting /approve-spec <full head SHA> on this pull request (the author may do so, being an engineering owner). Separately from that decision, three changes are needed:

  • Under Reduce Motion, an animate() that moves an element while its opacity is animating ends at the on-screen opacity, or at 0 with reducedMotion: 'instant', instead of the fade's target (packages/core/src/motion/animator.ts lines 879, 957 and 1067).
  • The description needs the ## Writing rubric results that the knowledge-contracts architecture asks of a pull request that creates a record.
  • Against main this pull request still carries its eight predecessors (11,461 authored lines); landing them first leaves its own 564 lines to review.

[Automated review]

Comment thread packages/core/src/motion/animator.ts Outdated
landsAt,
);
}
next.opacity = opacity;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Under prefers-reduced-motion this always writes next.opacity, even when the caller gave no opacity target. In 'instant' mode it is null, and the commit loop (lines 1067–1068: segments.delete before the valueAt fallback) then resets opacity to its value from before a running fade, so a move during a fade-in leaves the element at opacity 0. In 'crossfade' mode the dip returns to the on-screen opacity from line 879 instead of the fade's target. Ending the dip at finalValue('opacity'), leaving opacity alone in 'instant' mode, and reading the fallback before deleting the segment would keep a running fade intact.

…g running

Landing a waiting Reduce Motion move on the node just detached rebuilt
every channel the landing touched there, so an axis still moving (a coast
on y while x waited in a dip) was baked onto the detached node again and
promoted it, leaving an animation, timers and will-change behind. After the
landing, the unmount now cancels what runs, clears the retire timers and
demotes, before the target goes back to none. The test fails on the
previous head.
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.
…aves the detached node idle)) as a dependency

@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 are needed before this can merge. The current spec:AST-094, design:reduced-motion and react-component-runtime records on main cover the motion code here, so no spec decision is pending, and the animator's browser proofs pass in Chromium and WebKit. Against main, though, the branch still carries seven open predecessors (#8056, #8521, #8522, #8045, #8548, #8549 and #8446): 9,978 authored lines, of which this pull request's own are 732, so landing those first, or restacking onto them, is the next step.

  • The deferred unmount in animator.ts (lines 2276–2282) now lands a waiting Reduce Motion move on the detached node, and when another translate axis is still coasting that commit promotes the node again, so will-change: translate, an animation and two timers stay on it after the unmount (the will-change survives a later re-attach); cancelling what the landing started and calling demote() before restoring target, as dispose() does, would fix it, and the test at animator.test.ts line 1877 should await the end of the task before it asserts.

[Automated review]

Comment thread packages/core/src/motion/animator.ts Outdated
Comment on lines +2276 to +2282
const current = target;
target = detached;
try {
landPending(false);
} finally {
target = current;
}

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 lands the waiting move on the detached node, but landPending() commits, and rebuild() re-animates any channel whose other axis is still moving: with y coasting from a decay (momentum keeps moving under Reduce Motion) while x waits for its dip, the translate channel gets a new animation on the detached node, promote() writes will-change: translate there, and a retire and a demote timer start, all after the synchronous cleanup further down has run. Once target is null again, demote() writes nothing, so the inline will-change stays, also after a later re-attach of the same node. Cancelling running, clearing retireTimers and calling demote() in this finally, before restoring target (as dispose() does after its own landPending), would release them.

await commitFrame(clock);
await advance(20);
animator.attach(null);
expect(element.style.willChange).toBe('');

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 reads willChange right after attach(null), before the deferred unmount's microtask runs, so it passes while the microtask then writes will-change: translate on this element and leaves two timers. Awaiting the end of the task first (await Promise.resolve()), then checking willChange and vi.getTimerCount(), would catch it.

cixzhang and others added 8 commits October 26, 2026 14:58
…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.
…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.
…the latest detached node)) as a dependency
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>

@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 are needed before this can merge, for scope alone: the current spec:AST-094, design:reduced-motion and react-component-runtime records cover the code here, the animator's browser proofs pass in Chromium and WebKit, and the earlier unmount issue with a coasting axis is fixed. Against main, though, the branch still carries seven open predecessors (#8045, #8056, #8446, #8521, #8522, #8548 and #8549): 10,054 authored lines, of which only 648 sit in files this pull request alone changes. Landing those first, or restacking onto them, leaves only the hook, its test and the browser proofs.

[Automated review]

…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.
… awaits its microtask)) as a dependency

@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 are needed before this can merge, for scope alone: the current spec:AST-094, design:reduced-motion and react-component-runtime records cover the code here, and the animator's browser proofs pass in Chromium and WebKit on this head. Against main, the branch still carries seven open pull requests (#8045, #8056, #8446, #8521, #8522, #8548 and #8549): 10,057 authored lines, of which only 648 sit in files this pull request alone changes, and its copy of the interaction harness is already behind #8446 and #8548. Landing those first, or restacking onto them, leaves only the hook, its test and the browser proofs.

[Automated review]

@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 are needed before this can merge, for scope alone: the current spec:AST-094, design:reduced-motion and react-component-runtime records cover the code here, and the animator's browser proofs pass in Chromium and WebKit on this head. Against main, the branch still carries six open pull requests (#8045, #8446, #8521, #8522, #8548 and #8549): 5,829 authored lines, of which 732 are this pull request's own. Landing those first, or restacking onto them, leaves only the hook, its test and the browser proofs.

[Automated review]

…tor's browser proofs

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

@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 are needed before this can merge, for scope alone: the current spec:AST-094, design:reduced-motion and react-component-runtime records cover the hook and the browser proofs, and those proofs pass in Chromium and WebKit on the current branch. Against main, the branch still carries five open pull requests (#8045, #8446, #8548, #8549 and #8522): 5,376 authored lines, of which 736 are this pull request's own. Landing those first, or restacking onto them, leaves only the hook, its test and the browser proofs.

[Automated review]

@vjeux

vjeux commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Closed as carried by #8360: useAnimator and the animator's browser proofs are there, its animator plays each role through resolveMotion, and the Reduce Motion proof reads the dip while it runs (fe7f5f9). The remaining review item was ordering. Branch kept.

This branch was successfully deployed

1 active deployment
Preview — 2068fa24 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants