Skip to content

Workspace permanently locks after a missed pointerup (duplicate pointerdown for an already-active pointer) — regression since v12.0.0 #9955

@hcooch2ch3

Description

@hcooch2ch3

Description

On real touch devices the browser sometimes emits a pointerdown for a pointer id that is already down (a "missed pointerup" — Blockly's own code anticipates this: "That's funny. We must have missed a mouse up."). Since v12.0.0, when this happens the workspace becomes permanently unresponsive: Blockly.Gesture.inProgress() stays true forever, no block can be dragged and the toolbox flyout won't open, until the page is reloaded. v11.2.2 recovered from the same input.

I hit this in production on Android tablets during 3-finger touches (the OS/Chrome emits duplicate pointerdowns for an active pointer id under multitouch). Captured on a real device: the freeze is immediately preceded by console.warn("Tried to start the same gesture twice."), confirming the code path below.

Reproduction (deterministic, headless — any inject options)

const ws = Blockly.inject(div, {});                 // options don't matter
const svg = ws.getParentSvg(), r = svg.getBoundingClientRect();
const x = r.x + r.width / 2, y = r.y + r.height / 2;
const pe = (type, id) => { const up = /up|cancel/.test(type);
  return new PointerEvent(type, {pointerId: id, pointerType: 'touch', isPrimary: true,
    clientX: x, clientY: y, button: up ? -1 : 0, buttons: up ? 0 : 1,
    bubbles: true, cancelable: true, composed: true}); };

document.elementFromPoint(x, y).dispatchEvent(pe('pointerdown', 1)); // finger down
document.elementFromPoint(x, y).dispatchEvent(pe('pointerdown', 1)); // SAME id down again (missed pointerup)
document.dispatchEvent(pe('pointerup', 1));                          // up

console.log(Blockly.Gesture.inProgress());
// v12.0.0 .. v13.0.0-beta.8 and master: true  (BUG — workspace locked)
// v11.2.2: false                              (recovers)

Expected

After all pointers are released the gesture ends and the workspace stays interactive (as in v11.2.2).

Actual

Gesture.inProgress() stays true; the workspace ignores all further input until reload.

Version matrix (empirical)

Version Result
11.2.2 OK (recovers)
12.0.0 / 12.3.1 / 12.5.0 / 12.5.1 permanently locked
13.0.0-beta.8 permanently locked
master c531405 (built) permanently locked

(11.2.2 is the last release that recovers; 12.0.0 is the first that locks. The exact culprit commit between them has not been bisected — the matrix above is the boundary, and the master-HEAD repro alone justifies the fix regardless of when it regressed.)

Root cause

  1. WorkspaceSvg binds pointerdown via browserEvents.conditionalBind, which runs Touch.shouldHandleEventTouch.checkTouchIdentifier first. On the first pointerdown this sets the module-global touchIdentifier_.
  2. The duplicate pointerdown reaches WorkspaceSvg.getGesture (core/workspace_svg.ts), where:
    if (isStart && this.currentGesture_?.hasStarted()) {
      console.warn('Tried to start the same gesture twice.');
      this.currentGesture_.cancel();   // → Gesture.dispose() → Touch.clearTouchIdentifier()  ⇒ touchIdentifier_ = null
    }
    if (!this.currentGesture_ && isStart) {
      this.currentGesture_ = new Gesture(e, this);  // new gesture, but touchIdentifier_ is now null and never re-set
    }
  3. The terminating pointerup does reach Gesture.handleUp — the gesture's own event bindings pass opt_noCaptureIdentifier = true (gesture.ts ~449/458/467/476), so conditionalBind's identifier gate is bypassed. The filter is the explicit guard inside handleUp at gesture.ts:538:
    handleUp(e) {
      ...
      if (!this.isMultiTouch() || this.isDragging()) {
        if (!Touch.shouldHandleEvent(e)) return;   // ← returns here, before this.dispose()
        ...
        this.dispose();
      }
    }
    Touch.shouldHandleEvent(e)checkTouchIdentifier(e):
    if (touchIdentifier_) return touchIdentifier_ === identifier;
    if (e.type === 'pointerdown') { touchIdentifier_ = identifier; return true; }
    return false;   // ← touchIdentifier_ is null and this is a pointerup ⇒ false
    → returns false, so handleUp returns before this.dispose(); the replacement gesture is never disposed and currentGesture_ / inProgress() stay set forever.

The replacement gesture created in step 2 has no path to re-establish touchIdentifier_ (the originating pointerdown already passed the bind gate before cancel() cleared it).


Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    In Progress

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions