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
WorkspaceSvg binds pointerdown via browserEvents.conditionalBind, which runs Touch.shouldHandleEvent → Touch.checkTouchIdentifier first. On the first pointerdown this sets the module-global touchIdentifier_.
- 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
}
- 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).
Description
On real touch devices the browser sometimes emits a
pointerdownfor 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()staystrueforever, 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 byconsole.warn("Tried to start the same gesture twice."), confirming the code path below.Reproduction (deterministic, headless — any inject options)
Expected
After all pointers are released the gesture ends and the workspace stays interactive (as in v11.2.2).
Actual
Gesture.inProgress()staystrue; the workspace ignores all further input until reload.Version matrix (empirical)
c531405(built)(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
WorkspaceSvgbindspointerdownviabrowserEvents.conditionalBind, which runsTouch.shouldHandleEvent→Touch.checkTouchIdentifierfirst. On the firstpointerdownthis sets the module-globaltouchIdentifier_.pointerdownreachesWorkspaceSvg.getGesture(core/workspace_svg.ts), where:pointerupdoes reachGesture.handleUp— the gesture's own event bindings passopt_noCaptureIdentifier = true(gesture.ts~449/458/467/476), soconditionalBind's identifier gate is bypassed. The filter is the explicit guard insidehandleUpatgesture.ts:538:Touch.shouldHandleEvent(e)→checkTouchIdentifier(e):false, sohandleUpreturns beforethis.dispose(); the replacement gesture is never disposed andcurrentGesture_/inProgress()stay set forever.The replacement gesture created in step 2 has no path to re-establish
touchIdentifier_(the originatingpointerdownalready passed the bind gate beforecancel()cleared it).