Skip to content

feat(interaction): a rendering trace records why styles recalculated, and countBetween can leave out Chrome's own compositor animation update - #8554

Draft
vjeux wants to merge 156 commits into
meta:mainfrom
vjeux:uikit/trace-invalidations
Draft

vjeux wants to merge 156 commits into
meta:mainfrom
vjeux:uikit/trace-invalidations

Conversation

@vjeux

@vjeux vjeux commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Review only this PR's change: vjeux/astryx@87e516b...707e7fe

Maintainer impact

A per-frame "no Layout or Paint" proof can fail on a loaded machine for a pass the page never caused: Chrome restyles a compositor-animated element on its own (its start time arriving late), with one Layout and Paint. The ChatLayout keyboard and Popover touch proofs hit it about one run in ten under parallel workers.

Intended invariant

No product change. A rendering trace can tell Chrome's own compositor animation update from work the page caused, and counts the page's work as before.

Change

  • traceRendering(page, body, {invalidations: true}) also records each style recalculation's reason and element, the animations that ran and whether each ran on the compositor, and when script ran.
  • countBetween(trace, from, to, {skipCompositorAnimationUpdates: true}) leaves out a pass whose recalculations all have the reason Animation, on an element with an animation on the compositor at the time, with no script since the previous pass. A pseudo-element's Conditional ::backdrop restyle counts with its element's own, in that pass or the one before.
  • One entry per animation: Chrome reuses an animation's trace id once it is gone (a Popover's exit copy takes its presentation's ids), so entries keyed by id lost the earlier element.
  • README rows.

Stacked on #8446 (the recorder stand-down, on #8548).

Evidence

  • Failure before the change: in the Popover presentation's proof, a pass of Animation and then Conditional ::backdrop on the popover layer counted as page work, because the layer had dropped out of the trace's animations; under two-CPU load, 20 of 24 runs counted it.
  • Success after the change: a recorded trace of that run is a unit case, and the window counts nothing. At moderate load, 0 of 24 runs count it, against 1 of 24 without the option. Under two-CPU load 11 of 24 still count a pass: a frame split by more than 4 ms leaves a pass with no recorded cause.
  • A pass the page caused still counts: script before it, or an element with no animation on the compositor at the time. Reverting any one rule (per-animation entries, lifetimes, the pseudo-element rule, its own-restyle condition) fails a unit case.
  • Product/runtime behavior unchanged: private harness only.

Scope

  • No intended public API, product behavior, visual direction, or policy change.
  • Product changes discovered during the work were removed or split.
  • Public text and artifacts contain no internal Meta context.

Testing

vjeux added 30 commits October 24, 2026 14:37
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.
… 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.
… lift profiled flicks at their velocity

- pr-interaction now always reports and the `test` join requires its
  success (AST-030 FR7, FR11), shaped like test-native-swift: a Linux
  interaction-scope job decides, with the base's scope helper and failing
  closed, whether the harness, a component, Storybook, internal/a11y-spec,
  the workflow, package.json or pnpm-lock.yaml changed. Only then do the
  proofs take a 4-core runner; otherwise, and in merge groups (as with
  pr-a11y), the job passes without work.
- A profiled flick's last move runs at velocity times profile(1), so the
  finger lifts at the documented velocity, and releaseVelocity reads the
  last move instead of a 100 ms window mean.
- Docs: drop the /math subpath this package does not export yet; scripted
  timestamps arrive to 0.1 ms; the job comment describes the self-test it
  runs.
…ed its previous position

A sample that repeats a position is not a move: frames that merge() inserts
for another finger, where this finger did not move, made the last move look
shorter, so a 10 Hz drag merged with a tap read 375 px/s instead of 300 and a
60 Hz drag whose other finger lifted 5 ms before its last move read about
3.6x too fast. The previous sample is now the first one at the previous
position, as the page sees it, with a merged case in scripts.test.ts.
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.
…film no endless curve, let the page cancel gated callbacks

- holdAnimations holds only running document-timeline animations: a
  finished fill animation keeps its end value, an animation the page paused
  stays paused (release resumes only what was held), and a scroll-driven one
  is not seeked. Each held animation keeps its offset from the earliest
  start, so staggered starts stay staggered.
- A sample or storyboard frame at or past an end reports the time it was
  read at (presentedAt, half a millisecond inside the end), and curveOf
  reads oracles there.
- An animation that repeats forever no longer sets a storyboard's default
  length (it ran the worker out of memory); with nothing else to film,
  animationStoryboard asks for times.
