From 90e336761de2a277f930ca5bb5b36eca56357af6 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:33:35 +0800 Subject: [PATCH] fix(ui): don't finish a press the engine releases itself on macOS (#1463) The shim for WebKit's reordered taps (#1072) took a press stamped at the same time as the release before it for a stale one, and finished it with a synthetic pointerup, mouseup, click and dblclick a tick later, even after the engine had delivered that press's own release. A fast double-click whose second press shared the first release's time step was activated twice: on macOS, double-clicking a Gradle task started it twice and the second start asked to stop the first ("is not allowed to run in parallel"), which is why the linked Gradle E2E failed on main. Only a press stamped before the release is stale now, and a press the engine releases before the tick is left alone. --- src/platform/reordered-tap-click.test.ts | 29 ++++++++++++++++++++++++ src/platform/reordered-tap-click.ts | 12 ++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/platform/reordered-tap-click.test.ts b/src/platform/reordered-tap-click.test.ts index e415ac99..103ec26d 100644 --- a/src/platform/reordered-tap-click.test.ts +++ b/src/platform/reordered-tap-click.test.ts @@ -87,6 +87,35 @@ describe("installReorderedTapClick", () => { expect(log.filter((l) => l.startsWith("click"))).toEqual(["click:1", "click:1"]); }); + it("leaves a quick double-click alone when its second press shares the first release's timestamp", () => { + // Fast input (automation, a quick double-click) stamps both at the same time step. + el.dispatchEvent(ev("pointerdown", 5900.9)); + el.dispatchEvent(ev("mousedown", 5900.9, { detail: 1 })); + el.dispatchEvent(ev("pointerup", 5901)); + el.dispatchEvent(ev("mouseup", 5901)); + el.dispatchEvent(ev("click", 5901, { detail: 1 })); + el.dispatchEvent(ev("pointerdown", 5901)); + el.dispatchEvent(ev("mousedown", 5901, { detail: 2 })); + el.dispatchEvent(ev("pointerup", 5901)); + el.dispatchEvent(ev("mouseup", 5901)); + el.dispatchEvent(ev("click", 5901, { detail: 2 })); + el.dispatchEvent(ev("dblclick", 5912, { detail: 2 })); + vi.runAllTimers(); + expect(log.filter((l) => /^(click|dblclick)/.test(l))).toEqual(["click:1", "click:2", "dblclick:2"]); + }); + + it("finishes nothing when the engine releases the press before the tick", () => { + el.dispatchEvent(ev("pointerup", 1393)); + el.dispatchEvent(ev("pointerdown", 1386)); + el.dispatchEvent(ev("mousedown", 1386, { detail: 1 })); + el.dispatchEvent(ev("pointerup", 1400)); + el.dispatchEvent(ev("mouseup", 1400)); + el.dispatchEvent(ev("click", 1400, { detail: 1 })); + vi.runAllTimers(); + expect(log.filter((l) => l.startsWith("click"))).toEqual(["click:1"]); + expect(log.filter((l) => l.startsWith("pointerup"))).toHaveLength(2); + }); + it("repeats the double-click detail of the stale press", () => { reversedTap(1393, 1386, 2); expect(log).toContain("click:2"); diff --git a/src/platform/reordered-tap-click.ts b/src/platform/reordered-tap-click.ts index b2c2d048..ac78e00d 100644 --- a/src/platform/reordered-tap-click.ts +++ b/src/platform/reordered-tap-click.ts @@ -3,8 +3,8 @@ * focus, and with a third-party input method it can hand the page a tap as `pointerup` -> * `pointerdown` (WebKit bug 219670). The press then has no release: the tap's click is lost, * and the engine, which still considers the button down, starts the next tap with a bare - * `mousedown` (no `pointerdown`). `pointerdown.timeStamp` stays native, so a press stamped no - * later than the `pointerup` just before it is a reordered tap: finish it with a synthetic + * `mousedown` (no `pointerdown`). `pointerdown.timeStamp` stays native, so a press stamped before + * the `pointerup` just before it is a reordered tap: finish it with a synthetic * `pointerup`/`mouseup`/`click`, and give the next press the `pointerdown` the engine skips. * The next tap's own click lands on its own target, so it is left alone. * @@ -47,7 +47,10 @@ export function installReorderedTapClick({ const onPointerUp = (e: PointerEvent) => { if (!isTrusted(e)) return; down = false; - if (primaryMouse(e)) lastUp = e.timeStamp; + if (!primaryMouse(e)) return; + lastUp = e.timeStamp; + // The engine released the press itself, so it was not a stale one: there is nothing to finish. + pending = null; }; const onPointerCancel = (e: PointerEvent) => { @@ -60,7 +63,8 @@ export function installReorderedTapClick({ const before = lastUp; lastUp = null; pending = null; - if (!primaryMouse(e) || before === null || e.timeStamp > before) return; + // A quick in-order click can stamp its press with its last release's time: only an earlier press is stale. + if (!primaryMouse(e) || before === null || e.timeStamp >= before) return; if (!(e.target instanceof Element)) return; pending = { target: e.target, detail: 1, x: e.clientX, y: e.clientY, mods: mods(e) }; // After this tick the page's own pointerdown/mousedown handlers have run, as for an in-order tap.