Skip to content

fix(interaction-harness): a run held while its resume is pending keeps its place at any playback rate - #9109

Merged
cixzhang merged 2 commits into
meta:mainfrom
vjeux:harness/hold-pending-rate
Oct 31, 2026
Merged

cixzhang merged 2 commits into
meta:mainfrom
vjeux:harness/hold-pending-rate

Conversation

@vjeux

@vjeux vjeux commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Maintainer impact

holdAnimations placed a run whose play was still pending (resumed, or just made, with no start time yet) as if it ran at rate 1. A run at another playback rate, held before its start time resolved, was then seeked and sampled at the wrong place: at rate 2, a run resumed 400 ms into its curve landed 400 ms further along it than the hold found it, in Chromium and WebKit.

Intended invariant

Held runs keep their timing relative to one another: a seek to t moves each held run to its own t − offsetMs through its playback rate (HeldAnimation.offsetMs, seekAnimations in internal/interaction-harness/src/curves.ts), so a seek to the moment of the hold leaves every held run where the hold read it.

Change

  • A pending run's start on the document timeline is now now − currentTime / playbackRate (a rate of 0 counts as 1, as seeking reads it), not now − currentTime. A run at rate 2 covers its curve in half the timeline, so it started half as long ago.
  • The other timing paths already read the rate: the recorder's start and finish dating, waitForAnimation's dates and progress, seekAnimations and sampleCurve.
  • A new self-test resumes a rate-2 run from the hold's own first read of the timeline's time, caught once on the prototype that defines it, so its resume is still pending when the hold takes it, and seeks to the moment of the hold: both held runs must stay where the hold read them, to a millisecond. The animation recorder never reads the timeline's time; it lists the page's animations itself, at install and on every frame, so a trigger there resumed the run too early. Both runs last 60 s, as a hold on a loaded runner can come seconds after the runs were made.

Prior art: open pull requests searched for holdAnimations, seekAnimations and curveEndMs. #8535, merged, dated finished runs; #8446 changes the recorder while a trace runs. Neither changes how a hold reads a run's start.

Evidence

  • Failure before the change: the new case fails every run in Chromium pinned to two cores (12 of 12) and in a WebKit container capped at 3 CPUs at 16 workers (12 of 12), the faster run 400 ms off.
  • Success after the change: 24 of 24 pinned to two cores, 40 of 40 under the WebKit cap, and 10 of 10 uncapped in each engine; the existing hold cases pass 5 of 5 runs each in both engines.
  • 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 "resume is still pending"
  • node internal/interaction-harness/scripts/webkit-container.mjs internal/interaction-harness/src/harness.interaction.browser.spec.ts --grep "resume is still pending"
  • the harness browser spec in both engines

Local gates at da89ae6: Prettier, ESLint and the harness typecheck pass on the changed files; the root build passes; the core UI suite passes; the node project passes except two process-timing cases in packages/cli/foundation/release/cache.test.mjs, which pass when that file runs alone (34 of 34); the harness browser spec passes in Chromium and WebKit; the new case passes 24 of 24 in Chromium pinned to two cores, 40 of 40 in WebKit capped at 3 CPUs at 16 workers, and 10 of 10 uncapped in each engine.

…s its place at any playback rate

holdAnimations took the start of a run whose play is still pending (no start
time yet) as now less its current time, which holds only at rate 1. A run at
rate 2 covers its curve in half the timeline, so a run resumed 400 ms into its
curve and held before its start time resolved was given a start 200 ms too
early, and a seek to the moment of the hold put it 400 ms further along its
curve than the hold found it, in Chromium and WebKit. Its start now reaches
back through its playback rate, as seeking and sampling read it.

A new case resumes a rate-2 run inside the hold's own read of the page, so
its resume is pending when the hold takes it, and seeks to the moment of the
hold: both held runs stay where the hold read them, to a millisecond. Before
this change the faster run lands 400 ms off in both engines.
@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 Error Error Oct 31, 2026 5:56am 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) Oct 31, 2026, 6:04 AM UTC