- The timer gate queues held callbacks by id, so the page's clearTimeout
  and cancelAnimationFrame still cancel them; a held frame callback gets
  the time it runs; frames(), waitForRest and touchStoryboard use the real
  requestAnimationFrame, so they resolve while frozen.
- The storyboard self-tests carry the @Storyboard tag that
  `pnpm storyboards` selects.
…s from the element's own animations, resume endless ones

- animationStoryboard's default times run from the earliest start to the
  latest curve end among the animations that end, each seek at
  origin + t: beside a spinner that started earlier the animation under
  test no longer shows its start in every frame, and a second animation
  that starts later is filmed to its end.
- A sample's presentedAt (per channel: transform, opacity) comes from the
  sampled element's own animations, by keyframe property, not from any
  animation on the page.
- releaseAnimations({finish: true}) resumes an animation that repeats
  forever instead of finishing it (which throws), and always clears the
  held set.
- The interaction proofs' scope includes packages/themes/*/src/.
…before it looked

waitForAnimation polled getAnimations() after the action, so an entrance
shorter than the first look (a cold page answers its first evaluate late)
had already finished: "no animation started".

The page now records every animation from the first call into curves.ts
on it, and on every later document: a frame loop on its own
requestAnimationFrame, the CSS animation and transition events, and
Element.animate as it is called, pending animations included (WebKit can
start and finish a short script animation between two frames of a fresh
page). waitForAnimation also reports an animation that started after the
last waitForRest or waitForAnimation and has finished, with finishedAt.
watchAnimations(locator) arms the record before an action that may be the
page's first contact with the harness.
…uest

This pull request keeps the curve-sampling instruments: holding and seeking
animations, sampling presented values, the oracles, waiting for an
animation and for rest. The storyboards (storyboard.ts, the evidence
output, the timer gate and the `pnpm storyboards` script) and their tests
move to a pull request stacked on this one.
`holdAnimations` replaced the held set on each call, so a second hold left
the first set paused after `releaseAnimations` (it resumed only the newest
set), and `waitForRest` then reported the frozen frame as rest. A later hold
now adds to the set: everything held stays held until the release, and
offsets count from the earliest start of all of it. The self-test holds two
elements in turn, releases, and checks the first runs to its end; on the
old code it stays frozen 50 px into a 300 px move.

The self-test's header and the `curves.ts` SYNC line no longer describe the
storyboard and timer-gate layer, which moved to its own pull request.
animationStoryboard films held animations by seeking them (t = 0, 16.7,
33.3 … ms, from the earliest start to the latest curve end among the
animations that end, each at its own offset); touchStoryboard takes a
frame per scripted touch event. Each writes a PNG contact sheet and a JSON
trace of its measurements to the evidence directory and attaches them to
the report; the spec asserts on the trace.

The timer gate (installTimerGate, freezeTimers, thawTimers) holds the
page's timers and frame callbacks while a storyboard photographs, so a
component that finishes an animation from a timer cannot do it behind the
held animation's back; the page can still cancel a held callback, and the
harness's own frame waits (and the animation recorder) keep the page's
real requestAnimationFrame. `pnpm storyboards` runs only the tests tagged
@Storyboard.
`touchStoryboard` said velocity-dependent behavior is unchanged in
Chromium. That holds for velocity read from event timestamps, which keep
their scripted times, but a handler that reads `performance.now()` or
`Date.now()` (Item's and Toast's swipes) sees one event per photograph,
about 100 ms apart, so a flick that dismisses when played can settle back
when filmed. The doc and the README now state that limit and point such
gestures at a screencast, and a self-test shows both clocks: scripted
16.7 ms gaps in `timeStamp`, more than two frames in the handler's clock.
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.
…ading them from the animation

The settle-curve oracle took its easing and duration from the animation under
test, so it passed a 900 ms ease-in or a 150 ms linear settle as readily as
the real one. The spec now asserts the theme's values (the default
--ease-standard, cubic-bezier(0.24, 1, 0.4, 1), over the neutral theme's
--duration-medium, 300 ms) and builds the oracle from them; the header no
longer claims 410 ms.
…ebKit frames on its clock

- `blockMainThread`'s `during` path ran its loop inside the rAF callback of
  the frame that saw the animation. WebKit hands a newly started animation
  to its compositor in that frame's rendering update, which runs after rAF
  callbacks, so the loop held the hand-off and even a compositor animation
  froze (2 of 29 frames moved). The loop now runs from a task queued in that
  frame.
