Skip to content

test(interaction-harness): a hold the page arms itself takes a short animation on its first frame - #9230

Draft
vjeux wants to merge 1 commit into
meta:mainfrom
vjeux:test/harness-armed-hold
Draft

vjeux wants to merge 1 commit into
meta:mainfrom
vjeux:test/harness-armed-hold

Conversation

@vjeux

@vjeux vjeux commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Status at d658a05: every run below has finished at this head. WebKit runs use the CI runner's render-thread count (LP_NUM_THREADS set to the container's CPUs); the WebKit reds an earlier version of this description listed came from containers without it and are withdrawn (see Evidence).

Maintainer impact

A proof that holds an animation to seek it, sample it or film it (holdAnimations, sampleCurve, animationStoryboard) sends the hold from the test after the action that starts the animation. The hold reaches the page one round trip later, and on a loaded runner a short animation can end before it arrives: the hold comes back empty, and the proof fails on a correct build. Component proofs that hold a 200 ms press release fail this way on a two-core Chromium pin, and main's own "a second hold adds to the held set, and releasing resumes both" fails this way in WebKit at 16 workers on 3 CPUs and in Chromium with CPU-bound processes on the pinned cores.

Intended invariant

A held animation is held before it can end, however busy the runner: the hold is taken on the page itself, as soon as the animation runs, and joins the same held set holdAnimations builds, so seekAnimations, sampleCurve, releaseAnimations and animationStoryboard read it unchanged (README, "Curve sampling").

Change

  • armHold(page, scope) arms the page before the action and resolves once it is armed; held() after the action returns what the page held. The page holds every running animation under the scope (the whole document without one) as soon as one of them runs, or as soon as count of them run, for parts that start apart. With newOnly, only animations that start after the arm trigger and join the hold, so a turn that starts mid-motion is held while the motion already running goes on. It holds through the same in-page hold body as holdAnimations, which is now installed in the evaluate that installs the curve end, so holdAnimations takes no extra round trip. The busy-window control never triggers a hold, but an unscoped hold, which takes every running animation, pauses it too. held() rejects when nothing under the scope ran within the timeout.
  • The armed loop looks on every frame callback and every 4 ms between them. On a cold page WebKit delivers no frame callback for 65–76 ms while it first composites a newly animated layer (with the CI runner's four render threads; longer under load), while its timers run and the animation advances; the look between frames holds a short animation that much earlier.
  • animationStoryboard takes held, what an armed hold took, instead of holding after the action.
  • The holding, curve-sampling and storyboard self-tests that hold a short animation an action starts arm their holds: a CSS transition sampled onto its cubic-bézier, a spring baked into linear(), a sample at an end, a sample labelled per channel, a pseudo-element's animation, a percentage translate, a second hold, a held transition the page retargets, finishing an endless animation, the animation filmstrip, the endless filmstrip, and the timer-gate filmstrip (whose gate is now frozen before the component's timer is set, so the timer cannot fire before the freeze).
  • Three that hold after a wait or a watch, or hold two runners started 100 ms apart (which an armed hold would take one at a time), run their animations for 6 to 10 s instead of 0.6 to 1 s at the same speed: "only running document-timeline animations are held, each at its own offset", "a reversed run counts its progress from its end…" and "a hold stays where it was read, not a frame later". The two-runner test also makes its second runner once the first has started, so a runner that goes long without a frame cannot give both one first frame, and seeks 150 ms past the second start however late it comes, instead of at a fixed 300 ms.
  • The staggered-starts filmstrip ("a filmstrip runs from the earliest start to the latest end…") sets its second start 83 ms after the first on the document timeline instead of with a timer, which a loaded runner can fire after the first move has ended, and the page holds all three runs once the second move runs (count: 3).
  • Three new self-tests cover the armed hold: a 120 ms animation held on its first frames and sampled; a 120 ms turn held mid-motion with newOnly while a 10 s motion goes on (both read only after the run would have ended); and a rejection when nothing starts.

Prior art: open pull requests whose own diffs touch these four files were checked for the hold, the held set and the storyboard's options; none changes the hold. #9163, #8554 and #8446 edit the README's curves.ts row that this change also edits (a one-line rebase for whichever lands second), and #8446 and #8554 change the recorder in curves.ts. #8549 adds castStoryboard beside animationStoryboard. #8911 adds a self-test that holds a short animation after the action, which an armed hold would steady. #9185 and #9215, merged since, changed the touch-input and wheel self-tests in the harness spec, away from the holding, curve-sampling and storyboard describes; this head is rebased onto them.