@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Preview availability: Storybook and Sandbox are not both ready on the exact-head Vercel preview. CI concluded failure.

No new or modified components detected.

Bundle Size Summary

No component packages changed.

Accessibility Audit

Status: The accessibility audit did not run on this pull request. It runs after merges to main and before each release; pull requests that touch components run it with the ci:heavy-lanes label.


Generated by PR Enrichment workflow | 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.

The holdAnimations fix is correct: the new case fails against the old formula and passes with the new one, and the existing hold cases still pass. One change is needed, in the new self-test. Its resume fires in the recorder's first getAnimations() read during installRecorder, not in the hold's own read, so the run is still pending at the hold only if two page round trips finish before the browser resolves the play; when they are slower, heldAtMs reads about 467 and the exact toBe(400) fails on correct code. Triggering the resume from a read only the hold makes, such as document.timeline.currentTime (which holdAnimations reads first and the recorder never does), keeps the run pending whatever the round trips cost; please update the test comment and the description to match.

[Automated review]

await new Promise(resolve =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
// Resumed inside the hold's own read of the page's animations.

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 resume does not fire inside the hold's read. On this fresh page, the first getAnimations() call after the override is the recorder's initial record(), which holdAnimations reaches through installCurveEnd → installRecorder, two page round trips before its own read. When those round trips are slow, the run already has a start time at the hold, and heldAtMs is no longer 400 (line 2139). A trigger on document.timeline.currentTime, which holdAnimations reads first and the recorder never reads, resumes the run in the hold's own task:

const {get} = Object.getOwnPropertyDescriptor(AnimationTimeline.prototype, 'currentTime')!;
Object.defineProperty(document.timeline, 'currentTime', {
  configurable: true,
  get() {
    delete (document.timeline as {currentTime?: unknown}).currentTime;
    faster.play();
    return get!.call(document.timeline);
  },
});

…its run inside the hold, and runs long enough

The self-test resumed its rate-2 run from a one-shot patch of
document.getAnimations, but the harness's animation recorder, which the hold
installs, lists the page's animations itself, at install and on every frame:
it took the patch, and the run resumed a frame or more before the hold, no
longer pending (2 cores: 16 of 24 runs failed, held at 433 to 500 ms). The run
now resumes from the hold's own first read of the timeline's time, caught once
on the prototype that defines it; the recorder never reads it, and a property
on the timeline object itself can be lost with its wrapper. Both runs also
last 60 s: on a loaded WebKit runner the hold came after #box's 2 s run had
ended (20 of 20 failed).

Pinned to two cores it passes 24 of 24, and 40 of 40 in a WebKit container
capped at 3 CPUs at 16 workers; with main's hold it fails every run in both,
the resumed run 400 ms off.
@vjeux

vjeux commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in da89ae6, two causes in the self-test:

  • The resume fired in the recorder's first getAnimations() read (its install calls it, and every frame does), not in the hold's: it now fires from the hold's own first read of document.timeline.currentTime, caught once on the prototype that defines it (on the timeline object itself it was lost once in 20 WebKit runs, with its wrapper).
  • On a loaded WebKit runner the hold came after #box's 2 s run had ended: both runs now last 60 s.

Pinned to two cores it passes 24 of 24, and 40 of 40 in WebKit capped at 3 CPUs at 16 workers; with the old formula it fails every run in both, 400 ms off.

@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Oct 31, 2026
@cixzhang
cixzhang marked this pull request as ready for review October 31, 2026 06:01

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

The holdAnimations fix and its self-test are ready: the new case fails against the old formula, passes with the new one, and the existing hold cases still pass. The earlier concern is resolved, since the resume now fires from the hold's own document.timeline.currentTime read, so the run is still pending when the hold lists it however slow the page round trips are. Nothing further is needed for this change.

[Automated review]

@cixzhang
cixzhang merged commit 47dd87e into meta:main Oct 31, 2026
132 of 135 checks passed
@vjeux
vjeux deleted the harness/hold-pending-rate branch October 31, 2026 08:07

This branch had an error being deployed

1 failed deployment
Preview — da89ae61 Deployed Oct 31, 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