Commit b18b9998da for handsontable.com

commit b18b9998da760dd90585f6bbe0e4abe92d952271
Author: Adrian Dusinkiewicz <155736789+adrianspdev@users.noreply.github.com>
Date:   Mon Aug 31 13:16:05 2026 +0200

    DEV-2687: Make double-tap-to-edit deterministic on devices with touch and mouse listeners (#13306)

    * DEV-2687: Make double-tap-to-edit deterministic on devices with touch and mouse listeners

    On iPad (desktop UA) and other devices that register both touch and mouse
    listeners, #12804 dropped only the browser-synthesized `mouseup` that follows
    a touch tap. The editor then opened only when two `touchend`s landed within
    the 500 ms mouseup pairing slot, while iOS swallows taps under ~250 ms, so a
    double-tap at a human cadence (300-600 ms) failed intermittently. 18.0.0 had
    opened the editor on every tap because the unsuppressed synthesized pair
    completed a second double-click match, the same duplication that caused

    - Add `isTouchSynthesizedMouseEvent()` (helpers/dom/event.ts): reports Blink's
      `sourceCapabilities.firesTouchEvents`, `undefined` when the engine is silent.
    - Walkontable event.ts: gate both the `mousedown` and `mouseup` holder
      listeners with `#isTouchSynthesizedMouseEvent()` (helper result, else the
      500 ms `TOUCH_SYNTHESIZED_MOUSE_WINDOW` after the last touch-driven mouseup),
      and arm the mouseup pairing slot for `TOUCH_DBLTAP_TIMEOUT` (1000 ms, equal
      to the mousedown slot) when the tap came from touch. Mouse double-click
      timing is unchanged; context-menu commands still run once (#12803).
    - TableView reuses the shared helper in `#isSyntheticMouseEvent` (refactor).
    - Playwright spec `tests/e2e/touch-tap-to-edit.spec.ts` (desktop UA + hasTouch,
      page.clock): hooks fire once per tap, 700 ms double-tap opens the editor,
      1200 ms does not and the synthesized pair is dropped, context-menu tap
      inserts one row, script-dispatched events exercise the timing fallback.
    - Document the dual-listener rules in the Walkontable and tests agent guides.

    Regressing commit: 9ca060fe6 (DEV-1926, #12804).

    * DEV-2687: Add changelog entry for PR #13306

    * DEV-2687: Keep isTouchSynthesizedMouseEvent out of the public Handsontable.dom namespace

    * DEV-2687: Detect touch double-taps separately from mouse clicks and gate synthesized events on the touch path

    * DEV-2687: Share the synthesized-mouse window between Walkontable and TableView

    * DEV-2687: Re-read hook counters after the poll so an over-count fails the touch spec

    * DEV-2687: Describe the single-tap change in the changelog entry for PR #13306

    * DEV-2687: Exercise the Blink-reported synthesized pair in the drifted-tap spec

    * DEV-2687: Drop only the first synthesized mouse pair after a tap and stamp it after the tap is handled

    * DEV-2687: Rename the touch-origin helper to a getter and refine the changelog wording

    * DEV-2687: Settle the touch spec on the synthesized click instead of polling counters

    * DEV-2687: Note the straddle residual and give the first-tap spec a real settle barrier

    Document that a pair straddling the ceiling leaves one unpaired mouseup and
    that a long-press keeps the pending pair armed, same as a first tap on an
    unselected cell. Harden the first-tap spec with a trusted mouse click as a
    positive settle barrier instead of relying on runFor(100) alone.

    * DEV-2687: Reset the synthesized-pair gate per gesture and pair taps by coordinates

    * DEV-2687: Store a clone of the tapped cell coordinates

    * DEV-2687: Clear the pending synthesized pair at a scroll gesture's end instead of on touchstart

    * DEV-2687: Handle touchcancel, count clicks document-wide, and keep the drift docs honest

    * DEV-2687: Reset the mouse-down flag when a gesture is cancelled

    * DEV-2687: Clear the pending synthesized pair on touchcancel and pin the cancel path with tests

    A cancelled gesture arms no compatibility pair, so a still-pending flag
    from an earlier preventDefault-ed tap would drop the next real mouse
    pair inside the ceiling on engines that do not report the input origin
    - the same rationale the scroll branch of touchend already applies.

    The drifted-gesture spec goes back to absolute hook counts: the
    relative baseline was read after the drift, so it could no longer catch
    the deferred-mousedown scroll classification (#11659) regressing, and
    beforeOnCellMouseUp went unasserted. The cancelled-gesture spec now
    advances past LONG_PRESS_DELAY to prove the cancel killed the
    long-press timer, and two new specs pin the pending-pair clear and the
    mouse-down flag release (via the document-wide mousemove drag path).

diff --git a/.changelogs/13306.json b/.changelogs/13306.json
new file mode 100644
index 0000000000..7970586d33
--- /dev/null
+++ b/.changelogs/13306.json
@@ -0,0 +1,8 @@
+{
+  "issuesOrigin": "private",
+  "title": "Changed how touch opens the cell editor on devices that report both touch and mouse input, such as iPad: a double-tap opens it (taps up to 1 second apart), and a single tap on an already selected cell no longer opens it as it did in 18.0.0, where duplicated mouse events after the tap triggered the double-click.",
+  "type": "changed",
+  "issueOrPR": 13306,
+  "breaking": false,
+  "framework": "none"
+}
diff --git a/handsontable/src/3rdparty/walkontable/AGENTS.md b/handsontable/src/3rdparty/walkontable/AGENTS.md
index b85552fb1f..b1dd28a594 100644
--- a/handsontable/src/3rdparty/walkontable/AGENTS.md
+++ b/handsontable/src/3rdparty/walkontable/AGENTS.md
@@ -182,6 +182,15 @@ geometry (`isRendered`, no boxes), the styles handler asks whether `getComputedS
 Never guess a container from an empty style read, and never cache a layout decision taken while
 `isRendered()` is false without a way to retake it.

+## Dual-listener devices: touch AND mouse events reach the grid
+
+`isMobileBrowser()` reads the user agent, `isTouchSupported()` reads `'ontouchstart' in window`. iPad Safari/Chrome (desktop UA since iPadOS 13) and Windows touchscreens are **desktop UA + touch**, so `event.ts` registers both listener sets and the browser's synthesized `mousedown`/`mouseup`/`click` sequence after every `touchend` reaches the mouse listeners. Rules:
+
+- **Drop only the first synthesized pair after a tap, gated veto → pending pair → ceiling.** `#isTouchSynthesizedMouseEvent()` gates both `mousedown` and `mouseup` in that order: an engine that reports the origin and says "not touch" wins first (Blink's `sourceCapabilities.firesTouchEvents === false` means a real mouse or pen click, always processed, even right after a tap); otherwise the event is synthesized only if a touch-driven `onMouseUp` just armed `#synthesizedPairPending` AND the `TOUCH_SYNTHESIZED_MOUSE_WINDOW` (500 ms) ceiling since `#lastTouchMouseUpAt` has not passed — a tap that drifted past the move threshold is classified as a scroll by the touch path (no `onMouseDown`/`onMouseUp`, no stamp, pair never armed) — a drift ending over the already-selected cell gets its compatibility pair processed on every engine alike (DEV-2687); a drift over an unselected cell is `preventDefault`-ed and synthesizes nothing — provided no tap armed the pair inside the ceiling; a scroll-classified gesture clears the flag at its `touchend`, before its own compatibility pair arrives. The `mouseup` half consumes the pair (`#synthesizedPairPending = false`), so any later real mouse event inside the ceiling is treated as real — a fill-handle grab, a drag-selection, or a right-click started with a mouse right after a tap works on engines that do not report the input origin (WebKit, Firefox), where the residual gap is only the FIRST synthesized pair itself. Dropping only `mouseup` (the original #12804 fix) fired `onCellMouseDown` twice per tap and re-armed the pairing slot and made iPad double-tap-to-edit nondeterministic. `TOUCH_SYNTHESIZED_MOUSE_WINDOW` lives in `helpers/dom/inputOrigin.ts`, alongside `getMouseEventTouchOrigin()`, and is shared with `tableView.ts` (its `#recentTouchEndTimeout` uses the same constant), so both layers use the same fallback window. `tableView.ts`'s own gate (`#isSyntheticMouseEvent()`) deliberately keeps its older `reported ?? #recentTouchEnd` policy instead of adopting `event.ts`'s consume-once order: a Blink-flagged pair (`firesTouchEvents === true`) must never be allowed to close the editor through the outside-click handler, so the two gates are not to be "harmonized".
+- **Touch double-taps are paired independently of the mouse double-click slots.** `#handleTouchTap()` tracks `#lastTapCoords`/`#lastTapAt` and fires the double-click callbacks when a second tap lands on the same coordinates within `TOUCH_DBLTAP_TIMEOUT` (1000 ms); a long-press, a tap outside the cells, or a tap on different coordinates resets the detector. Coordinates, not the resolved TD, because Walkontable recycles TD elements across scrolls and re-renders (DEV-2687 review). Touch taps never arm `#dblClickOrigin` — only a mouse `mousedown` with `button === 0` does — so a real mouse click right after a tap cannot complete a double-click by pairing with it. Mouse double-click timing (`DBLCLICK_MOUSEDOWN_TIMEOUT` 1000 ms / `DBLCLICK_MOUSEUP_TIMEOUT` 500 ms) is unchanged. `touchcancel` resets the per-gesture state (`onTouchCancel`) — `touchApplied`, the mouse-down flag, the long-press timer, and the pending synthesized pair — so a cancelled gesture cannot leave `touchApplied` stuck and route real mouse events into the tap detector, leave drag-selection armed with no `mouseup` coming, or leave an earlier tap's pending flag dropping the next real mouse pair inside the ceiling.
+- **Before 18.1, a tap on an already-selected cell opened the editor** on these devices (the unsuppressed synthesized pair completed a second double-click match; a first tap on an unselected cell is `preventDefault`-ed and synthesizes nothing). That was a side effect, not a feature; do not "restore" it.
+- Test it in Playwright with `test.use({ ...devices['Desktop Chrome'], hasTouch: true, browserName: 'chromium' })` (`tests/e2e/touch-tap-to-edit.spec.ts`); Chromium synthesizes the same mouse sequence after `locator.tap()`. Use `page.clock` to drive the pairing timers. Script-dispatched `MouseEvent`s have `sourceCapabilities === null` and exercise the WebKit fallback.
+
 ## Known Tech Debt

 - The DAO layer has been replaced by constructor injection + the `wire.ts` composition root (see the DI section above) — do not reintroduce DAO getters or `wot`-god-object passing.
diff --git a/handsontable/src/3rdparty/walkontable/src/event.ts b/handsontable/src/3rdparty/walkontable/src/event.ts
index 7a3b826fc4..71d96d8eaa 100644
--- a/handsontable/src/3rdparty/walkontable/src/event.ts
+++ b/handsontable/src/3rdparty/walkontable/src/event.ts
@@ -14,10 +14,31 @@ import { getCellCoordsFromMousePosition } from './utils/pointerToCoords';
 import { isTouchSupported } from '../../../helpers/feature';
 import { isMobileBrowser, isChromeWebKit, isFirefoxWebKit, isIOS } from '../../../helpers/browser';
 import { isDefined } from '../../../helpers/mixed';
+import { getMouseEventTouchOrigin, TOUCH_SYNTHESIZED_MOUSE_WINDOW } from '../../../helpers/dom/inputOrigin';

 const LONG_PRESS_DELAY = 500;
 const LONG_PRESS_MOVE_THRESHOLD = 10;

+/**
+ * How long (ms) after a mousedown the double-click detector keeps waiting for the matching
+ * second click. Mirrors the OS double-click tolerance with margin.
+ */
+const DBLCLICK_MOUSEDOWN_TIMEOUT = 1000;
+
+/**
+ * How long (ms) after a mouseup the double-click detector keeps the first click "armed"
+ * for a mouse-driven double-click.
+ */
+const DBLCLICK_MOUSEUP_TIMEOUT = 500;
+
+/**
+ * How long (ms) after a touch tap a second tap on the same cell counts as a double-tap. Touch
+ * taps are paired by `#handleTouchTap()`, independently of the mouse double-click slots, so a
+ * real mouse click after a tap never pairs with it. iOS drops taps under ~250 ms and humans
+ * double-tap at 300–600 ms (DEV-2687).
+ */
+const TOUCH_DBLTAP_TIMEOUT = 1000;
+
 /**
  * Assembles the Event module's dependencies from the engine composition context. The DOM roots,
  * settings, event manager, table, and selection manager fold into this one object; the parent Event
@@ -44,6 +65,12 @@ export function createEventDeps(ctx: EngineContext) {
  */
 export type EventDeps = ReturnType<typeof createEventDeps>;

+/**
+ * The cell object `Event#parentCell()` returns: a TD/TH element and its coords, or both `null`
+ * when the event target maps to neither a cell nor a border affordance.
+ */
+type ParentCell = ReturnType<Event['parentCell']>;
+
 /**
  * @class Event
  */
@@ -91,6 +118,21 @@ class Event {
    * @type {number[]}
    */
   #dblClickOrigin: (HTMLElement | null)[] = [null, null];
+  /**
+   * Coordinates of the most recent touch tap, kept for double-tap detection. Coordinates, not the
+   * resolved TD: Walkontable recycles TD elements across scrolls and re-renders, so element
+   * identity can pair two taps that landed on different cells (DEV-2687 review). Touch taps never
+   * arm the mouse double-click slots, so a mouse click after a tap cannot pair with a tap.
+   *
+   * @type {CellCoords|null}
+   */
+  #lastTapCoords: CellCoords | null = null;
+  /**
+   * Timestamp (ms) of the most recent touch tap.
+   *
+   * @type {number}
+   */
+  #lastTapAt: number = 0;
   /**
    * Timer ID for the long-press gesture detection.
    *
@@ -126,13 +168,28 @@ class Event {
   #deferredTouchStartEvent: TouchEvent | null = null;
   /**
    * Timestamp (ms) of the most recent `onMouseUp` call that originated from a touch gesture.
-   * Used to suppress the browser-synthesized `mouseup` event that fires after `touchend` on
-   * devices that register both touch and mouse listeners (e.g. iPad Safari), which would
-   * otherwise trigger `onCellMouseUp` — and context-menu commands — a second time.
+   * On devices that register both touch and mouse listeners (iPad with a desktop UA, Windows
+   * touchscreens) the browser synthesizes a `mousedown`/`mouseup`/`click` sequence after
+   * `touchend`. The whole sequence must be ignored – dropping only one half fires
+   * `onCellMouseDown` a second time per tap, and the leaked pair would act as a phantom mouse
+   * click (DEV-2687).
+   * Used as the fallback for engines that do not expose `sourceCapabilities`
+   * (see `#isTouchSynthesizedMouseEvent`).
    *
    * @type {number}
    */
   #lastTouchMouseUpAt: number = 0;
+  /**
+   * `true` between a touch-driven `onMouseUp` and the browser-synthesized `mouseup` that follows it.
+   * Only that first pair is dropped; once it is consumed, real mouse events pass even inside the
+   * `TOUCH_SYNTHESIZED_MOUSE_WINDOW` ceiling, so a fill-handle drag or a drag-selection started with
+   * a mouse right after a tap works on engines that do not report the input origin (DEV-2687). The
+   * flag is re-armed by every touch-driven `onMouseUp` and cleared when a scroll-classified gesture
+   * ends, so a tap that synthesized nothing cannot swallow that gesture's own compatibility pair.
+   *
+   * @type {boolean}
+   */
+  #synthesizedPairPending: boolean = false;
   /**
    * @type {boolean}
    */
@@ -193,23 +250,31 @@ class Event {
         (event: TouchEvent) => this.onTouchEnd(event));
       this.#deps.eventManager.addEventListener(this.#deps.wtTable.holder, 'touchmove',
         (event: TouchEvent) => this.onTouchMove(event));
+      this.#deps.eventManager.addEventListener(this.#deps.wtTable.holder, 'touchcancel',
+        () => this.onTouchCancel());
       this.#deps.eventManager.addEventListener(this.#deps.wtTable.holder, 'scroll', () => this.onHolderScroll());
     };

     const initMouseEvents = () => {
+      // On devices that register both touch and mouse listeners (e.g. iPad Safari with a
+      // desktop UA), `touchend` already drove `onMouseDown`/`onMouseUp`. The browser then
+      // synthesizes a `mousedown`/`mouseup` pair ~0-50 ms later; drop BOTH halves when the
+      // touch path handled the tap, so `onCellMouseDown` fires once per tap (DEV-2687) and
+      // context-menu commands do not execute twice (#12803).
       this.#deps.eventManager.addEventListener(this.#deps.wtTable.holder, 'mouseup',
         (event: MouseEvent) => {
-          // On devices that register both touch and mouse listeners (e.g. iPad Safari),
-          // `touchend` fires first and calls `onMouseUp` directly. The browser then
-          // synthesizes a `mouseup` event ~0-50 ms later; suppress it to prevent
-          // context-menu commands from executing twice.
-          if (Date.now() - this.#lastTouchMouseUpAt < 500) {
+          if (this.#isTouchSynthesizedMouseEvent(event)) {
             return;
           }
           this.onMouseUp(event);
         });
       this.#deps.eventManager.addEventListener(this.#deps.wtTable.holder, 'mousedown',
-        (event: MouseEvent) => this.onMouseDown(event));
+        (event: MouseEvent) => {
+          if (this.#isTouchSynthesizedMouseEvent(event)) {
+            return;
+          }
+          this.onMouseDown(event);
+        });
     };

     if (isMobileBrowser()) {
@@ -283,6 +348,39 @@ class Event {
     return cell;
   }

+  /**
+   * Decides whether a mouse event caught by the mouse listeners is one half of the browser-synthesized
+   * `mousedown`/`mouseup` pair that follows a touch tap, and must therefore be dropped. Order of the
+   * checks: an engine that reports the input origin and says "not touch" wins (Blink, a real mouse or
+   * pen is never dropped); otherwise the event is synthesized only if a touch-driven `onMouseUp` just
+   * armed the pair (`#synthesizedPairPending`) and the ceiling has not passed. The `mouseup` half
+   * consumes the pair, so any later mouse event inside the ceiling is treated as real. Dropping both
+   * halves keeps `onCellMouseDown` at one call per tap and context-menu commands at one execution
+   * (#12803); a tap that left no stamp (a gesture treated as a scroll) has its pair processed.
+   *
+   * @param {MouseEvent} event The mouse event object.
+   * @returns {boolean}
+   */
+  #isTouchSynthesizedMouseEvent(event: MouseEvent): boolean {
+    if (getMouseEventTouchOrigin(event) === false) {
+      return false;
+    }
+
+    const withinCeiling = Date.now() - this.#lastTouchMouseUpAt < TOUCH_SYNTHESIZED_MOUSE_WINDOW;
+
+    if (!this.#synthesizedPairPending || !withinCeiling) {
+      this.#synthesizedPairPending = false;
+
+      return false;
+    }
+
+    if (event.type === 'mouseup') {
+      this.#synthesizedPairPending = false;
+    }
+
+    return true;
+  }
+
   /**
    * OnMouseDown callback.
    *
@@ -321,8 +419,9 @@ class Event {
       }
     }

-    // doubleclick reacts only for left mouse button or from touch events
-    if (((event as MouseEvent).button === 0 || this.touchApplied) && cell.TD) {
+    // The mouse double-click slots are armed by mouse clicks only; touch taps are paired by
+    // #handleTouchTap() in onMouseUp (DEV-2687).
+    if (!this.touchApplied && (event as MouseEvent).button === 0 && cell.TD) {
       this.#dblClickOrigin[0] = cell.TD;

       if (this.#dblClickTimeout[0] !== null) {
@@ -331,7 +430,7 @@ class Event {

       this.#dblClickTimeout[0] = setTimeout(() => {
         this.#dblClickOrigin[0] = null;
-      }, 1000);
+      }, DBLCLICK_MOUSEDOWN_TIMEOUT);
     }
   }

@@ -468,17 +567,19 @@ class Event {
       this.callListener('onCellMouseUp', event, cell.coords!, cell.TD);
     }

-    // if not left mouse button, and the origin event is not comes from touch
-    if ((event as MouseEvent).button !== 0 && !this.touchApplied) {
+    if (this.touchApplied) {
+      this.#handleTouchTap(event, cell);
+
+      return;
+    }
+
+    // ignore non-left mouse buttons
+    if ((event as MouseEvent).button !== 0) {
       return;
     }

     if (cell.TD && cell.TD === this.#dblClickOrigin[0] && cell.TD === this.#dblClickOrigin[1]) {
-      if (hasClass(eventTargetEl(event)!, 'corner')) {
-        this.callListener('onCellCornerDblClick', event, cell.coords!, cell.TD);
-      } else {
-        this.callListener('onCellDblClick', event, cell.coords!, cell.TD);
-      }
+      this.#fireDblClick(event, cell);

       this.#dblClickOrigin[0] = null;
       this.#dblClickOrigin[1] = null;
@@ -492,7 +593,55 @@ class Event {

       this.#dblClickTimeout[1] = setTimeout(() => {
         this.#dblClickOrigin[1] = null;
-      }, 500);
+      }, DBLCLICK_MOUSEUP_TIMEOUT);
+    }
+  }
+
+  /**
+   * Pairs touch taps into double-taps. Called from `onMouseUp` while `touchApplied` is `true`,
+   * i.e. for the `onMouseUp` that `onTouchEnd` drives. Two taps on the same coordinates within
+   * `TOUCH_DBLTAP_TIMEOUT` fire the double-click callbacks; a long-press, a tap outside the cells,
+   * or a tap on different coordinates resets the detector.
+   *
+   * @param {MouseEvent|TouchEvent} event The event that ended the tap.
+   * @param {ParentCell} cell The tapped cell, as returned by `parentCell()`.
+   */
+  #handleTouchTap(event: MouseEvent | TouchEvent, cell: ParentCell): void {
+    if (this.#longPressFired || !cell.TD || !cell.coords) {
+      this.#lastTapCoords = null;
+
+      return;
+    }
+
+    const now = Date.now();
+    const isSameCell = this.#lastTapCoords !== null &&
+      this.#lastTapCoords.row === cell.coords.row && this.#lastTapCoords.col === cell.coords.col;
+
+    if (isSameCell && now - this.#lastTapAt < TOUCH_DBLTAP_TIMEOUT) {
+      this.#fireDblClick(event, cell);
+      this.#lastTapCoords = null;
+
+      return;
+    }
+
+    // Clone: parentCell()'s border branches return references into the live selection CellRange,
+    // which normalize() mutates in place — a stored alias could silently shift within the
+    // double-tap window.
+    this.#lastTapCoords = cell.coords.clone();
+    this.#lastTapAt = now;
+  }
+
+  /**
+   * Fires the corner or the cell double-click callback for the given cell.
+   *
+   * @param {MouseEvent|TouchEvent} event The event that completed the double-click.
+   * @param {ParentCell} cell The double-clicked cell.
+   */
+  #fireDblClick(event: MouseEvent | TouchEvent, cell: ParentCell): void {
+    if (hasClass(eventTargetEl(event)!, 'corner')) {
+      this.callListener('onCellCornerDblClick', event, cell.coords!, cell.TD!);
+    } else {
+      this.callListener('onCellDblClick', event, cell.coords!, cell.TD!);
     }
   }

