Repository navigation
test(interaction-harness): a busy-window proof can judge a component against a compositor-only control - #8906
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
There was a problem hiding this comment.
This looks ready to merge: the control-relative verdict is opt-in, the default busy-window judgment is unchanged, and both new browser cases passed the compositor animation and failed the rAF one in every Chromium and WebKit run. One optional follow-up: in Chromium a frame stamped up to about 50 ms into a block can still show the main thread's last frames, so pooled 70 ms blocks credited the rAF animation with up to 58% of the control's frames (a few single runs fully) even though the pool always rejected it. The pooledBusyProof doc line "in every run the control moves and it does not" could say that one run's numbers don't separate the two, or each run's judged part could start past that lag.
[Automated review]
| * `maxMedianFrameMs`, when given, applies to each run. A main-thread | ||
| * animation still fails: in every run the control moves and it does not. |
There was a problem hiding this comment.
Optional: in Chromium a frame stamped up to ~50 ms after the block starts can still show the main thread's last frames, and a 70 ms block's judged part (20 ms margins) ends at 50 ms. Pooling the self-test's rAF animation 25 times, it changed position inside 149 of 150 blocks and 7 runs credited it with every control frame of the run (pooled credit up to 58%); the pool still failed it every time. This sentence describes the animation rather than what a run reads: worth saying that one run's numbers (runs) don't separate the two and that the pool rejects a main-thread animation on its runs' later frames, or starting each run's judged part past that lag.
There was a problem hiding this comment.
Changes requested: the new sub-step credit lets pooledBusyProof pass a rAF loop that steps a fixed 1 or 2 px per frame, which passed 16 of 20 Chromium pools of six 70 ms blocks that the previous revision rejected, because each run's frozen frames end in a single step, stepPx reads 2 px when a run's judged part shows no change, and one late frame per run fills the pooled four-frame floor. Please bound the credit to runs the component entered already moving under one step a frame (for example, a run at most one frame longer than its previous run, which keeps all 18 busy.control.test.ts cases passing), stop stepPx defaulting to 2 px for a part with no change, and add a pooled frame-counted case to the unit and browser tests, red first. Otherwise the control verdict holds up: compositor animations passed and time-based rAF ones failed in Chromium and WebKit, single and pooled.
[Automated review]
| return ( | ||
| k < series.length && | ||
| Math.abs(series[k].value - series[i].value) < 1.5 * stepPx | ||
| ); |
There was a problem hiding this comment.
This credits any run of unchanged frames whose ending change is under 1.5 × stepPx, however long the run and however fast the component moved before it. With stepPx reading 2 px for a judged part that shows no change (line 640) and the pooled new-position floor summed over the runs (line 895), a rAF loop that steps a fixed 1 or 2 px per frame passed pooledBusyProof in 16 of 20 Chromium pools of six 70 ms blocks; the previous revision failed all 10 it judged on the same frames. Crediting a run only when it is at most one frame longer than the component's previous run kept all 18 busy.control.test.ts cases passing and failed those pools 10 of 10.
…eta#8666 08a2a28) into the web-input drivers helper The harness files are 8d35612's verified union, byte for byte, plus meta#8535's change to the backward self-test, which this line carries; the two helpers hold the same content apart from that change. This line's own harness additions leave it, as no open pull request builds on them: the earlier busy-window control and pooled proof (now meta#8906), openStory waiting for Storybook to finish a render, the touch driver's synthesizesClicks and SCRIPTED_CLICK_SKIP, and the wheel-at-DPR-3 self-test.
There was a problem hiding this comment.
Looks ready to merge: judged against the control, the busy proof passed the compositor animation and failed both the time-based and the fixed-step rAF loops in Chromium and WebKit, single and pooled, and the fixed-step loop that passed pooled runs on the previous revision now fails every pool. One optional cleanup: BusyProof.stepPx's JSDoc in busy.ts still describes the old position-based step, while the value now comes from the judged part's changes and is 0.5 when there are none (as the README says), and the file header could also name pooledBusyProof.
[Automated review]
| // The cast's position step for the tracked value: the largest of 2, 1 and | ||
| // 0.5 CSS px that every change between its consecutive positions in the | ||
| // judged part is a multiple of. Changes rather than positions: a position | ||
| // the page commits once the block ends can sit off that grid. |
There was a problem hiding this comment.
Optional: BusyProof.stepPx's JSDoc (lines 261-265) still says "the largest of 0.5, 1 and 2 that every tracked position is a multiple of". With this change it is the largest dividing every change between consecutive positions in the judged part, and 0.5 when the part shows no change, as the README now says; rewording the field's doc keeps a frozen part's reported 0.5 from reading as the cast's grid.
| * that reports exactly when it ran —, `startBusyControl` — a plain | ||
| * compositor-only control animation recorded in the same window — and | ||
| * `busyWindowProof`, which reads frame evidence for that window, against | ||
| * the control when it has one. |
There was a problem hiding this comment.
Optional: the header's @output now names startBusyControl but not pooledBusyProof, which this file also exports.
There was a problem hiding this comment.
Changes requested: when a loaded runner starts the cast late enough that the 700 ms self-test's block holds the box's turnaround at 1,600 ms, two cast frames that straddle the linear turnaround evenly read the same position and the control verdict fails the correct compositor animation (in Chromium, 2 of 2 runs at one of six one-in-six cast phases: "moved on 6 of the 7 frames the control moved"). Dropping direction: 'alternate' from that animation (harness.interaction.browser.spec.ts:404) so it wraps from 560 px back to 0 passed every phase, and the pooled case's animation alternates the same way. Otherwise the control verdict holds up at this head: compositor animations passed and the time-based and fixed-step rAF loops failed in Chromium and WebKit, single and pooled. Optional: BusyProof.stepPx's JSDoc in busy.ts still describes the old position-based step, and the file header could name pooledBusyProof.
[Automated review]
| duration: 1600, | ||
| easing: 'linear', | ||
| iterations: Infinity, | ||
| direction: 'alternate', |
There was a problem hiding this comment.
direction: 'alternate' adds a linear turnaround at 1,600 ms. When the cast starts about 0.7–1.4 s after the animation (the slow runner this commit targets), the turnaround lands inside the judged part, and two cast frames that straddle it evenly read the same position; the run they form ends in a full frame's motion, so underStep doesn't credit it and the compositor run fails. In Chromium, with the block about 1 s in, one of six one-in-six cast phases failed 2 of 2 runs ("moved on 6 of the 7 frames the control moved"). Without direction, the endless animation wraps from 560 px back to 0, a change on every frame, and passed all six phases.
There was a problem hiding this comment.
Changes requested: the reworked first busy self-test failed 7 of 7 Chromium runs here, because its rAF run blocks right after cancelling the previous run's still-running compositor animation, so the compositor keeps moving the box into the block and the rAF mutant reads as moving (at one frame in six it even passed the control verdict). Letting a rendering update land between that cancel and the rAF run's block, as the 200 ms wait in the control case does, made it pass 4 of 4 with the mutant frozen.
- The backdrop case's
during: dialoggate is satisfied by thewithincontrol's own animation, so its block no longer waits for the backdrop; skipping the harness's control induring(or gating on the::backdropanimation) restores the wait and lets the WebKit comparison use one gate on both sides. - The description still links an earlier compare range and does not mention
withinor the first self-test's rework.
[Automated review]
| const window = await blockMainThread( | ||
| page, | ||
| 700, | ||
| driver === 'compositor' ? {during: box} : {}, | ||
| ); |
There was a problem hiding this comment.
For the rAF driver this blocks right after the evaluate that cancels the previous run's animation. The compositor run's 1,600 ms animation is still running then (1,050–1,467 ms in, in every replay here), and with no rendering update between the cancel and this block the compositor keeps playing it into the judged part: the rAF mutant read "moved on 9 of its 39 frames" (raf.changed 9 at line 416) and, at one frame in six, passed the control verdict in 2 of 4 replays. With await page.waitForTimeout(200) before this call for the rAF driver, as the control case keeps at line 506, the test passed 4 of 4 with the mutant at 0 changed frames.
| pseudoElement: '::backdrop', | ||
| }); | ||
| }); | ||
| const window = await blockMainThread(page, 600, {during: dialog}); |
There was a problem hiding this comment.
during counts every running animation in the dialog's subtree (busy.ts:228-235), and within (line 569) put the control's endless animation there, so this gate is satisfied in the first frame it looks rather than once the backdrop runs: in Chromium the block started 5.4–23.4 ms after the dim was created, against 25.6–27.2 ms when the gate counted only the ::backdrop animation. Skipping the harness's own control in during, or gating on the backdrop animation, keeps the hand-off wait and makes the WebKit with/without-within comparison use one gate on both sides.
… control) into uikit/c21-wheel-proofs
…nst a compositor-only control [skip ci] The 1 s busy proof marked one row and asked that every frame the compositor presented show it moved. Two things made it fail on a busy machine. Late in the block the coast moves about a pixel a frame, under the screencast's step, so a frame delivered early shows no change; and over a whole second the coast travels further than the column shows, so the marked row was out of sight at one end, turned and faded at the other. It now follows markers that ride the strip itself, every three rows in three colours, placed before the flick so the block meets them already on screen. They move with the coast and never turn or fade, and each is judged while within reach of the band; together they cover the block. The judgment is busyWindowProof's against a compositor-only control recorded in the same frames (meta#8906): the coast moves on every frame the control moves, a run under one step that a single step ends counts as moved, and its longest stall is no longer than the control's.
There was a problem hiding this comment.
Changes requested: the reworked first busy self-test fails in Chromium, the browser CI runs it in, because its rAF run blocks right after cancelling the previous run's still-running 1,600 ms compositor animation, so the compositor keeps moving the box into the block and raf.changed reads 6 to 8 against the <= 1 assertion (the base version passes). Letting a rendering update land before that block removed every changed frame in replays here; keeping await page.waitForTimeout(200) there, as the base version and the control case do, is one way. In the same push, please make blockMainThread's during and waitForAnimation skip the harness's own control, or say in the within docs that a backdrop proof must wait on the backdrop's own animation, since a control mounted within a dialog satisfies both gates on a dialog that animates nothing else.
[Automated review]
| const window = await blockMainThread( | ||
| page, | ||
| blockMs, | ||
| driver === 'compositor' ? {during: box} : {}, | ||
| ); |
There was a problem hiding this comment.
For the rAF driver this blocks right after the evaluate that cancels the previous run's animation. The compositor run's 1,600 ms animation is still running then (1,417 to 1,467 ms in, in every replay here), and with no rendering update between that cancel and this block the compositor keeps playing it into the block: the rAF mutant's marker kept moving until 146 to 179 ms into the block, raf.changed read 6 to 8, and line 421 failed in every Chromium run. Awaiting two animation frames before this block left the mutant with 0 changed frames in every replay, and the base version, which waited 200 ms before its block as the control case still does at line 516, passed every run.
| document.body.appendChild(box); | ||
| box.showPopover(); | ||
| } else { | ||
| host.appendChild(box); |
There was a problem hiding this comment.
With within, the control's endless animation lives inside the dialog, so blockMainThread's during: dialog (lines 363-365: "Any animation inside during counts") and waitForAnimation(dialog) both count it: on a dialog that animates nothing else, both returned at once in Chromium and WebKit, where with the control in the top layer both reject "no animation started". A backdrop proof that follows the README's recipe can therefore start its block before its dim runs. Skipping [data-harness-control] animations in both gates, or saying in the within docs that the proof must wait on the backdrop's own animation, would close it.
…against a compositor-only control
A busy-window proof judged a compositor animation against a display-rate bound. On a two-core or loaded runner the compositor presents fewer frames than that bound allows, though the animation moves on each one, so Lightbox's double-tap zoom missed the bound in 7 of 10 two-core runs.
startBusyControl(page) mounts a small top-layer box that the compositor animates, tracked from the same screencast. busyWindowProof(frames, window, {control}) passes when the component moves on every frame the control moves and never stalls longer. The frames are the same, so there is no tolerance. One exception: on an easing tail, unchanged frames count as moved when one cast step ends the run and it is at most a frame longer than the run before. At least four control frames must move.
- pooledBusyProof(runs) judges short runs together.
- {within: dialog} mounts the control inside an open dialog, for backdrop proofs.
- A hold that starts before the block is judged from its first presented change inside it. WebKit presents a just-started compositor animation 130 to 300 ms late, and its cast reaches Node about 0.7 s late on a loaded runner, too late to wait on. Every frame after that change is judged, so a freeze later in the block still fails.
- Main's first busy self-test judges against the control too.
…pdate land before its rAF block, and the gates never count the busy control's own animation astracat found two gaps on 1ca51a4. 1. The rAF run blocked right after cancelling the compositor run's still-running animation. With no rendering update between the cancel and the block, the compositor kept playing the cancelled animation into the block, so the rAF mutant read 6 to 8 changed frames. The run now waits two animation frames before blocking. A probe that cancels and then blocks at once kept moving in 1 of 8 Chromium runs here (27 changed frames); with two frames first, 0 of 8. 2. Inside a dialog (`within`), the control's endless animation answered `waitForAnimation(dialog)` and `blockMainThread`'s `during: dialog`, so a backdrop proof could block before its dim ran. Both gates now skip any animation inside `[data-harness-control]`. Red first: on a dialog that animates nothing else, both gates resolved at once. Now both keep waiting until the dialog's own animation runs, 6 of 6 in Chromium and in WebKit.
…inst the busy control "A block that starts during a just-started transition" and "a block never starts on an animation that is not on the compositor yet" still judged their compositor animation against the display-rate bound alone. On a loaded runner that bound fails a component that moved on every frame. The second one failed this way on a gate run of meta#8911 (WebKit, 8 workers: "43/43 moved, longest stall 52 ms"), and in 3 of 16 Chromium runs at 16 workers ("31/31 moved, longest stall 169 ms"). Both now start the busy control before the cast and judge against what the compositor presented in the same window. Both pass 32 of 32 per engine at 16 workers. Their intent is unchanged: a block in the frame the transition has run 16 ms, and a block never on a pending animation, each still fails if the compositor's motion stalls longer than the control's.
meta#8360's change on the internal touch foundation: one click guard shared by the menu press and the gesture host (its click listener stays once the menu press arms it, as its record has it, and lasts only while armed for the gesture host, FR24), the host as the one touch owner (finger scroll, page holds, attachments, the press tracker hook and a held control's kept touch, the long press's single source, presented hit testing on iOS WebKit), and the gesture browser proof in Chromium and WebKit with the README. It carries meta#8074, meta#8270, meta#8293, meta#8315, meta#8320 and meta#8356, and folds in meta#8358 (the long press's tolerance comment and an unused import). Rebased onto section 2 as one commit; the touch-action rule now comes with meta#8071, and meta#8072's claimed-touchend fix, which this line never had, comes back with meta#8309. The animator's busy proof is judged against the harness's compositor-only control (meta#8906), with a main-thread mutant as its negative, and the spring's curve is placed against the control's own clock, so the late frames a loaded runner sends fail nothing. With the host serving every pointer (meta#8071), this change's own pieces follow it: a press tracker takes a mouse press as a press, painting at once and braking no scroll (FR11); a finger's or a pen's hold that does something keeps text unselected under any primary pointer, while a mouse keeps its released behavior (FR41); a claimed pen's stylus touchend is cancelled as a finger's (FR21); and `perEventPointer`, from before FR43, changes nothing. These come from meta#8665, which keeps only the undecided rule for text selection during a mouse or pen drag. The hover recognizer and the host's hover routing land with hover's first user. Co-authored-by: Cindy Zhang <cindyxz@meta.com>
1ca51a4 to
187ee49
Compare
PR Analysis Report📚 Storybook Preview🧪 Sandbox PreviewNo new or modified components detected. Bundle Size SummaryNo component packages changed. Accessibility AuditStatus: The accessibility audit did not run on this pull request. It runs after merges to Generated by PR Enrichment workflow | Storybook | Sandbox | View full report |
There was a problem hiding this comment.
Looks good to merge: judged against the control, every busy self-test passes the compositor animation and fails the script-driven ones in Chromium and WebKit, and the first self-test that failed on the previous revision now passes. Two optional follow-ups: a control mounted within a dialog still makes waitForRest(dialog) reject "still moving" and is paused by holdAnimations(page, dialog), so either skip [data-harness-control] there as during and waitForAnimation now do or say that a backdrop proof removes the control first; and a hold excused before the block is summarized as "nothing moved before the block", which the summary and onScreenFromMs's doc could name instead.
[Automated review]
| @@ -554,6 +895,9 @@ export function busyWindowProof( | |||
| (onScreenFromMs == null | |||
| ? '' | |||
| : `, on screen from +${onScreenFromMs.toFixed(0)} ms (nothing moved before the block)`) + | |||
There was a problem hiding this comment.
This label now also covers a hold that started before the block (lines 583-593), where the element did move first: frames held from 120 ms before a 700 ms block to +600 ms pass and read "on screen from +600 ms (nothing moved before the block)". Naming the excused hold here and in onScreenFromMs's doc (lines 289-295), for example "held from -120 ms, judged from +600 ms", would keep the annotation accurate.
| }; | ||
| // The busy-window control (busy.ts) animates on its own, inside a | ||
| // dialog with `within`: it never answers for the component. | ||
| const isControl = (a: Animation) => { |
There was a problem hiding this comment.
waitForRest (line 1185) and holdAnimations (line 368) still count a within control: on a still dialog hosting one, waitForRest(dialog) rejects "still moving" and holdAnimations(page, dialog) pauses the control, in Chromium and WebKit. Sharing this predicate with them, or saying in the within docs that the control is removed first, would close the gap.
meta#8360's change on the internal touch foundation: one click guard shared by the menu press and the gesture host (its click listener stays once the menu press arms it, as its record has it, and lasts only while armed for the gesture host, FR24), the host as the one touch owner (finger scroll, page holds, attachments, the press tracker hook and a held control's kept touch, the long press's single source, presented hit testing on iOS WebKit), its cases in the gesture browser proof (Chromium and WebKit) and the README. It carries meta#8074, meta#8270, meta#8293, meta#8315, meta#8320 and meta#8356, and folds in meta#8358 (the long press's tolerance comment and an unused import). Rebased onto section 2 as one commit; the touch-action rule now comes with meta#8071, and meta#8072's claimed-touchend fix, which this line never had, comes back with meta#8309. The animator's busy proof is judged against the harness's compositor-only control (meta#8906), with a main-thread mutant as its negative, and the spring's curve is placed against the control's own clock, so the late frames a loaded runner sends fail nothing. With the host serving every pointer (meta#8071), this change's own pieces follow it: a press tracker takes a mouse press as a press, painting at once and braking no scroll (FR11); a finger's or a pen's hold that does something keeps text unselected under any primary pointer, while a mouse keeps its released behavior (FR41); and `perEventPointer`, from before FR43, changes nothing. These come from meta#8665, which keeps only the undecided rule for text selection during a mouse or pen drag. The hover recognizer and the host's hover routing land with hover's first user. Co-authored-by: Cindy Zhang <cindyxz@meta.com>
meta#8360's change on the internal touch foundation: one click guard shared by the menu press and the gesture host (its click listener stays once the menu press arms it, as its record has it, and lasts only while armed for the gesture host, FR24), the host as the one touch owner (finger scroll, page holds, attachments, the press tracker hook and a held control's kept touch, the long press's single source, presented hit testing on iOS WebKit), its cases in the gesture browser proof (Chromium and WebKit) and the README. It carries meta#8074, meta#8270, meta#8293, meta#8315, meta#8320 and meta#8356, and folds in meta#8358 (the long press's tolerance comment and an unused import). Rebased onto main after section 2 as one commit; the touch-action rule came with meta#8071. Where fingers come from their pointer events, a claimed finger's touchend is cancelled from its element (FR21), under a fine primary pointer wherever the browser sends Touch Events and inside an open shadow root too. The animator's busy proof is judged against the harness's compositor-only control (meta#8906), with a main-thread mutant as its negative, and the spring's curve is placed against the control's own clock, so the late frames a loaded runner sends fail nothing. With the host serving every pointer (meta#8071), this change's own pieces follow it: a press tracker takes a mouse press as a press, painting at once and braking no scroll (FR11); a finger's or a pen's hold that does something keeps text unselected under any primary pointer, while a mouse keeps its released behavior (FR41); and `perEventPointer`, from before FR43, changes nothing. These come from meta#8665, which keeps only the undecided rule for text selection during a mouse or pen drag. The hover recognizer and the host's hover routing land with hover's first user. Co-authored-by: Cindy Zhang <cindyxz@meta.com>
meta#8360's change on the internal touch foundation: one click guard shared by the menu press and the gesture host (its click listener stays once the menu press arms it, as its record has it, and lasts only while armed for the gesture host, FR24), the host as the one touch owner (finger scroll, page holds, attachments, the press tracker hook and a held control's kept touch, the long press's single source, presented hit testing on iOS WebKit), its cases in the gesture browser proof (Chromium and WebKit) and the README. It carries meta#8074, meta#8270, meta#8293, meta#8315, meta#8320 and meta#8356, and folds in meta#8358 (the long press's tolerance comment and an unused import). Rebased onto main after section 2 as one commit; the touch-action rule came with meta#8071. Where fingers come from their pointer events, a claimed finger's touchend is cancelled from its element (FR21), under a fine primary pointer wherever the browser sends Touch Events and inside an open shadow root too; at most eight such lifts wait for their touchend, and each goes after 500 ms or with the host (FR24). The animator's busy proof is judged against the harness's compositor-only control (meta#8906), with a main-thread mutant as its negative, and the spring's curve is placed against the control's own clock, so the late frames a loaded runner sends fail nothing. With the host serving every pointer (meta#8071), this change's own pieces follow it: a press tracker takes a mouse press as a press, painting at once and braking no scroll (FR11); a finger's or a pen's hold that does something keeps text unselected under any primary pointer, while a mouse keeps its released behavior (FR41); and `perEventPointer`, from before FR43, changes nothing. These come from meta#8665, which keeps only the undecided rule for text selection during a mouse or pen drag. The hover recognizer and the host's hover routing land with hover's first user. Co-authored-by: Cindy Zhang <cindyxz@meta.com>
meta#8360's change on the internal touch foundation: one click guard shared by the menu press and the gesture host (its click listener stays once the menu press arms it, as its record has it, and lasts only while armed for the gesture host, FR24), the host as the one touch owner (finger scroll, page holds, attachments, the press tracker hook and a held control's kept touch, the long press's single source, presented hit testing on iOS WebKit), its cases in the gesture browser proof (Chromium and WebKit) and the README. It carries meta#8074, meta#8270, meta#8293, meta#8315, meta#8320 and meta#8356, and folds in meta#8358 (the long press's tolerance comment and an unused import). Rebased onto main after section 2 as one commit; the touch-action rule came with meta#8071. Where fingers come from their pointer events, a claimed finger's touchend is cancelled from its element (FR21), under a fine primary pointer wherever the browser sends Touch Events and inside an open shadow root too; at most eight such lifts wait for their touchend, and each goes after 500 ms or with the host (FR24). The animator's busy proof is judged against the harness's compositor-only control (meta#8906), with a main-thread mutant as its negative, and the spring's curve is placed against the control's own clock, so the late frames a loaded runner sends fail nothing. With the host serving every pointer (meta#8071), this change's own pieces follow it: a press tracker takes a mouse press as a press, painting at once and braking no scroll (FR11); a finger's or a pen's hold that does something keeps text unselected under any primary pointer, while a mouse keeps its released behavior (FR41); and `perEventPointer`, from before FR43, changes nothing. These come from meta#8665, which keeps only the undecided rule for text selection during a mouse or pen drag. The hover recognizer and the host's hover routing land with hover's first user. Co-authored-by: Cindy Zhang <cindyxz@meta.com>
meta#8360's change on the internal touch foundation: one click guard shared by the menu press and the gesture host (its click listener stays once the menu press arms it, as its record has it, and lasts only while armed for the gesture host, FR24), the host as the one touch owner (finger scroll, page holds, attachments, the press tracker hook and a held control's kept touch, the long press's single source, presented hit testing on iOS WebKit), its cases in the gesture browser proof (Chromium and WebKit) and the README. It carries meta#8074, meta#8270, meta#8293, meta#8315, meta#8320 and meta#8356, and folds in meta#8358 (the long press's tolerance comment and an unused import). Rebased onto main after section 2 as one commit; the touch-action rule came with meta#8071. Where fingers come from their pointer events, a claimed finger's touchend is cancelled from its element (FR21), under a fine primary pointer wherever the browser sends Touch Events and inside an open shadow root too; at most eight such lifts wait for their touchend, and each goes once taken or after 500 ms, kept across the host's uninstall since the touchend comes in a later task (FR24). The animator's busy proof is judged against the harness's compositor-only control (meta#8906), with a main-thread mutant as its negative, and the spring's curve is placed against the control's own clock, so the late frames a loaded runner sends fail nothing. With the host serving every pointer (meta#8071), this change's own pieces follow it: a press tracker takes a mouse press as a press, painting at once and braking no scroll (FR11); a finger's or a pen's hold that does something keeps text unselected under any primary pointer, while a mouse keeps its released behavior (FR41); and `perEventPointer`, from before FR43, changes nothing. These come from meta#8665, which keeps only the undecided rule for text selection during a mouse or pen drag. The hover recognizer and the host's hover routing land with hover's first user. Co-authored-by: Cindy Zhang <cindyxz@meta.com>
meta#8360's change on the internal touch foundation: one click guard shared by the menu press and the gesture host (its click listener stays once the menu press arms it, as its record has it, and lasts only while armed for the gesture host, FR24), the host as the one touch owner (finger scroll, page holds, attachments, the press tracker hook and a held control's kept touch, the long press's single source, presented hit testing on iOS WebKit), its cases in the gesture browser proof (Chromium and WebKit) and the README. It carries meta#8074, meta#8270, meta#8293, meta#8315, meta#8320 and meta#8356, and folds in meta#8358 (the long press's tolerance comment and an unused import). Rebased onto main after section 2 as one commit; the touch-action rule came with meta#8071. Where fingers come from their pointer events, a claimed finger's touchend is cancelled from its element (FR21), under a fine primary pointer wherever the browser sends Touch Events and inside an open shadow root too; at most eight such lifts wait for their touchend, and each goes once taken or after 500 ms, kept across the host's uninstall, which can run in the microtask after the pointerup, before the touchend that comes later in the same task (FR24). An armed press's page listeners go once the arena has decided its last touch, a tap that waits on a double tap included, and after its last detach the host holds no listener, note, frame or timer (FR24). The animator's busy proof is judged against the harness's compositor-only control (meta#8906), with a main-thread mutant as its negative, and the spring's curve is placed against the control's own clock, so the late frames a loaded runner sends fail nothing. With the host serving every pointer (meta#8071), this change's own pieces follow it: a press tracker takes a mouse press as a press, painting at once and braking no scroll (FR11); a finger's or a pen's hold that does something keeps text unselected under any primary pointer, while a mouse keeps its released behavior (FR41); and `perEventPointer`, from before FR43, changes nothing. These come from meta#8665, which keeps only the undecided rule for text selection during a mouse or pen drag. The hover recognizer and the host's hover routing land with hover's first user. Co-authored-by: Cindy Zhang <cindyxz@meta.com>
meta#8360's change on the internal touch foundation: one click guard shared by the menu press and the gesture host (its click listener stays once the menu press arms it, as its record has it, and lasts only while armed for the gesture host, FR24), the host as the one touch owner (finger scroll, page holds, attachments, the press tracker hook and a held control's kept touch, the long press's single source, presented hit testing on iOS WebKit), its cases in the gesture browser proof (Chromium and WebKit) and the README. It carries meta#8074, meta#8270, meta#8293, meta#8315, meta#8320 and meta#8356, and folds in meta#8358 (the long press's tolerance comment and an unused import). Rebased onto main after section 2 as one commit; the touch-action rule came with meta#8071. Where fingers come from their pointer events, a claimed finger's touchend is cancelled from its element (FR21), under a fine primary pointer wherever the browser sends Touch Events and inside an open shadow root too; at most eight such lifts wait for their touchend, and each goes once taken or after 500 ms, kept across the host's uninstall, which can run in the microtask after the pointerup, before the touchend that comes later in the same task (FR24). An armed press's page listeners go once the arena has decided its last touch, a tap that waits on a double tap included, and after its last detach the host holds no listener, note, frame or timer (FR24). The animator's busy proof is judged against the harness's compositor-only control (meta#8906), with a main-thread mutant as its negative, and the spring's curve is placed against the control's own clock, so the late frames a loaded runner sends fail nothing. With the host serving every pointer (meta#8071), this change's own pieces follow it: a press tracker takes a mouse press as a press, painting at once and braking no scroll (FR11); a finger's or a pen's hold that does something keeps text unselected under any primary pointer, while a mouse keeps its released behavior (FR41); and `perEventPointer`, from before FR43, changes nothing. These come from meta#8665, which keeps only the undecided rule for text selection during a mouse or pen drag. The hover recognizer and the host's hover routing land with hover's first user. Co-authored-by: Cindy Zhang <cindyxz@meta.com>
Why
A busy-window proof judges a compositor animation against a display-rate bound. On a two-core or loaded runner the compositor presents fewer frames than that bound allows though the animation moves on every one: Lightbox's double-tap zoom missed it in 7 of 10 two-core runs. A short fade gives one window too few frames.
What led to it
Both were written for Lightbox's proofs; only its pull requests carried them.
Options and tradeoffs
Why this choice
startBusyControl(page)mounts a small top-layer box that the compositor animates, tracked from the same screencast.busyWindowProof(frames, window, {control})passes when the component moves on every frame the control moves and never stalls longer. Same frames, so no tolerance. One exception: on an easing tail, a run of unchanged frames counts as moved when a single cast step ends it and it is at most a frame longer than the run before; a freeze ends in a larger catch-up. At least four control frames need a visible move.pooledBusyProof(runs)judges short runs together.{within: dialog}mounts the control inside an open dialog, for a backdrop proof.waitForAnimationandblockMainThread'sduringnever count the control's own animation, so on a dialog they still wait for the backdrop's.How to check it
pnpm exec vitest run --project node internal/interaction-harness/src/busy.control.test.tspnpm test:interaction internal/interaction-harness/src/harness.interaction.browser.spec.ts --grep "busy|control|pooled": a compositor animation passes; a rAF one, timed or frame-counted, fails.Evidence
withinit moves in step with the sheet. On a dialog that animates nothing else, the two waits resolved at once on the control's animation; now they wait for the dialog's own, 6 of 6 per engine.busy.ts.Local gates at 187ee49: 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), on this head and on main; the harness browser spec passes in Chromium (56 of 56, in two runs) and in WebKit apart from the backward self-test, a flake on main that #8535 fixes.