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>
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>
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>
…ost (#8360) * feat(core): one click guard for the menu press and the gesture host #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 #8074, #8270, #8293, #8315, #8320 and #8356, and folds in #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 #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 (#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 (#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 #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> * fix(gestures): a touch handled late goes to what was drawn where the finger landed On iOS WebKit the host resolves moving content at touch-down and asks the engine what is under the finger. When a busy main thread hands it the touch later, the content has moved on: the host now asks at the point where what was under the finger at the touch's timestamp is drawn, so a tap on a moving sheet's Done still clicks Done (spec:AST-094 FR11). A presented source reports its offset at a given time for this. The presented browser proof drives the sheet with Core's animator and a critically damped spring, up into place and down off the screen over the scrim: a tap on the drawn Done clicks it once, and a drag on the drawn sheet catches it where it is drawn, in both directions. * spec(AST-094): a touch on moving content goes to what is drawn under the finger (FR11, FR19); INV13 names the shared input listener Where the engine hit-tests an element a compositor animation moves at the motion's start (iOS WebKit), a touch on that content goes to what was drawn under the finger at touch-down, and a tap there activates that element with one click while the browser's own click is cancelled or swallowed (FR11). While such content around a recognizer that claims moving touches is moving, the document may carry a non-passive touchmove listener that cancels only owned moves and comes off when the motion ends; while it is on, a native scroll waits for a free main thread (FR19). The gesture host, press feedback, page hold, finger-scroll watch and click guard hear document-wide input through one capture-phase listener per event type on the window (architecture:react-component-runtime INV13). * fix(gestures): content that slid under a late touch takes nothing; review fixes A touch the host handles late now also leaves alone content that slid under the finger after it landed: the touch goes to what was beneath it then (spec:AST-094 FR11). The input-stream changeset says what moved: the gesture host's listeners, and the press feedback's scroll and visibility listeners, since its pointer listeners were already on the window's capture phase. The coarse-pointer gate reads TOUCH_QUERY from touchQueries.ts instead of spelling it again. The presented proof checks that the sheet moves on a spring (its speed changes along the animator's curve) and no longer compares the drawn box with the animator's offset: under a 3-CPU cap the two disagree by 3 to 27 px in about half the frames whatever reference is used, while the tap and drag cases go through the host's real path. * test(motion): the busy-window proof reads the control's clock from the origin most frames agree with Under the CI lane's WebKit profile (four CPUs, one worker), "a spring keeps advancing every frame through a 1 s main-thread block" failed in two ways, each about once in a few hundred runs, and a failing run's frames show each cause: - The spring drifted 138.6 ms against the control. The control's clock took its origin from the first usable frame alone, reading that frame's direction from the next usable one: the frame sat at x 210 on its way out, the next usable one at x 210 after the turn, and the tie read as "back". The clock now lives in controlClock.ts and takes the origin most frames agree with. On that run's own frames it reads 6.5 ms (bound 12), and its unit case for that layout fails on the old reading (150.7 ms astray). - The spring "moved on 61 of the 62 frames the control moved". At the block's end it was in its tail, under two of the cast's 1 px steps a frame, where a frame delivered early shows no step and the next catches up two. The harness now reads such a tail at its own pace (busyWindowProof), and the proof judges the whole block: on that run's own frames the earlier proof fails it and this one passes. No bound changes. * fix(core): the haptic switch's private attribute has its owner module (architecture:public-component-api INV23) Main's lint rule now keeps every private data attribute in its owner module. This change had moved `data-astryx-haptic-switch` from the haptics module to utils/hapticSwitch.ts, so the click guard could skip the hidden switch without importing haptics (architecture:react-component-runtime INV13). The name now lives in privateAttributes/hapticSwitch.stylex.ts, modelled on segmentDisabled.stylex.ts: it exports markHapticSwitch, which the haptics module uses on its hidden switch, and isHapticSwitch, which the click guard reads. utils/hapticSwitch.ts and the deprecated gestures/hapticSwitch.ts re-export go, and the haptics module's baseline entry goes with its raw name. No behavior changes. * refactor(core): one iOS WebKit check and one flat-tree walk, shared by the gesture host and the selection lock The selection lock and the gesture host each kept a copy of two helpers: the iOS WebKit engine check, and the walk that asks whether a node is inside an element along the flat tree. The engine check now lives in utils/touchQueries.ts, the React-free half of touchPresentation.ts, as IOS_SUPPORTS_QUERY with one isIosWebKit(view) reader; touchPresentation.ts re-exports the query, so its export is unchanged. The walk is containsAlongFlatTree in utils/flatTree.ts, beside parentAcrossShadow. Unit cases cover both in their new homes. No behavior changes. * chore(changeset): the input stream's fix line covers only what ships No Core or Lab component attaches a gesture recognizer yet, so the input-stream changeset's fix entry told consumers about a change they cannot meet. It now keeps `[fix]` for what ships, touch press feedback hearing scrolls and page hides on the window's capture phase, and a new `[chore] Internal:` changeset carries the recognizers' shared stream and its passive-unless-cancelled listeners. * fix(core): presented hit testing decides "inside the moving element" along the flat tree everywhere Two checks of whether a node is inside a moving element still stopped at a shadow boundary. presentedOffsetOf walked a node's light-DOM parents, so a control slotted into a moving shadow-tree panel read no offset and, on iOS, its press bounds stayed where the panel started; it now walks parentAcrossShadow, through the slot. The host's check for a claiming recognizer in or around a moving element used Node.contains, so a pan in an open shadow root of a moving sheet, or around a moving page in its own shadow root, left the document without its blocking touchmove; it now uses containsAlongFlatTree both ways, as presentedTarget does. A presented hit-testing case for each failed before. * fix(gestures): the moving-content blocking touchmove subscribes through the shared input stream While an element a claiming recognizer lives in moves on the compositor, the host's blocking touchmove now subscribes to the window's capturing touchmove through subscribeInput with {cancels: true}, as the page hold does, instead of adding its own non-passive listener on the document. The one input stream carries every page-level listener (architecture:react-component-runtime INV13), and its window capture phase sees every touch whatever the engine targets. The presented hit-testing cases for that listener now check that the window's touchmove turns non-passive while the element moves and passive again after, with no touchmove listener on the document; both failed before. * docs(spec): AST-094's Platform support states how presented hit testing is verified Presented hit testing on iOS WebKit merges on unit tests that mimic its hit testing, the WebKit tap and drag proof with the correction off as its control, and the iOS Simulator; real-device evidence for it follows merge.
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.