- WebKit's screencast frames were rebased onto the wall clock by taking the
  first frame as the start, which shifted every frame earlier by however
  long that frame took to come (60–100 ms). They are now rebased by the
  measured offset between the wall clock and CLOCK_MONOTONIC, which WebKit
  stamps them on.
- `busyWindowProof` fails a judged part shorter than four frames (the
  animation under test was not moving during the block) instead of passing
  it, takes the end value from `settledAt` when given (WebKit can present a
  stale position once an accelerated animation ends while the main thread
  is blocked), and reports where the longest stall starts.
- The BottomSheet busy case arms the block before the release and reads
  where the settle ends from the page.
Retargeting a CSS transition cancels it. If that happened while it was
held, `seekAnimations` and `sampleCurve` set its time again, which revived
it over the page's own new transition, and `releaseAnimations` replayed it
from the start. Seeking, sampling and releasing now drop an `idle` animation
from the held set first, and times count from a fixed origin (the earliest
start held until the release), so dropping one never moves the others. The
self-test retargets a held transition, seeks, releases and waits for rest:
the cancelled transition stays idle and the box ends where the page sent
it. On the old code the cancelled transition is still held after the seek.
# Conflicts:
#	internal/interaction-harness/src/harness.interaction.browser.spec.ts
# Conflicts:
#	internal/interaction-harness/src/harness.interaction.browser.spec.ts
… instrument

`touchStoryboard`'s doc and the README pointed a clock-timed gesture at
`startScreencast` and `castStoryboard`, which this branch does not have. They
now say a storyboard cannot show such a gesture and to record it in real
time.
…cast instruments

With frame evidence in the harness, a clock-timed gesture a storyboard
cannot show is recorded with `startScreencast` and `castStoryboard`: the
doc and the README name them.
`pause()` only schedules the pause: the engine takes the hold time at its
next frame, so a held animation stayed held a frame past `heldAtMs`, and a
read right after the hold varied by a frame between runs. Setting the
current time right after `pause()` completes a pending pause at once, where
`heldAtMs` read it. The self-test holds a 1 s move and checks it is still
at `heldAtMs` two frames later; on the previous code it reads one frame
later in Chromium (16.6–16.7 ms) and 15–70 ms later in WebKit.
# Conflicts:
#	internal/interaction-harness/src/harness.interaction.browser.spec.ts
# Conflicts:
#	internal/interaction-harness/src/harness.interaction.browser.spec.ts
`blockMainThread({during})` counted an animation as running as soon as its
current time reached `minProgressMs`. An animation created in the frame the
page looks is still pending, not on the compositor yet, and at
`minProgressMs: 0` the block started on it and froze it: in WebKit
deterministically, and in Chromium on a Lightbox page while the block still
ran inside the frame's callback. A pending animation no longer counts, and
`minProgressMs` is raised to one frame (16 ms), since in WebKit an
animation's start time resolves before its layer is flushed.

The self-test creates the animation in the frame the helper looks and
blocks at `minProgressMs: 0`. On the previous code it fails 6/6 in WebKit
(0 frames presented through the block); now it passes 10/10 in WebKit and
6/6 in Chromium.

@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.

Requesting changes. The skip rule's unit cases hold, but this branch still carries the four open pull requests it is stacked on (#8045, #8548, #8549, #8446), which make up 4,037 of its 4,890 changed lines, so please land those first, or rebase once they land, so that this pull request carries only the trace-invalidation change. Separately, skipCompositorAnimationUpdates drops rendering passes caused by a test's own page.evaluate, because a DevTools command leaves none of the script events trace.ts records; counting DevTools-command tasks that carry no HARNESS_READ mark as script (the long-task split already detects both), with a unit case, would close that gap.

[Automated review]

(own.has(inv.nodeId) || ownBefore.has(inv.nodeId)))) &&
compositedAt(inv.nodeId, start),
) &&
!scripts.some(t => t > previousEnd && t <= start);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With skipCompositorAnimationUpdates, a pass caused by a test's own page.evaluate is left out. A DevTools command leaves no FunctionCall, FireAnimationFrame, TimerFire or EventDispatch, so this check sees no script.

On the anchored fixture, take a 16 ms seek of the running compositor animation followed by a layout read. Run from a timer, it keeps 2 of its 3 Layouts with the skip; run from page.evaluate, it keeps 0. The README counts that same evaluate as the page's own long task.