Evidence

  • Failure before the change:
    • Component proofs that hold a 200 ms press release (a button's, a card's, a checkbox's and a list row's, in a branch that moves them onto this hold), Chromium pinned to two cores, 4 workers ×10: 26 of 370 runs failed holding after the lift; 370 of 370 with armed holds, on the same build.
    • Main's holding describe, Chromium pinned to two cores with four CPU-bound processes on the same cores, ×10: 9 of 130 failed (a second hold 8, a sample at an end 1). On the pin alone, main's self-tests pass (230 of 230).
    • Main at 6224efd, WebKit capped at 3 CPUs with 16 workers, the curve-sampling, holding and storyboard describes ×4: a second hold failed 2 of 4. Under the CI lane's command (4 CPUs, CI=1, one worker) ×10 main passes them (240 of 240): the WebKit reds listed here before (18 of 240, 22 of 220) came from containers whose software renderer started a thread pool sized for the host, and do not reproduce with the runner's thread count.
  • Success after the change, at d658a05: WebKit under the CI lane's command ×10, 270 of 270 (10 runs skipped by engine); Chromium pinned to two cores ×10, 280 of 280; with the four CPU-bound processes on the pinned cores (holding describe ×10), 150 of 150, measured at an earlier revision of this change.
  • Reported, not required, at d658a05: WebKit at 3 CPUs, 1 worker ×10 over the same describes, 270 of 270; 16 workers ×2 over the whole harness spec, 136 of 136 (26 skipped by engine). Three animation-state cases ("waitForAnimation's progress starts after a transition's delay", "waitForAnimation reports a short animation that ended before it looked", "an entrance the page cancels when it ends keeps its start") fail at 16 workers on main as well (28 of 48 runs on main, 17 of 48 here, ×16 each); they wait on the recorder, not the hold, and are not part of this change.
  • Product/runtime behavior verified unchanged: no product code changes.

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

  • pnpm exec playwright test --config playwright.interaction.config.ts --project chromium internal/interaction-harness/src/harness.interaction.browser.spec.ts --grep "curve sampling|holding|storyboards"
  • CI=1 node internal/interaction-harness/scripts/webkit-container.mjs --config playwright.interaction.config.ts --workers=1 --repeat-each=10 internal/interaction-harness/src/harness.interaction.browser.spec.ts --grep "curve sampling|holding|storyboards", with the container capped at 4 CPUs and LP_NUM_THREADS=4
  • the whole harness browser spec: Chromium pinned to two cores, 81 of 81; WebKit at 3 CPUs, 16 workers ×2, 136 of 136

Local gates at d658a05: Prettier, ESLint, the harness and core typechecks (core's interaction specs included) pass, and the harness and CI-script node tests pass (111 of 111).

@vercel

vercel Bot commented Oct 31, 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 1, 2026 12:21am UTC

Request Review

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

astracat-bot Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 1, 2026, 12:26 AM 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 looks ready to merge: armHold is a contained addition to the internal harness, and holdAnimations keeps its hold body unchanged. A few small follow-ups would make it stronger:

  • In the new armed-hold test, read hold.held() after a wait longer than the 120 ms run, so the test proves the page took the hold itself.
  • Define __harnessHold in the same evaluate that installs __harnessCurveEnd, so holdAnimations reaches the page no later than before.
  • Say in armHold's JSDoc that an unscoped hold still pauses the busy-window control, and list armHold in the curves.ts header.

[Automated review]

{duration: 120, easing: 'linear'},
),
);
const held = await hold.held();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reading held() right after the action lets a hold taken at read time pass on an idle runner. A wait longer than the run, such as await page.waitForTimeout(200);, before this line makes the test prove the page held the 120 ms run itself.

