fix(ui): deliver the click of a tap WebKit reorders on macOS (#1072)

On macOS, while an editable element has focus and a third-party input
method (reproduced with WeChat's WeType) is active, WebKit can deliver a
tap as pointerup, mouseup, pointerdown, mousedown (WebKit bug 219670).
The tap's click is lost, and because the engine still considers the
button down, the next tap starts with a bare mousedown and no
pointerdown. Taps on tree rows, tabs and tool window buttons needed a
second tap.

A press stamped no later than the pointerup just before it is a
reordered tap: the shim finishes it with a synthetic pointerup, mouseup
and click (and dblclick for a double) after the page's handlers ran, and
dispatches the skipped pointerdown when a primary mouse press arrives
with no pointer down. The next tap's own click is kept. Installed on
macOS only; untrusted events and other pointer types are ignored.

Verified in a WKWebView with WeType, alternating taps on two targets:
main 19/20 taps clicked their target, with this change 20/20, every press
ran the pointerdown handlers and setPointerCapture worked; with the ABC
layout the shim dispatches nothing.

Refs #760, #724

Co-authored-by: ntbang0901 <[email protected]>
Цей коміт міститься в:
ntbang0901 authored and GitHub committed 2026-11-05 10:30:43 +08:00
1 джерело 29c54c243a
коміт 38ac62f1a4
3 files changed
+253

No files matched your search

+3
Переглянути файл
@@ -15,6 +15,8 @@ import {
import { auxiliaryWindow } from "./platform/auxWindow";
import { installRepeatedDoubleClick } from "./platform/doubleClick";
import { installErrorLogging } from "./platform/errorLog";
import { guessOs } from "./platform/platformInfo";
import { installReorderedTapClick } from "./platform/reordered-tap-click";
import { installKeyboardDispatcher } from "./platform/keymap";
import { installPointerFocus } from "./platform/pointerFocus";
import { isTauri } from "./platform/ipc";
@@ -30,6 +32,7 @@ if (import.meta.env.DEV && isTauri()) {
}
installRepeatedDoubleClick();
if (guessOs() === "macos") installReorderedTapClick();
installWindowDrag();
installPointerFocus();
installErrorLogging();
+116
Переглянути файл
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { installReorderedTapClick } from "./reordered-tap-click";
/** An event as the engine would deliver it: native `timeStamp`, mouse pointer. */
function ev(type: string, timeStamp: number, extra: { detail?: number; pointerType?: string } = {}) {
const e = new MouseEvent(type, { bubbles: true, cancelable: true, detail: extra.detail ?? 0 });
Object.defineProperty(e, "timeStamp", { value: timeStamp });
if (type.startsWith("pointer")) Object.assign(e, { pointerType: extra.pointerType ?? "mouse", isPrimary: true });
return e;
}
describe("installReorderedTapClick", () => {
let uninstall: () => void;
let el: HTMLElement;
let log: string[];
beforeEach(() => {
vi.useFakeTimers();
uninstall = installReorderedTapClick({ isTrusted: () => true });
el = document.body.appendChild(document.createElement("div"));
log = [];
for (const t of ["pointerdown", "pointerup", "mouseup", "click", "dblclick"])
el.addEventListener(t, (e) => log.push(`${t}:${(e as MouseEvent).detail}`));
});
afterEach(() => {
uninstall();
el.remove();
vi.useRealTimers();
});
/** WebKit's order for a tap while an input method is active: up, then the stale down. */
const reversedTap = (up: number, down: number, detail = 1) => {
el.dispatchEvent(ev("pointerup", up));
el.dispatchEvent(ev("mouseup", up));
el.dispatchEvent(ev("pointerdown", down));
el.dispatchEvent(ev("mousedown", down, { detail }));
vi.runAllTimers();
};
const inOrderTap = (down: number, up: number) => {
el.dispatchEvent(ev("pointerdown", down));
el.dispatchEvent(ev("mousedown", down, { detail: 1 }));
el.dispatchEvent(ev("pointerup", up));
el.dispatchEvent(ev("mouseup", up));
el.dispatchEvent(ev("click", up, { detail: 1 }));
vi.runAllTimers();
};
it("leaves an in-order tap alone", () => {
inOrderTap(10, 20);
expect(log).toEqual(["pointerdown:0", "pointerup:0", "mouseup:0", "click:1"]);
});
it("finishes a reordered tap with exactly one click", () => {
reversedTap(1393, 1386);
// The engine's own pointerup/mouseup, then ours; one click only.
expect(log.filter((l) => l.startsWith("click"))).toEqual(["click:1"]);
expect(log.filter((l) => l.startsWith("pointerup"))).toHaveLength(2);
});
it("gives the next press the pointerdown the engine skips, and keeps its click", () => {
reversedTap(1393, 1386);
log = [];
// The engine still considers the button down: the next tap starts with a bare mousedown.
el.dispatchEvent(ev("mousedown", 2000, { detail: 1 }));
el.dispatchEvent(ev("pointerup", 2050));
el.dispatchEvent(ev("mouseup", 2050));
el.dispatchEvent(ev("click", 2050, { detail: 1 }));
vi.runAllTimers();
expect(log).toEqual(["pointerdown:0", "pointerup:0", "mouseup:0", "click:1"]);
});
it("adds no pointerdown to an in-order press", () => {
inOrderTap(10, 20);
inOrderTap(30, 40);
expect(log.filter((l) => l.startsWith("pointerdown"))).toEqual(["pointerdown:0", "pointerdown:0"]);
});
it("adds no pointerdown to a press another pointer type started", () => {
el.dispatchEvent(ev("pointerdown", 10, { pointerType: "touch" }));
el.dispatchEvent(ev("mousedown", 10, { detail: 1 }));
expect(log).toEqual(["pointerdown:0"]);
});
it("does not drop the click of an in-order tap that follows a reordered one", () => {
reversedTap(1393, 1386);
inOrderTap(3000, 3050);
expect(log.filter((l) => l.startsWith("click"))).toEqual(["click:1", "click:1"]);
});
it("repeats the double-click detail of the stale press", () => {
reversedTap(1393, 1386, 2);
expect(log).toContain("click:2");
expect(log).toContain("dblclick:2");
});
it("ignores touch and pen pointers", () => {
el.dispatchEvent(ev("pointerup", 1393, { pointerType: "touch" }));
el.dispatchEvent(ev("pointerdown", 1386, { pointerType: "touch" }));
vi.runAllTimers();
expect(log).toEqual(["pointerup:0", "pointerdown:0"]);
});
it("does nothing when the target left the document before the tick", () => {
el.dispatchEvent(ev("pointerup", 1393));
el.dispatchEvent(ev("pointerdown", 1386));
el.remove();
vi.runAllTimers();
expect(log).toEqual(["pointerup:0", "pointerdown:0"]);
});
it("stops intercepting after uninstall", () => {
uninstall();
reversedTap(1393, 1386);
expect(log.filter((l) => l.startsWith("click"))).toEqual([]);
});
});
+134
Переглянути файл
@@ -0,0 +1,134 @@
/**
* macOS WebKit routes mouse events through the input method while an editable element has
* 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
* `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.
*
* TODO: remove once the minimum supported macOS ships a WebKit with bug 219670 fixed (WebKit#76581).
*/
interface Options {
win?: Window;
/** Tests dispatch untrusted events. */
isTrusted?: (e: Event) => boolean;
}
const primaryMouse = (e: PointerEvent) => e.pointerType === "mouse" && e.isPrimary && e.button === 0;
/** The engine's id of the mouse pointer (the only one a reordered tap concerns). */
const MOUSE_POINTER_ID = 1;
export function installReorderedTapClick({
win = window,
isTrusted: trusted = (e) => e.isTrusted,
}: Options = {}): () => void {
// Events this shim dispatches must not be mistaken for the engine's.
const own = new WeakSet<Event>();
const isTrusted = (e: Event) => trusted(e) && !own.has(e);
const send = (target: Element, e: Event) => {
own.add(e);
target.dispatchEvent(e);
};
let lastUp: number | null = null;
// A pointer is down as far as the page has been told; a mouse press without one is a skipped pointerdown.
let down = false;
let pending: { target: Element; detail: number; x: number; y: number; mods: MouseEventInit } | null = null;
const mods = (e: MouseEvent): MouseEventInit => ({
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
altKey: e.altKey,
metaKey: e.metaKey,
});
const onPointerUp = (e: PointerEvent) => {
if (!isTrusted(e)) return;
down = false;
if (primaryMouse(e)) lastUp = e.timeStamp;
};
const onPointerCancel = (e: PointerEvent) => {
if (isTrusted(e)) down = false;
};
const onPointerDown = (e: PointerEvent) => {
if (!isTrusted(e)) return;
down = true;
const before = lastUp;
lastUp = null;
pending = null;
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.
setTimeout(finish);
};
const onMouseDown = (e: MouseEvent) => {
if (!isTrusted(e) || e.button !== 0) return;
if (pending) {
pending.detail = e.detail || 1;
return;
}
if (down || !(e.target instanceof Element)) return;
down = true;
send(
e.target,
new PointerEvent("pointerdown", {
bubbles: true,
cancelable: true,
composed: true,
pointerId: MOUSE_POINTER_ID,
pointerType: "mouse",
isPrimary: true,
button: 0,
buttons: 1,
clientX: e.clientX,
clientY: e.clientY,
screenX: e.screenX,
screenY: e.screenY,
...mods(e),
}),
);
};
const finish = () => {
const tap = pending;
pending = null;
if (!tap) return;
down = false;
if (!tap.target.isConnected) return;
const init = {
bubbles: true,
cancelable: true,
composed: true,
button: 0,
buttons: 0,
clientX: tap.x,
clientY: tap.y,
...tap.mods,
};
send(
tap.target,
new PointerEvent("pointerup", { ...init, pointerId: MOUSE_POINTER_ID, pointerType: "mouse", isPrimary: true }),
);
send(tap.target, new MouseEvent("mouseup", { ...init, detail: tap.detail }));
send(tap.target, new MouseEvent("click", { ...init, detail: tap.detail }));
if (tap.detail === 2) send(tap.target, new MouseEvent("dblclick", { ...init, detail: 2 }));
};
const capture = { capture: true };
win.addEventListener("pointerup", onPointerUp as EventListener, capture);
win.addEventListener("pointercancel", onPointerCancel as EventListener, capture);
win.addEventListener("pointerdown", onPointerDown as EventListener, capture);
win.addEventListener("mousedown", onMouseDown as EventListener, capture);
return () => {
win.removeEventListener("pointerup", onPointerUp as EventListener, capture);
win.removeEventListener("pointercancel", onPointerCancel as EventListener, capture);
win.removeEventListener("pointerdown", onPointerDown as EventListener, capture);
win.removeEventListener("mousedown", onMouseDown as EventListener, capture);
};
}