Counting a DevTools-command task that carries no HARNESS_READ mark as script would keep the pass; the long-task split below already detects both.

const compositedAt = (nodeId: number | undefined, t: number) =>
nodeId != null &&
animations.some(a => a.nodeId === nodeId && a.composited && alive(a, t)) &&
!animations.some(a => a.nodeId === nodeId && !a.composited && alive(a, t));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nothing pins this rule: removing it still passes all nine trace.test.ts cases, and no browser case runs a main-thread and a compositor animation on one element. Yet it is what keeps a top animation's per-frame layout counted when the same element also runs a transform animation: 24 Layouts kept with it, 0 without it, on the same traces. A unit case with both entries on one node would hold it.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
…'s base

A dependency-only merge, with no conflict: meta#8554 (the rendering trace's invalidations, on cixzhang's meta#8446 87e516b, meta#8549, meta#8548 and meta#8045) with the sheet's ride (meta#8566 on meta#8559 6be79f4 and meta#8076 62ecda1).
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
…ss renders; the base moves to the foundation heads

The base (3f66f76) is the sheet's Gboard ride (meta#8566, on meta#8559
6be79f4, meta#8484 and meta#8076 62ecda1) merged with meta#8554 e4f3c8d, on
cixzhang's meta#8446 87e516b, meta#8549, meta#8548 and meta#8045, with no conflict.

meta#8559 moved `useKeyboardHeight` here, to its first consumer: ChatLayout
lays its dock and transcript padding out from it. Its subscribe and
snapshot functions are now stable for the component's life (per window),
so React keeps one subscription across renders; before, every render
unsubscribed and resubscribed, and a lone reader detached the module's
listeners and measured again on each keyboard change (INV11). Red first:
"reads the layout once per keyboard change for a lone component"
expected no listener re-added and one read, and got two `addEventListener`
calls. The hook's tests (one re-render per change, a keyboard already up,
StrictMode's replay leaving no listener) and the README's layout example
come with it.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
meta#8176 takes useKeyboardHeight from meta#8559 (one subscription across renders) and moves its base to the foundation heads (meta#8566 on meta#8559 6be79f4, meta#8554 e4f3c8d). A base move: the dismissal's own change applies on it unchanged.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
…nothing

Its last line expected readLongTasks' answer toBeDefined, which every array
is. Since meta#8554's readLongTasks fails closed (852d55c), that read throws in
WebKit, which has no Long Tasks API, and the storyboard went red there. The
line asserted nothing, so it goes. The Chinese hasZoom description now
matches the English one: on a touch screen a double tap zooms to the tapped
point; a double-click or a zoom key zooms 2x about the centre.
@github-actions

github-actions Bot commented Oct 27, 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

No component packages changed.

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

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…eta#8554 e4f3c8d) into ChatLayout's keyboard work

# Conflicts:
#	packages/core/src/keyboard/README.md
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…eta#8554 e4f3c8d, main 0cbddea) into ChatLayout's keyboard work

# Conflicts:
#	packages/core/src/Chat/ChatComposer.test.tsx
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
@cixzhang cixzhang closed this Oct 28, 2026
@cixzhang cixzhang reopened this Oct 28, 2026
@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf: closed and reopened only to restart this PR's CI on the current workflow after tonight's queue-wide cancel; nothing in the PR changed.

…ocuments the invalidations trace

The trace.ts row's longer text re-padded the whole module table, so it clashed with every branch that adds a row there (the wheel and pen drivers' webInput.ts row). The table goes back to its base, and a paragraph after "No Layout / Paint" documents traceRendering's {invalidations: true} and countBetween's skipCompositorAnimationUpdates.

@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.

Requesting changes. The skip rule's unit cases hold, but this branch still carries the stack it is built on (#8548, #8549, #8446, and a copy of #8045 that now conflicts with main), which makes up 4,063 of its 4,898 changed lines, so please land those first, or rebase once they land, so that this pull request carries only the trace-invalidation change. Separately, skipCompositorAnimationUpdates leaves out rendering passes a test's own page.evaluate causes, because a DevTools command leaves none of the script events trace.ts records; counting a DevTools-command task without the HARNESS_READ mark as script, with a unit case, or documenting the limit would close that gap. A unit case with a composited and a main-thread animation on one node would also pin the rule at trace.ts:176.

[Automated review]