@@ -576,8 +725,20 @@ class Event {
     // before/after hooks (e.g. nestedHeaders, ContextMenu close logic).
     // Suppress only for pure scroll gestures, where onMouseDown was never fired.
     if (!wasScrolled || this.#longPressFired) {
-      this.#lastTouchMouseUpAt = Date.now();
+      // The stamp is taken AFTER the tap is handled, so the selection/render work onMouseUp does
+      // is not charged against the TOUCH_SYNTHESIZED_MOUSE_WINDOW ceiling. Everything onMouseUp
+      // triggers is synchronous; the browser only dispatches the synthesized mousedown/mouseup
+      // sequence after this touchend handler returns.
       this.onMouseUp(event);
+      this.#lastTouchMouseUpAt = Date.now();
+      this.#synthesizedPairPending = true;
+    } else {
+      // A pure scroll gesture calls neither onMouseDown nor onMouseUp, so #handleTouchTap never
+      // runs to reset the tap detector. Reset it here so a scroll between two taps can't pair them.
+      this.#lastTapCoords = null;
+      // This gesture armed nothing, so a still-pending flag belongs to an earlier gesture whose
+      // pair never came; clear it so THIS gesture's compatibility pair is processed.
+      this.#synthesizedPairPending = false;
     }

     this.touchApplied = false;
@@ -585,6 +746,34 @@ class Event {
     this.#longPressFired = false;
   }

+  /**
+   * OnTouchCancel callback. Browsers cancel a gesture instead of ending it whenever the system
+   * claims the touch (an edge-swipe, a dialog, a context callout) — `touchend` then never fires.
+   * Without this reset `touchApplied` stays `true` and every REAL mouse `mouseup` is routed into
+   * the touch tap detector, which has no button filter, so two right-clicks or two drag-selections
+   * ending on the same cell within the double-tap window would fire a phantom double-click.
+   * It also releases the mouse-down flag: a long-press fires `onMouseDown` from its timer, and
+   * after a cancel no `mouseup` ever follows, which would leave mouse-move selection dragging
+   * armed until the next click. The pending synthesized-pair flag is cleared for the same reason
+   * the scroll branch of `onTouchEnd` clears it: a cancelled gesture armed nothing, so a
+   * still-pending flag belongs to an earlier gesture whose pair never came, and left in place it
+   * would drop the next real mouse pair inside the ceiling on engines that do not report the
+   * input origin.
+   *
+   * @private
+   */
+  onTouchCancel() {
+    this.#cancelLongPressTimer();
+
+    this.touchApplied = false;
+    this.#mouseDown = false;
+    this.#touchWasMoved = false;
+    this.#longPressFired = false;
+    this.#deferredTouchStartEvent = null;
+    this.#lastTapCoords = null;
+    this.#synthesizedPairPending = false;
+  }
+
   /**
    * Starts the long-press timer. When the timer fires, a synthetic `contextmenu` event is
    * dispatched on the original touch target so that the existing contextmenu hook chain
@@ -616,6 +805,7 @@ class Event {

       this.#dblClickOrigin[0] = null;
       this.#dblClickOrigin[1] = null;
+      this.#lastTapCoords = null;

       if (this.#dblClickTimeout[0] !== null) {
         clearTimeout(this.#dblClickTimeout[0]);
diff --git a/handsontable/src/helpers/dom/__tests__/inputOrigin.unit.ts b/handsontable/src/helpers/dom/__tests__/inputOrigin.unit.ts
new file mode 100644
index 0000000000..ad55a2f0fe
--- /dev/null
+++ b/handsontable/src/helpers/dom/__tests__/inputOrigin.unit.ts
@@ -0,0 +1,19 @@
+import { getMouseEventTouchOrigin } from 'handsontable/helpers/dom/inputOrigin';
+
+describe('inputOrigin helper', () => {
+  describe('getMouseEventTouchOrigin', () => {
+    it('should return true when the browser reports the event originates from a touch device', () => {
+      expect(getMouseEventTouchOrigin({ sourceCapabilities: { firesTouchEvents: true } })).toBe(true);
+    });
+
+    it('should return false when the browser reports a non-touch input device', () => {
+      expect(getMouseEventTouchOrigin({ sourceCapabilities: { firesTouchEvents: false } })).toBe(false);
+    });
+
+    it('should return undefined when the browser does not expose the information', () => {
+      expect(getMouseEventTouchOrigin({})).toBeUndefined();
+      expect(getMouseEventTouchOrigin({ sourceCapabilities: null })).toBeUndefined();
+      expect(getMouseEventTouchOrigin({ sourceCapabilities: undefined })).toBeUndefined();
+    });
+  });
+});
diff --git a/handsontable/src/helpers/dom/inputOrigin.ts b/handsontable/src/helpers/dom/inputOrigin.ts
new file mode 100644
index 0000000000..b4806cabbe
--- /dev/null
+++ b/handsontable/src/helpers/dom/inputOrigin.ts
@@ -0,0 +1,63 @@
+/**
+ * Helpers that tell where an input event came from. Deliberately NOT re-exported through
+ * `Handsontable.dom` (see `src/index.ts`): the detection is a browser-quirk heuristic that may
+ * change, so it stays internal.
+ */
+
+/**
+ * How long (ms) after a touch gesture the browser-synthesized `mousedown`/`mouseup`/`click`
+ * sequence is still expected when the engine does not report the input origin (WebKit,
+ * Firefox). Shared by Walkontable's mouse listeners and TableView's outside-click handling so
+ * both layers use the same fallback window.
+ *
+ * Ceiling, not a target: Walkontable drops only the first `mousedown`/`mouseup` pair after a tap
+ * (see `#synthesizedPairPending`), so the constant only bounds how long that pair may take to
+ * arrive; browsers deliver it within a few hundred milliseconds, which this value covers with
+ * margin. On engines that do not report the input origin (WebKit, Firefox) a real mouse event
+ * that arrives inside the ceiling BEFORE the synthesized pair — e.g. a first tap on an unselected
+ * cell is `preventDefault`-ed and synthesizes nothing, so the pair never comes — is still
+ * dropped: that includes a fill-handle grab or a drag-selection started with a mouse or trackpad
+ * within half a second of such a tap. A pair that straddles the ceiling yields one unpaired
+ * `mouseup` (the `mousedown` is dropped, the `mouseup` is processed and clears the pending pair).
+ * Besides a first tap on an unselected cell, a long-press synthesizes no pair either, so the
+ * pending pair stays armed for the same ceiling after it.
+ */
+export const TOUCH_SYNTHESIZED_MOUSE_WINDOW = 500;
+
+/**
+ * Shape of a UI event on engines that implement the InputDeviceCapabilities API (Blink).
+ */
+interface EventWithSourceCapabilities extends Event {
+  sourceCapabilities?: { firesTouchEvents: boolean } | null;
+}
+
+/**
+ * Checks whether the event carries the `sourceCapabilities` property.
+ *
+ * @param {Event} event The event object.
+ * @returns {boolean}
+ */
+function hasSourceCapabilities(event: Event): event is EventWithSourceCapabilities {
+  return 'sourceCapabilities' in event;
+}
+
+/**
+ * Returns whether a mouse event was synthesized from a touch gesture, as reported by the browser
+ * (`sourceCapabilities.firesTouchEvents`, Blink only) — `true`/`false` when reported, `undefined`
+ * when the engine does not tell (WebKit, Firefox, script-dispatched events). Browsers that fire
+ * touch events synthesize a `mousedown`/`mouseup`/`click` sequence after `touchend` for
+ * compatibility. Only Blink exposes the origin; WebKit and Firefox do not, and script-dispatched
+ * events carry `null`. In those cases the function returns `undefined` so the caller can fall back
+ * to a timing heuristic.
+ *
+ * @param {Event} event The mouse event object.
+ * @returns {boolean|undefined} `true` when synthesized from touch, `false` when it comes from a
+ *                              non-touch device, `undefined` when the browser does not tell.
+ */
+export function getMouseEventTouchOrigin(event: Event): boolean | undefined {
+  if (hasSourceCapabilities(event) && event.sourceCapabilities) {
+    return event.sourceCapabilities.firesTouchEvents === true;
+  }
+
+  return undefined;
+}
diff --git a/handsontable/src/tableView.ts b/handsontable/src/tableView.ts
index 64d392b091..b9386dfad1 100644
--- a/handsontable/src/tableView.ts
+++ b/handsontable/src/tableView.ts
@@ -28,7 +28,13 @@ import {
 import EventManager from './eventManager';
 import { formatCellValue, renderCell } from './renderers/renderCell';
 import { RenderSizeProbe } from './renderSizeProbe';
-import { isImmediatePropagationStopped, isRightClick, isLeftClick, isMiddleClick } from './helpers/dom/event';
+import {
+  isImmediatePropagationStopped,
+  isRightClick,
+  isLeftClick,
+  isMiddleClick,
+} from './helpers/dom/event';
+import { getMouseEventTouchOrigin, TOUCH_SYNTHESIZED_MOUSE_WINDOW } from './helpers/dom/inputOrigin';
 import Walkontable from './3rdparty/walkontable/src';
 import { handleMouseEvent } from './selection/mouseEventHandler';
 import { isRootInstance } from './utils/rootInstance';
@@ -190,10 +196,11 @@ class TableView {
   #mouseDownLastPos: {row: number, col: number} | null = null;
   /**
    * Flag indicating that a touch interaction just ended. Set to `true` on
-   * `touchend` and reset asynchronously via `_registerTimeout`. Used together with
-   * `sourceCapabilities.firesTouchEvents` (Chrome/Blink) to detect synthetic
-   * mouse events that Android fires after touch interactions. These synthetic
-   * events can falsely trigger the outside-click handler, closing editors or
+   * `touchend` and reset asynchronously via `_registerTimeout` after
+   * `TOUCH_SYNTHESIZED_MOUSE_WINDOW`, shared with Walkontable's mouse listeners so both layers
+   * use the same fallback window. Used together with `sourceCapabilities.firesTouchEvents`
+   * (Chrome/Blink) to detect synthetic mouse events that Android fires after touch interactions.
+   * These synthetic events can falsely trigger the outside-click handler, closing editors or
    * popups that just opened via double-tap.
    *
    * @type {boolean}
@@ -502,11 +509,17 @@ class TableView {

       // Clear the flag after the browser's synthetic mouse event sequence completes.
       // Android dispatches mousedown/mouseup/click asynchronously after touchend,
-      // so the flag must survive across multiple event loop ticks.
+      // so the flag must survive across multiple event loop ticks. The window is shared
+      // with Walkontable's mouse listeners (`TOUCH_SYNTHESIZED_MOUSE_WINDOW`), so both
+      // layers use the same fallback window.
+      // The policies deliberately differ: Walkontable drops only the first pending pair
+      // (veto → pending → ceiling), while this layer keeps
+      // `getMouseEventTouchOrigin(event) ?? #recentTouchEnd` — a Blink-flagged pair must never
+      // run the outside-click handling that closes editors.
       this.#recentTouchEndTimeout = this.hot._registerTimeout(() => {
         this.#recentTouchEnd = false;
         this.#recentTouchEndTimeout = null;
-      }, 400);
+      }, TOUCH_SYNTHESIZED_MOUSE_WINDOW);
     });

     this.eventManager.addEventListener(documentElement, 'mousedown', (event) => {
@@ -1540,16 +1553,11 @@ class TableView {
    * Uses `sourceCapabilities.firesTouchEvents` (Chrome/Blink) when available,
    * falls back to the `#recentTouchEnd` flag for other browsers (Firefox, Safari).
    *
-   * @param {MouseEvent} event The mouse event to check.
-   * @private
+   * @param {Event} event The mouse event to check.
    * @returns {boolean}
    */
-  #isSyntheticMouseEvent(event: Event & { sourceCapabilities?: { firesTouchEvents: boolean } }) {
-    if (event.sourceCapabilities) {
-      return event.sourceCapabilities.firesTouchEvents === true;
-    }
-
-    return this.#recentTouchEnd;
+  #isSyntheticMouseEvent(event: Event): boolean {
+    return getMouseEventTouchOrigin(event) ?? this.#recentTouchEnd;
   }

   /**
diff --git a/tests/AGENTS.md b/tests/AGENTS.md
index 71e8d4392c..7064be351e 100644
--- a/tests/AGENTS.md
+++ b/tests/AGENTS.md
@@ -66,6 +66,13 @@ Visual regression is a separate package (`visual-tests/`). Task workflow: the
   on its own, so `scrollTop > 0` passes with the auto-scroller dead. The scroll timer
   reschedules itself, so one `touchmove` past the edge starts it — poll for a further increase
   instead of holding for a fixed time (`waitForTimeout` is banned, see below).
+- Dual-listener devices (iPad with a desktop UA, Windows touchscreens) are emulated with
+  `test.use({ ...devices['Desktop Chrome'], hasTouch: true, browserName: 'chromium' })` — desktop
+  UA keeps `isMobileBrowser()` false while `hasTouch` makes Walkontable register touch AND mouse
+  listeners, and Chromium synthesizes the same mousedown/mouseup/click after `locator.tap()` that
+  iPad Safari does. Drive the pairing timers with `page.clock`. Reference:
+  `e2e/touch-tap-to-edit.spec.ts` (page object in `fixtures/pages/`, not `fixtures/pages/mobile/`,
+  because the fixture is not a mobile-UA grid).

 ## Determinism

diff --git a/tests/e2e/touch-tap-to-edit.spec.ts b/tests/e2e/touch-tap-to-edit.spec.ts
new file mode 100644
index 0000000000..a637fdc6a3
--- /dev/null
+++ b/tests/e2e/touch-tap-to-edit.spec.ts
@@ -0,0 +1,275 @@
+import { devices } from '@playwright/test';
+import { test, expect } from '../fixtures/test';
+import { TouchTapToEditPage } from '../fixtures/pages/TouchTapToEditPage';
+
+/**
+ * Regression spec for DEV-2687: double-tap-to-edit became nondeterministic on iPad
+ * (desktop UA WebKit) after #12804 dropped only the synthesized `mouseup` that
+ * follows a touch tap. Desktop UA + `hasTouch` reproduces the iPad listener setup
+ * (both touch and mouse listeners) in Chromium, which synthesizes the same
+ * mousedown/mouseup/click sequence after every tap.
+ *
+ * `page.clock` drives the double-click pairing timers and `Date.now()` so the
+ * cadence under test is exact — no sleeps. `click` is the last event the browser
+ * synthesizes after a tap, so `grid.settleOnClicks()` (poll the click counter) is the
+ * settle barrier for the browser's asynchronous compatibility sequence; the mouse
+ * counters are then read with `grid.expectHookCountExactly()`, a plain (non-polling)
+ * assertion, so an over-count fails instead of passing on a first match.
+ */
+test.use({
+  ...devices['Desktop Chrome'],
+  hasTouch: true,
+  browserName: 'chromium',
+});
+
+const CLOCK_START = new Date('2026-08-28T10:00:00Z');
+
+test.describe('touch tap-to-edit on a device with touch and mouse listeners', () => {
+  let grid: TouchTapToEditPage;
+
+  test.beforeEach(async ({ page, theme, bundle }) => {
+    await page.clock.install({ time: CLOCK_START });
+    grid = new TouchTapToEditPage(page, theme, bundle);
+    await grid.goto();
+    await page.clock.pauseAt(CLOCK_START.getTime() + 60_000);
+  });
+
+  test('a first tap on an unselected cell is preventDefault-ed: no synthesized mouse pair, hooks fire once', async ({ page }) => {
+    await grid.tapCell(1, 1);
+    await page.clock.runFor(100);
+
+    await grid.expectHookCountExactly('click', 0);
+    await grid.expectHookCountExactly('beforeOnCellMouseDown', 1);
+    await grid.expectHookCountExactly('beforeOnCellMouseUp', 1);
+    await grid.expectEditorClosed();
+
+    // A real mouse click is a positive barrier: its trusted `click` lands after anything the tap
+    // could still have in flight, so the counts below are final — the tap contributed exactly one
+    // mousedown/mouseup and no synthesized pair.
+    await grid.clickCell(1, 1);
+    await grid.settleOnClicks(1);
+
+    await grid.expectHookCountExactly('beforeOnCellMouseDown', 2);
+    await grid.expectHookCountExactly('beforeOnCellMouseUp', 2);
+    await grid.expectEditorClosed();
+  });
+
+  test('a double-tap at a 700 ms cadence opens the editor', async ({ page }) => {
+    // Select-first protocol from the ticket: one tap selects, then the double-tap.
+    await grid.tapCell(1, 1);
+    await page.clock.runFor(1500);
+
+    await grid.tapCell(1, 1);
+    await page.clock.runFor(700);
+    await grid.tapCell(1, 1);
+
+    // Click count Chromium synthesizes: the select-first tap is preventDefault-ed (0), the second
+    // tap lands on the already-selected cell and synthesizes a pair (1), and so does the third (2).
+    await grid.settleOnClicks(2);
+    await grid.expectHookCountExactly('afterBeginEditing', 1);
+    await grid.expectEditorOpen();
+  });
+
+  test('two taps more than a second apart are not a double-tap, and the second tap\'s synthesized mouse pair is ignored', async ({ page }) => {
+    await grid.tapCell(1, 1);
+    await page.clock.runFor(1200);
+    await grid.tapCell(1, 1);
+
+    // The second tap hits an already-selected cell, so the browser synthesizes a mouse pair here
+    // (the first tap is preventDefault-ed and gets none) — both halves must be dropped. This is
+    // the test that proves the symmetric drop: pre-fix, the dropped mousedown would leak through
+    // and beforeOnCellMouseDown would read 3.
+    await grid.settleOnClicks(1);
+    await grid.expectHookCountExactly('beforeOnCellMouseDown', 2);
+    await grid.expectHookCountExactly('beforeOnCellMouseUp', 2);
+    await grid.expectEditorClosed();
+  });
+
+  test('tapping a context-menu item runs the command once (#12803 stays fixed)', async () => {
+    await grid.tapCell(1, 1);
+    await grid.openContextMenu(1, 1);
+
+    await grid.tapContextMenuItem('Insert row above');
+
+    // Click trace with the document-wide counter: the first tap lands on an unselected cell and
+    // is preventDefault-ed, synthesizing nothing (0); the right-click that opens the context menu
+    // never fires a `click` event at all — Chromium fires `click` only for the primary button (0);
+    // the menu item's `.tap()` synthesizes one click on the ContextMenu's own Handsontable root,
+    // which the document-wide listener now covers (1). Total: 1 — the settle barrier for the
+    // duplicate-command guard this test exists for (#12803).
+    await grid.settleOnClicks(1);
+    await grid.expectHookCountExactly('afterCreateRow', 1);
+    expect(await grid.rowCount()).toBe(6);
+  });
+
+  test('mouse events without an origin right after a tap are ignored, then accepted once the window passed', async ({ page }) => {
+    // Script-dispatched events carry sourceCapabilities === null, which is the
+    // WebKit/Firefox path: the engine falls back to the 500 ms window after the tap.
+    await grid.tapCell(1, 1);
+    await page.clock.runFor(100);
+    await grid.expectHookCountExactly('beforeOnCellMouseDown', 1);
+    await grid.expectHookCountExactly('beforeOnCellMouseUp', 1);
+
+    // The first script-dispatched pair after the tap: both halves are the dropped synthesized
+    // pair, and the mouseup half consumes #synthesizedPairPending.
+    await grid.dispatchMouseEvent(1, 1, 'mousedown');
+    await grid.dispatchMouseEvent(1, 1, 'mouseup');
+
+    await page.clock.runFor(100);
+    await grid.expectHookCountExactly('beforeOnCellMouseDown', 1);
+    await grid.expectHookCountExactly('beforeOnCellMouseUp', 1);
+
+    // Consume-once proof: still inside the TOUCH_SYNTHESIZED_MOUSE_WINDOW ceiling, but the pair
+    // was already consumed by the previous mouseup, so this THIRD script pair is processed as a
+    // real click — counters go 1 -> 2. This is the assertion that fails if #synthesizedPairPending
+    // is not reset on mouseup (the pre-fix gate would keep dropping every event for the whole
+    // 500 ms ceiling).
+    await grid.dispatchMouseEvent(1, 1, 'mousedown');
+    await grid.dispatchMouseEvent(1, 1, 'mouseup');
+
+    await page.clock.runFor(100);
+    await grid.expectHookCountExactly('beforeOnCellMouseDown', 2);
+    await grid.expectHookCountExactly('beforeOnCellMouseUp', 2);
+
+    await page.clock.runFor(600);
+
+    // Well past the ceiling too: a fourth pair is processed just the same.
+    await grid.dispatchMouseEvent(1, 1, 'mousedown');
+    await grid.dispatchMouseEvent(1, 1, 'mouseup');
+
+    await page.clock.runFor(100);
+    await grid.expectHookCountExactly('beforeOnCellMouseDown', 3);
+    await grid.expectHookCountExactly('beforeOnCellMouseUp', 3);
+
+    // None of the late pairs pair with the tap.
+    await grid.expectEditorClosed();
+  });
+
+  test('a tap followed by a real mouse click on the same cell does not open the editor', async ({ page }) => {
+    await grid.tapCell(1, 1);
+    await page.clock.runFor(300);
+
+    // A physical mouse click: Chromium reports firesTouchEvents === false, so it is processed.
+    await grid.clickCell(1, 1);
+
+    // Tap on an unselected cell synthesizes no click (0); the real mouse click is a trusted click (1).
+    await grid.settleOnClicks(1);
+    await grid.expectHookCountExactly('beforeOnCellMouseDown', 2);
+    await grid.expectHookCountExactly('beforeOnCellMouseUp', 2);
+    await grid.expectEditorClosed();
+  });
+
+  test('a drifted gesture over the selected cell lets the browser mouse pair through on the fallback path', async ({ page }) => {
+    // Select the cell first; its own tap is preventDefault-ed and synthesizes nothing.
+    await grid.tapCell(2, 1);
+    await page.clock.runFor(200);
+
+    // Drift over the SELECTED cell — nothing is preventDefault-ed there, so real engines do
+    // synthesize a compatibility pair for this gesture; its touchend (scroll branch) cleared the
+    // pending flag, so the pair, carrying no origin information, must be processed. The counts
+    // are absolute on purpose: the tap above contributed exactly one mousedown/mouseup, and a
+    // scroll-classified drift must contribute none — this is the assertion that proves the
+    // deferred-mousedown scroll classification (#11659) still holds.
+    await grid.dispatchTouchDrag(2, 1, 20);
+    await grid.expectHookCountExactly('beforeOnCellMouseDown', 1);
+    await grid.expectHookCountExactly('beforeOnCellMouseUp', 1);
+
+    await grid.dispatchMouseEvent(2, 1, 'mousedown');
+    await grid.dispatchMouseEvent(2, 1, 'mouseup');
+
+    await grid.expectHookCountExactly('beforeOnCellMouseDown', 2);
+    await grid.expectHookCountExactly('beforeOnCellMouseUp', 2);
+    await grid.expectEditorClosed();
+  });
+
+  test('a scroll gesture between two taps cancels the double-tap pairing', async ({ page }) => {
+    await grid.tapCell(1, 1);
+    await page.clock.runFor(200);
+
+    // A drifted (scroll-classified) gesture on the same cell resets the tap detector.
+    await grid.dispatchTouchDrag(1, 1, 20);
+    await page.clock.runFor(200);
+
+    await grid.tapCell(1, 1);
+    await page.clock.runFor(100);
+
+    await grid.expectHookCountExactly('afterBeginEditing', 0);
+    await grid.expectEditorClosed();
+  });
+
+  test('a pair armed by a tap that synthesized nothing does not swallow the next gesture\'s pair', async ({ page }) => {
+    // First tap on an unselected cell: preventDefault-ed, no pair arrives, but the gate was armed.
+    await grid.tapCell(1, 1);
+    await page.clock.runFor(200);
+
+    // A drifted gesture ON THE SELECTED CELL, still inside the armed ceiling: selectedCellWasTouched()
+    // is true, so nothing is preventDefault-ed and Blink does synthesize a pair for it (firesTouchEvents
+    // === true, no veto). The touch path left no stamp, and the scroll-classified touchend cleared the
+    // still-pending flag from the first tap, so this gesture's own pair must be processed.
+    await grid.dispatchTouchDrag(1, 1, 20);
+    await grid.dispatchMouseEvent(3, 1, 'mousedown', true);
+    await grid.dispatchMouseEvent(3, 1, 'mouseup', true);
+
+    await grid.expectSelectedCell(3, 1);
+  });
+
+  test('a cancelled gesture does not leave real mouse clicks pairing as taps', async ({ page }) => {
+    await grid.dispatchTouchCancel(1, 1);
+
+    // The cancel also killed the long-press timer: well past LONG_PRESS_DELAY nothing fires —
+    // no long-press mousedown, no synthetic contextmenu.
+    await page.clock.runFor(600);
+    await grid.expectHookCountExactly('beforeOnCellMouseDown', 0);
+    await grid.expectContextMenuClosed();
+
+    // Two real right-clicks on the same cell inside the double-tap window: with touchApplied
+    // stuck they would route into the tap detector and open the editor.
+    await grid.openContextMenu(1, 1);
+    await page.keyboard.press('Escape');
+    await page.clock.runFor(300);
+    await grid.openContextMenu(1, 1);
+    await page.keyboard.press('Escape');
+    await page.clock.runFor(100);
+
+    await grid.expectHookCountExactly('afterBeginEditing', 0);
+    await grid.expectEditorClosed();
+  });
+
+  test('a cancelled gesture does not leave a stale pending pair swallowing the next real mouse pair', async ({ page }) => {
+    // First tap on an unselected cell: preventDefault-ed, no pair arrives, but the gate was armed.
+    await grid.tapCell(1, 1);
+    await page.clock.runFor(200);
+
+    // The next gesture is cancelled, not ended: the cancel must clear the leftover flag the same
+    // way a scroll gesture's touchend does.
+    await grid.dispatchTouchCancel(2, 1);
+
+    // A real mouse pair with no origin information (the WebKit/Firefox fallback), still inside
+    // the 500 ms ceiling from the tap: were the stale flag to survive the cancel, the mousedown
+    // half would be dropped and the selection would stay on the tapped cell.
+    await grid.dispatchMouseEvent(3, 1, 'mousedown');
+    await grid.dispatchMouseEvent(3, 1, 'mouseup');
+
+    await grid.expectSelectedCell(3, 1);
+  });
+
+  test('a cancelled long-press releases the mouse-down flag: no drag-selection follows', async ({ page }) => {
+    // A held touch: the long-press timer fires the mousedown (selecting the cell) and opens the
+    // context menu through the synthetic contextmenu event.
+    await grid.dispatchTouchEvent(1, 1, 'touchstart');
+    await page.clock.runFor(600);
+    await grid.expectHookCountExactly('beforeOnCellMouseDown', 1);
+    await grid.expectSelectedCell(1, 1);
+    await grid.expectContextMenuOpen();
+
+    // The system claims the touch: no touchend follows, so no mouseup ever pairs the long-press
+    // mousedown — only the cancel path can release the mouse-down flag.
+    await grid.dispatchTouchEvent(1, 1, 'touchcancel');
+    await page.keyboard.press('Escape');
+
+    // A mousemove far outside the viewport: with the mouse-down flag stuck, drag-selection would
+    // extend the selection to the nearest edge cell.
+    await grid.dispatchMouseMove(10_000, 10_000);
+    await grid.expectSelectedCell(1, 1);
+  });
+});
diff --git a/tests/fixtures/demo/touch-tap-to-edit.html b/tests/fixtures/demo/touch-tap-to-edit.html
new file mode 100644
index 0000000000..90a7535e78
--- /dev/null
+++ b/tests/fixtures/demo/touch-tap-to-edit.html
@@ -0,0 +1,90 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <title>Handsontable touch tap-to-edit fixture (DEV-2687)</title>
+  <link rel="stylesheet" href="/handsontable/styles/handsontable.min.css">
+  <script>
+    // Theme and bundle come from the ?theme=/?bundle= query params set by the
+    // Playwright projects, mapped through fixed allowlists to LITERALS so a
+    // crafted param can never be reflected into the document — no XSS. An
+    // ABSENT param keeps the default (main / plain UMD); an unknown value THROWS
+    // instead of falling back (a typo in the project config must be one red leg).
+    const htParams = new URLSearchParams(location.search);
+
+    window.htTheme = ({ main: 'main', horizon: 'horizon', classic: 'classic' })[htParams.get('theme') ?? 'main'];
+    if (!window.htTheme) {
+      throw new Error('Unknown ?theme= value: ' + JSON.stringify(htParams.get('theme')));
+    }
+    document.write('<link rel="stylesheet" href="/handsontable/styles/ht-theme-' + window.htTheme + '.min.css">');
+    window.htBundle = ({ umd: 'handsontable.js', 'full-min': 'handsontable.full.min.js' })[htParams.get('bundle') ?? 'umd'];
+    if (!window.htBundle) {
+      throw new Error('Unknown ?bundle= value: ' + JSON.stringify(htParams.get('bundle')));
+    }
+  </script>
+  <style> body { font-family: sans-serif; margin: 1rem; } </style>
+</head>
+<body>
+  <!--
+    Fixture for DEV-2687 (double-tap-to-edit on devices that register both touch and
+    mouse listeners). Visit it with a DESKTOP user agent plus `hasTouch: true` — that is
+    the iPad-with-desktop-UA configuration: isMobileBrowser() is false, isTouchSupported()
+    is true, so Walkontable listens to touch AND mouse events and the browser's synthesized
+    mouse sequence after each tap reaches the grid. Rows are tall so taps land reliably.
+  -->
+  <div id="grid" data-testid="grid"></div>
+
+  <script>
+    document.write('<scr' + 'ipt src="/handsontable/dist/' + window.htBundle + '"></scr' + 'ipt>');
+  </script>
+  <script>
+    const container = document.querySelector('[data-testid="grid"]');
+
+    container.className = `ht-theme-${window.htTheme}`;
+
+    window.hookCounts = {
+      beforeOnCellMouseDown: 0,
+      beforeOnCellMouseUp: 0,
+      afterBeginEditing: 0,
+      afterCreateRow: 0,
+      click: 0,
+    };
+
+    const testIdRenderer = function(instance, td, row, col, prop, value, cellProperties) {
+      Handsontable.renderers.TextRenderer.apply(this, arguments);
+      td.setAttribute('data-testid', `cell-${row}-${col}`);
+    };
+
+    window.hot = new Handsontable(container, {
+      data: [
+        ['A1', 'B1', 'C1'],
+        ['A2', 'B2', 'C2'],
+        ['A3', 'B3', 'C3'],
+        ['A4', 'B4', 'C4'],
+        ['A5', 'B5', 'C5'],
+      ],
+      colHeaders: true,
+      rowHeaders: true,
+      rowHeights: 40,
+      colWidths: 120,
+      contextMenu: ['row_above', 'row_below', 'remove_row'],
+      renderer: testIdRenderer,
+      licenseKey: 'non-commercial-and-evaluation',
+      beforeOnCellMouseDown() { window.hookCounts.beforeOnCellMouseDown += 1; },
+      beforeOnCellMouseUp() { window.hookCounts.beforeOnCellMouseUp += 1; },
+      afterBeginEditing() { window.hookCounts.afterBeginEditing += 1; },
+      afterCreateRow() { window.hookCounts.afterCreateRow += 1; },
+    });
+
+    // `click` is the last event the browser synthesizes after a tap, so counting it (capture
+    // phase, so it is seen regardless of what a listener further down does with the event) gives
+    // the spec a settle barrier that distinguishes "nothing synthesized" (0, a preventDefault-ed
+    // tap) from "a pair was synthesized and dropped" (1) (DEV-2687). Listens on `document`, not
+    // `container`: the ContextMenu plugin opens a SEPARATE Handsontable root outside the fixture's
+    // grid container, so a tap on a menu item synthesizes its click there — a `container`-scoped
+    // listener would never see it and the #12803 duplicate-command guard would have no barrier for
+    // that click. Clicks on the grid itself still bubble to `document` and count exactly once.
+    document.addEventListener('click', () => { window.hookCounts.click += 1; }, true);
+  </script>
+</body>
+</html>
diff --git a/tests/fixtures/pages/TouchTapToEditPage.ts b/tests/fixtures/pages/TouchTapToEditPage.ts
new file mode 100644
index 0000000000..529e7178c0
--- /dev/null
+++ b/tests/fixtures/pages/TouchTapToEditPage.ts
@@ -0,0 +1,253 @@
+import { type Page, type Locator, expect } from '@playwright/test';
+import type { HookCounterName } from './windowTypes';
+
+/**
+ * Page Object for the DEV-2687 touch tap-to-edit fixture. The spec using it must run
+ * with a desktop user agent and `hasTouch: true` (the iPad-with-desktop-UA setup that
+ * makes Walkontable register both touch and mouse listeners).
+ */
+export class TouchTapToEditPage {
+  readonly page: Page;
+  readonly theme: string;
+  readonly bundle: string;
+  readonly grid: Locator;
+  readonly contextMenu: Locator;
+
+  constructor(page: Page, theme = 'main', bundle = 'umd') {
+    this.page = page;
+    this.theme = theme;
+    this.bundle = bundle;
+    this.grid = page.getByTestId('grid');
+    this.contextMenu = page.locator('.htContextMenu.handsontable');
+  }
+
+  /**
+   * Navigate to the fixture and wait for the grid to render.
+   */
+  async goto(): Promise<void> {
+    await this.page.goto(`/tests/fixtures/demo/touch-tap-to-edit.html?theme=${this.theme}&bundle=${this.bundle}`);
+    await expect(this.cell(0, 0)).toBeVisible();
+  }
+
+  /**
+   * A single data cell, by visual row/column, via its stable test id.
+   */
+  cell(row: number, col: number): Locator {
+    return this.page.getByTestId(`cell-${row}-${col}`);
+  }
+
+  /**
+   * Tap a cell with a touch gesture. Chromium follows the tap with a synthesized
+   * mousedown/mouseup/click sequence, exactly like iPad Safari does.
+   */
+  async tapCell(row: number, col: number): Promise<void> {
+    await this.cell(row, col).tap();
+  }
+
+  /**
+   * A mouse click on a cell, as opposed to `tapCell()`'s touch gesture.
+   */
+  async clickCell(row: number, col: number): Promise<void> {
+    await this.cell(row, col).click();
+  }
+
+  /**
+   * Dispatch a script-created mouse event on a cell. Without `firesTouchEvents`, the event
+   * carries `sourceCapabilities === null`, which forces the engine onto the WebKit/Firefox
+   * timing fallback. Passing `firesTouchEvents` stamps the event with a Chromium
+   * `InputDeviceCapabilities` object, reproducing what Blink itself attaches to its own
+   * synthesized compatibility pairs (`sourceCapabilities.firesTouchEvents === true`) — the
+   * branch that only a real `sourceCapabilities` report, not a script-dispatched `null`, can
+   * exercise.
+   */
+  async dispatchMouseEvent(
+    row: number,
+    col: number,
+    type: 'mousedown' | 'mouseup',
+    firesTouchEvents?: boolean
+  ): Promise<void> {
+    await this.cell(row, col).evaluate((td, { eventType, fires }) => {
+      interface MouseEventInitWithSourceCapabilities extends MouseEventInit {
+        sourceCapabilities?: { firesTouchEvents: boolean };
+      }
+
+      const init: MouseEventInitWithSourceCapabilities = { bubbles: true, cancelable: true, button: 0 };
+
+      if (fires !== undefined) {
+        init.sourceCapabilities = new window.InputDeviceCapabilities({ firesTouchEvents: fires });
+      }
+
+      td.dispatchEvent(new MouseEvent(eventType, init));
+    }, { eventType: type, fires: firesTouchEvents });
+  }
+
+  /**
+   * Dispatch a script-created touch gesture that drifts `dx` pixels before lifting: a
+   * `touchstart`, a `touchmove` offset by `dx`, then a `touchend`. These touch events drive
+   * Walkontable's 10 px move-threshold path (`LONG_PRESS_MOVE_THRESHOLD`): past that offset the
+   * touch path classifies the gesture as a scroll and fires no cell mouse hooks. The mouse pair
+   * that follows is dispatched separately, with `dispatchMouseEvent()`, to drive the
+   * synthesized-event origin gate.
+   */
+  async dispatchTouchDrag(row: number, col: number, dx: number): Promise<void> {
+    await this.cell(row, col).evaluate((td, dxOffset) => {
+      const rect = td.getBoundingClientRect();
+      const x = rect.left + rect.width / 2;
+      const y = rect.top + rect.height / 2;
+      const touch = (clientX: number) => new Touch({
+        identifier: 1,
+        target: td,
+        clientX,
+        clientY: y,
+        pageX: clientX + window.scrollX,
+        pageY: y + window.scrollY,
+      });
+      const fire = (type: string, clientX: number, ended: boolean) => td.dispatchEvent(new TouchEvent(type, {
+        bubbles: true,
+        cancelable: true,
+        composed: true,
+        touches: ended ? [] : [touch(clientX)],
+        targetTouches: ended ? [] : [touch(clientX)],
+        changedTouches: [touch(clientX)],
+      }));
+
+      fire('touchstart', x, false);
+      fire('touchmove', x + dxOffset, false);
+      fire('touchend', x + dxOffset, true);
+    }, dx);
+  }
+
+  /**
+   * Dispatch a script touch event of the given type on a cell. `touchstart` carries the touch in
+   * the active lists; `touchcancel` reports it only in `changedTouches` (the finger is gone).
+   */
+  async dispatchTouchEvent(row: number, col: number, type: 'touchstart' | 'touchcancel'): Promise<void> {
+    await this.cell(row, col).evaluate((td, eventType) => {
+      const rect = td.getBoundingClientRect();
+      const x = rect.left + rect.width / 2;
+      const y = rect.top + rect.height / 2;
+      const touch = new Touch({
+        identifier: 1,
+        target: td,
+        clientX: x,
+        clientY: y,
+        pageX: x + window.scrollX,
+        pageY: y + window.scrollY,
+      });
+      const activeTouches = eventType === 'touchstart' ? [touch] : [];
+
+      td.dispatchEvent(new TouchEvent(eventType, {
+        bubbles: true,
+        cancelable: true,
+        composed: true,
+        touches: activeTouches,
+        targetTouches: activeTouches,
+        changedTouches: [touch],
+      }));
+    }, type);
+  }
+
+  /**
+   * Dispatch a script touchstart followed by a touchcancel on a cell — the shape of a gesture the
+   * system claims (edge-swipe, dialog): `touchend` never fires.
+   */
+  async dispatchTouchCancel(row: number, col: number): Promise<void> {
+    await this.dispatchTouchEvent(row, col, 'touchstart');
+    await this.dispatchTouchEvent(row, col, 'touchcancel');
+  }
+
+  /**
+   * Dispatch a script mousemove on the document at the given client coordinates. Walkontable
+   * listens for mousemove document-wide, so coordinates far outside the grid drive the
+   * drag-selection (`onCellMouseOverOutside`) path when its mouse-down flag is armed.
+   */
+  async dispatchMouseMove(clientX: number, clientY: number): Promise<void> {
+    await this.page.evaluate(({ x, y }) => {
+      document.dispatchEvent(new MouseEvent('mousemove', {
+        bubbles: true,
+        cancelable: true,
+        clientX: x,
+        clientY: y,
+        button: 0,
+      }));
+    }, { x: clientX, y: clientY });
+  }
+
+  /**
+   * Read one of the fixture's hook counters.
+   */
+  async hookCount(name: HookCounterName): Promise<number> {
+    return this.page.evaluate(counter => window.hookCounts[counter], name);
+  }
+
+  /**
+   * Wait until the browser has finished the compatibility sequence for the taps performed so far.
+   * `click` is the last event the browser synthesizes after a tap, so polling the click counter to
+   * its expected value is the settle barrier; the mouse counters are then read with a plain
+   * `expect`, which makes them exact rather than first-match assertions.
+   */
+  async settleOnClicks(expectedClicks: number): Promise<void> {
+    await expect.poll(() => this.hookCount('click')).toBe(expectedClicks);
+  }
+
+  /**
+   * Exact read of a hook counter. Call `settleOnClicks()` first so the browser's synthesized
+   * sequence has landed.
+   */
+  async expectHookCountExactly(name: HookCounterName, expected: number): Promise<void> {
+    expect(await this.hookCount(name)).toBe(expected);
+  }
+
+  /**
+   * Whether the active cell editor is currently open (the engine's own state, not DOM visibility —
+   * the `.handsontableInput` textarea is always rendered, off-screen when closed).
+   */
+  async isEditorOpen(): Promise<boolean> {
+    return this.page.evaluate(() => window.hot.getActiveEditor()?.isOpened() ?? false);
+  }
+
+  async expectEditorOpen(): Promise<void> {
+    await expect.poll(() => this.isEditorOpen()).toBe(true);
+  }
+
+  async expectEditorClosed(): Promise<void> {
+    await expect.poll(() => this.isEditorOpen()).toBe(false);
+  }
+
+  /**
+   * Number of rows currently in the grid.
+   */
+  async rowCount(): Promise<number> {
+    return this.page.evaluate(() => window.hot.countRows());
+  }
+
+  /**
+   * Assert that the given cell is the sole current selection.
+   */
+  async expectSelectedCell(row: number, col: number): Promise<void> {
+    await expect.poll(() => this.page.evaluate(() => window.hot.getSelectedLast())).toEqual([row, col, row, col]);
+  }
+
+  /**
+   * Open the context menu on a cell with a mouse right-click and wait for it.
+   */
+  async openContextMenu(row: number, col: number): Promise<void> {
+    await this.cell(row, col).click({ button: 'right' });
+    await expect(this.contextMenu).toBeVisible();
+  }
+
+  async expectContextMenuOpen(): Promise<void> {
+    await expect(this.contextMenu).toBeVisible();
+  }
+
+  async expectContextMenuClosed(): Promise<void> {
+    await expect(this.contextMenu).not.toBeVisible();
+  }
+
+  /**
+   * Tap a context-menu entry by its visible label.
+   */
+  async tapContextMenuItem(label: string): Promise<void> {
+    await this.contextMenu.getByText(label, { exact: true }).tap();
+  }
+}
diff --git a/tests/fixtures/pages/windowTypes.ts b/tests/fixtures/pages/windowTypes.ts
index 855cbfb299..94f7017bdc 100644
--- a/tests/fixtures/pages/windowTypes.ts
+++ b/tests/fixtures/pages/windowTypes.ts
@@ -106,6 +106,12 @@ export interface MoveCellsHookRecord {
   isCopy: boolean;
 }

+/**
+ * Hook counters the DEV-2687 touch tap-to-edit fixture exposes on `window.hookCounts`.
+ */
+export type HookCounterName =
+  'beforeOnCellMouseDown' | 'beforeOnCellMouseUp' | 'afterBeginEditing' | 'afterCreateRow' | 'click';
+
 declare global {
   interface Window {
     /** The fixture's live Handsontable instance. */
@@ -128,5 +134,12 @@ declare global {
     hookLog: { name: string, args: unknown[] }[];
     /** Makes the fixture's `beforeMoveCells` listener return `false`. */
     setBeforeMoveCellsVeto(shouldVeto: boolean): boolean;
+    /** Per-hook invocation counters of the touch tap-to-edit fixture (DEV-2687). */
+    hookCounts: Record<HookCounterName, number>;
+    /**
+     * Chromium-only InputDeviceCapabilities constructor, used to stamp synthetic mouse events
+     * with their origin (DEV-2687).
+     */
+    InputDeviceCapabilities: new (init: { firesTouchEvents: boolean }) => { firesTouchEvents: boolean };
   }
 }