*/
async function installHold(page: Page): Promise<void> {
await installCurveEnd(page);
await page.evaluate(() => {

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 extra page.evaluate runs before every holdAnimations hold, so callers that still hold after an action reach the page one round trip later than before. Defining __harnessHold in the evaluate that installs __harnessCurveEnd keeps the base's number of round trips.

…animation on its first frame

`holdAnimations` reaches the page after a round trip, and on a loaded
runner a short animation can end before it arrives: main's own holding
and storyboard self-tests fail that way (a second hold, a held
transition the page retargets), on a two-core pin with CPU-bound
processes on the same cores too, and component proofs that hold a 200 ms
press release miss it the same way.

`await armHold(page, scope)` arms the page before the action; it holds
every running animation under the scope as soon as one of them runs (or
`count` of them, for parts that start apart; with `newOnly`, only
animations that start after the arm, so a turn that starts mid-motion is
held while the motion goes on), and `held()` after the action returns
what it held. It holds through the same in-page body as
`holdAnimations`, installed in the evaluate that installs the curve end
so a hold costs no extra round trip, and into the same held set, so
`seekAnimations`, `sampleCurve` and `releaseAnimations` read it
unchanged, and a later hold adds to it. It looks on every frame callback
and every 4 ms between them: on a cold page WebKit can deliver no frame
callback for 260 to 300 ms while it first composites a newly animated
layer, while its timers run and its animations advance. The busy-window
control never counts (an unscoped hold still pauses it, as it takes
every running animation), and `held()` rejects when nothing under the
scope ran before the timeout. `animationStoryboard` films what an armed
hold took (`held`) instead of holding after the action. The holding,
curve-sampling and storyboard self-tests that hold a short animation an
action starts arm their holds. Three that hold after a wait or a watch,
or hold two runners started 100 ms apart (which an armed hold would take
one at a time), run their animations for 6 to 10 s instead of 0.6 to 1 s
at the same speed. The two-runner test also makes its second runner once
the first has started, so a loaded engine cannot give both one first
frame, and seeks 150 ms past the second start, however late that came.
The staggered-starts filmstrip sets its second start 83 ms after the
first on the document timeline rather than with a timer, and the page
holds all three runs once the second move runs (`count: 3`). Three new
self-tests cover the armed hold: a 120 ms run held on its first frames,
a turn held mid-motion with `newOnly` (both read only after the run
would have ended), and a rejection when nothing starts.

Red first: on main the holding and storyboard describes fail under the
CI WebKit lane's command (a container capped at 4 CPUs, one worker), the
two holds above among them, and the holding describe fails in Chromium
under the load above; with the armed holds they pass there.
@vjeux
vjeux force-pushed the test/harness-armed-hold branch from 0bfda6b to d658a05 Compare November 1, 2026 00:09
@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

PR Analysis Report

Current run: The current CI run concluded skipped. Current analysis is unavailable.


Generated by PR Enrichment workflow | View current CI run

@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 looks ready to merge: armHold is a contained addition to the internal harness, holdAnimations keeps its hold body and round trips, and the changed holding, curve-sampling and storyboard self-tests pass in both Chromium and WebKit. One optional follow-up: animationStoryboard's JSDoc still says it holds every running animation and must be called right after the start, so say there that held replaces that hold and that scope is ignored when held is passed.

[Automated review]

options: AnimationStoryboardOptions,
): Promise<{storyboard: Storyboard; held: HeldAnimation[]}> {
const held = await holdAnimations(page, options.scope);
const held = options.held

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 held passed, this skips the hold and ignores options.scope, while the JSDoc above still says the function holds every running animation and should be called right after the start. A sentence there saying held replaces the hold and scope would keep a caller from passing both.

vjeux added a commit to vjeux/astryx that referenced this pull request Nov 1, 2026
…wn clock

The bottom placement's two change-of-mind proofs turned the field with a
scripted tap, or a focus, sent once the first motion had run a while. A busy
runner delivered it late, near or past the gel's peak, and there the proofs
checked only part of the claim and recorded a "reduced" annotation instead
of "keeps going, then turns once".

Each proof now arms the turn in the page before the input that starts the
first motion: on the first frame, or 4 ms timer, at which the trailing end's
new motion has run a set time on its own clock (120 ms of the activation;
the cancel's first frame), the page clicks the close button or focuses the
field. The first inputs start in the page too, since a scripted tap's
dispatch can keep the page from looking at the motion for hundreds of
milliseconds as it starts. The turn's own motion is held by an armed hold
that takes only animations starting after it (armHold's newOnly), once they
have run as long as the turn may start at the latest, so no round trip
races a short turn. The reduced branch is gone: every run checks the whole
claim, including that the turn started at least two frames before the peak.

Depends on meta#9230, which adds armHold's newOnly to the interaction harness.

This branch was successfully deployed

1 active deployment
Preview — d658a054 Deployed Nov 1, 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