(own.has(inv.nodeId) || ownBefore.has(inv.nodeId)))) &&
compositedAt(inv.nodeId, start),
) &&
!scripts.some(t => t > previousEnd && t <= start);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With skipCompositorAnimationUpdates, a pass a test's own page.evaluate causes is left out: a DevTools command leaves no FunctionCall, FireAnimationFrame, TimerFire or EventDispatch, so this check sees no script.

On the self-test's anchored fixture, a script that seeks the running compositor animation by 16 ms and reads getBoundingClientRect() lays out 4 times; with the skip it keeps 2 when run from a page timer and 0 when run from page.evaluate.

Counting a task that ran a DevTools command without the HARNESS_READ mark as script (the long-task split below already detects both), with a unit case, would keep the pass; otherwise the README and the header should state the limit.

const compositedAt = (nodeId: number | undefined, t: number) =>
nodeId != null &&
animations.some(a => a.nodeId === nodeId && a.composited && alive(a, t)) &&
!animations.some(a => a.nodeId === nodeId && !a.composited && alive(a, t));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nothing pins this clause: without it all nine trace.test.ts cases still pass, and no browser case runs a main-thread and a compositor animation on one element. Yet it alone keeps a top animation's per-frame layout counted when the same element also runs a transform animation: 24 Layouts kept with it, 0 without, on the same traces. A unit case with a composited and a main-thread entry on one node would hold it.

@vjeux
vjeux marked this pull request as draft October 29, 2026 22:37
cixzhang pushed a commit that referenced this pull request Oct 31, 2026
…mator (#8076)

Behavior-module PR: internal touch foundation, animator, press tracking

Stacked on #8071, one commit on `main`, with no merge commits: review only this pull request's two commits, vjeux@0ddfa8937e and its test vjeux@91d49e1438.

## Why

Touch components need Core's animator, press tracking and gesture host without making them public.

## What it absorbs, and why

One solution, the internal touch foundation, so these close with a link here:

- #8073 (closed): UIControl press tracking, its press profiles and the hooks. Everything else here rests on it.
- #8340: the animator's start and hold. No frame shows where a motion is going, a call can wait for a presented frame, and in Safari a start from rest moves past a block of over two frames in the task that created it. It brings the held dip's load fixes from #8360 too.
- #8641's rule (approved): a gesture that cancels itself while its finger moves keeps that finger until it lifts.

## Left out

- Nothing here rests on the undecided mouse or pen drag selection (#8665), wheel ending (#8676) or an open owner ruling.
- #8446's harness changes land with #8554.
- The StyleX `defineConsts` build fix and the stylesheet's undefined-variable check stay in #8075.
- `RotationGestureRecognizer` and `HoverGestureRecognizer` are not exported: nothing uses them, and rotation's threshold is not measured.
- `paintPress` and its held state land with the action-group tracker, `useAnimator` with Lab's SearchField, and Core's internal entry points for Lab (#8562's change) with the first Lab component that uses them: each with its first user.

## What changes

- Press tracking follows UIControl and is built by `createPressTracker`: the press clocks and profiles, `usePressTracker`, and release velocity on UIKit's filter (`spec:AST-100` FR1, FR2).
- The animator holds the value on screen inline while a curve runs, starts a motion from rest when the browser commits it, offers `start: 'presented'` and `'commit'`, additive reversals and `handle.curve`. A browser proof counts no Layout or Paint while a spring plays, on the harness's rendering trace.
- The motion modules and recognizers load in plain Node, with no StyleX module in their graph.

## How to check it

1. Run `pnpm exec vitest run scripts/motion-node-load.test.mjs`: the motion modules and recognizers load in a plain Node process.
2. In a component using `usePressTracker`, change `highlightDelay` while mounted: the next touch uses the new delay.
3. Run `pnpm test:interaction animator.interaction.browser.spec.ts` in Chromium and WebKit: a motion started right before a blocked main thread plays from its start after the block.

## Evidence

- Local gates at 91d49e1: build, type check, `pnpm test:ui` (16,704), the node suites, lint, Storybook, and the motion and gesture interaction specs in Chromium (103) and WebKit (96); the held-dip, block and no-Layout-or-Paint proofs pass six times each at eight workers.

## Credit

Co-authored-by: Cindy Zhang (her commits in #8073 and #8076).

## Scope

- [x] Unrelated fixes, cleanup, and policy changes were removed or split.
- [x] Public text and artifacts contain no internal Meta context.



Behavior-module PR: LOC split waived per maintainer rule.

This branch was successfully deployed

1 active deployment
Preview — 707e7fe9 Deployed Oct 29, 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