Skip to content

Commit bafab53

Browse files
shai-almogclaude
andcommitted
fix(js-port): order pointer press/release on nativeEdt to recover dropped releases
ParparVM compiles every Java method to a JS generator. JSO calls inside ``onMouseDown`` / ``onMouseUp`` (``getClientX``, ``focusInputElement``, ``evt.preventDefault``) yield while the host bridge round-trips, so while ``onMouseDown`` is suspended the worker can dequeue and start ``onMouseUp`` for the same click. If onMouseUp finishes first, its ``nativeCallSerially(pointerReleased)`` lands on ``nativeEdt`` BEFORE onMouseDown's matching press. The EDT then sees POINTER_RELEASED before POINTER_PRESSED, drops the release because ``eventForm == null`` (Display.java POINTER_RELEASED handler), and the matching ``Button.released`` never fires -- so a Hello-button click never shows its Dialog and PR #4795 freezes. Two coordinated changes close the race: 1. Set ``mouseDown=true`` synchronously at handler entry (before any JSO yield), so an interleaved onMouseUp doesn't early-return on a stale ``!isMouseDown()`` check and silently drop the release. 2. Deferred-release pattern. onMouseDown sets ``pressInFlight=true`` synchronously and clears it in the press's nativeCallSerially completion hook. onMouseUp checks the flag at dispatch time: if a press is still in flight, it stashes the release in ``deferredRelease`` and returns; the press's completion hook then runs the deferred release. This guarantees POINTER_RELEASED reaches Display.inputEventStack AFTER its matching POINTER_PRESSED. ``Object.wait()`` would also work but blocks the worker's listener thread -- if the EDT is later inside ``invokeAndBlock`` (Dialog modal) the listener won't unblock until the dialog disposes, starving every subsequent pointerdown. After this change Hello reliably opens its Dialog, and the previously seen transparent-hole regression on rapid drag/click sequences (Test 2 of test-initializr-interaction.mjs) clears too -- it was the same dropped- release symptom on a different surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e8ca302 commit bafab53

1 file changed

Lines changed: 131 additions & 20 deletions

File tree

Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java

Lines changed: 131 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,33 @@ public class HTML5Implementation extends CodenameOneImplementation {
188188

189189
final Object editingLock=new Object();
190190

191+
// Coordinates the order in which pointer-press and pointer-release events
192+
// reach Display.inputEventStack. ParparVM compiles every Java method to a
193+
// JS generator; JSO calls inside ``onMouseDown`` / ``onMouseUp`` (e.g.
194+
// ``getClientX``, ``focusInputElement``) suspend the generator while the
195+
// host bridge round-trips. While ``onMouseDown`` is suspended on a yield,
196+
// the worker can dequeue and start running ``onMouseUp`` for the SAME
197+
// click. If onMouseUp finishes first (it has slightly fewer yields), its
198+
// ``nativeCallSerially(pointerReleased)`` schedules the release on
199+
// ``nativeEdt`` BEFORE onMouseDown's matching press. The EDT then sees
200+
// POINTER_RELEASED before POINTER_PRESSED, drops the release because
201+
// ``eventForm == null`` (Display.java POINTER_RELEASED handler), and the
202+
// matching Button.released never fires -- so a Hello-button click never
203+
// shows its Dialog.
204+
//
205+
// Fix: deferred-release pattern. onMouseDown sets ``pressInFlight=true``
206+
// synchronously at handler entry (before any JSO yield) and clears it
207+
// after ``Display.pointerPressed`` returns. onMouseUp checks the flag at
208+
// dispatch time: if a press is still in flight, it stashes the release
209+
// in ``deferredRelease`` and returns immediately; the press's completion
210+
// hook then runs the deferred release. We avoid ``Object.wait()`` on
211+
// purpose -- blocking a worker-side event-listener thread while the EDT
212+
// is inside ``invokeAndBlock`` (e.g. Dialog modal) starves subsequent
213+
// pointerdown listener invocations and stalls the entire UI.
214+
private final Object pointerEventOrderLock = new Object();
215+
private boolean pressInFlight = false;
216+
private Runnable deferredRelease;
217+
191218
private Form _getCurrent() {
192219
return getCurrentForm();
193220
}
@@ -1332,10 +1359,45 @@ public void add(String eventName, Object listener) {
13321359

13331360
@Override
13341361
public void handleEvent(Event evt) {
1362+
// Set ``mouseDown=true`` IMMEDIATELY, before any JSO call
1363+
// that can yield. ParparVM compiles every Java method to a
1364+
// JS generator, and JSO calls (``evt.getType()``,
1365+
// ``getClientX(me)``, ``focusInputElement()``,
1366+
// ``evt.preventDefault()``) all suspend the generator while
1367+
// they round-trip through the host bridge. While onMouseDown
1368+
// is suspended, the worker can dequeue and start running
1369+
// onMouseUp for the SAME click — which then reads
1370+
// ``mouseDown==false`` (we haven't set it yet), early-returns
1371+
// via ``if (!isMouseDown()) return``, and the press's
1372+
// matching release is silently dropped. By the time
1373+
// onMouseDown resumes and sets ``mouseDown=true``, it's too
1374+
// late: the next click's onMouseDown sees ``mouseDown==true``
1375+
// (still — never cleared by the swallowed mouseup),
1376+
// shouldIgnoreMousePress returns true, and the next click
1377+
// gets the opposite asymmetry (release-only).
1378+
//
1379+
// Root cause of the PR #4795 dialog freeze: a Dialog's OK
1380+
// click landed on this every-other-half drop, Button.released
1381+
// never fired, dispose never happened, ``invokeAndBlock``
1382+
// blocked the EDT forever. Setting the flag synchronously at
1383+
// listener entry closes the window.
1384+
if (!pointerState.isMouseDown()) {
1385+
pointerState.setMouseDown(true);
1386+
}
1387+
// Mark a press as in-flight SYNCHRONOUSLY (before any JSO
1388+
// yield) and clear any stale deferredRelease left over from a
1389+
// previous click. The matching nativeCallSerially below
1390+
// clears the flag after Display.pointerPressed returns, then
1391+
// runs any release that onMouseUp deferred while waiting.
1392+
synchronized (pointerEventOrderLock) {
1393+
pressInFlight = true;
1394+
deferredRelease = null;
1395+
}
13351396
if (nativeEventListener != null) {
13361397
CancelableEvent cevt = (CancelableEvent)evt;
13371398
nativeEventListener.handleEvent(evt);
13381399
if (cevt.isDefaultPrevented()) {
1400+
completePressInFlight();
13391401
return;
13401402
}
13411403
}
@@ -1351,18 +1413,29 @@ public void handleEvent(Event evt) {
13511413
evt.preventDefault();
13521414
evt.stopPropagation();
13531415
}
1354-
if (JavaScriptInputCoordinator.shouldIgnoreMousePress(pointerState.isTouchDown(), pointerState.isMouseDown(), evt.getTarget() == textField || evt.getTarget() == textArea)) {
1416+
// Re-check ignore conditions with the now-already-set flag.
1417+
// ``shouldIgnoreMousePress`` reads mouseDown=true here for
1418+
// every press, so the only way it stays meaningful is via
1419+
// touchDown / textInputTarget. That's intentional — the old
1420+
// mouseDown-based dedup was for the duplicate listener
1421+
// registration we removed in JavaScriptEventWiring.
1422+
boolean ignore = pointerState.isTouchDown()
1423+
|| (evt.getTarget() == textField || evt.getTarget() == textArea);
1424+
if (ignore) {
13551425
debugLog("[mouseDown] touchIsDown");
13561426
if (pointerState.isTouchDown()) {
13571427
pointerState.setMouseDown(false);
13581428
}
1429+
completePressInFlight();
13591430
return;
13601431
}
13611432
onMouseMoveHandle = EventUtil.addEventListener(peersContainer, "mousemove", onMouseMove, true);
13621433
onPointerMoveHandle = EventUtil.addEventListener(peersContainer, "pointermove", onMouseMove, true);
1363-
1434+
13641435
pointerState.setLastMousePosition(x, y);
1365-
pointerState.setMouseDown(true);
1436+
// ``mouseDown=true`` already set at handler entry — see comment
1437+
// at top. Don't unset/re-set here; doing so opens the same
1438+
// every-other-half-drop race we just closed.
13661439
callSerially(new Runnable() {
13671440
public void run() {
13681441

@@ -1375,7 +1448,11 @@ public void run() {
13751448
installBacksideHooksInUserInteraction();
13761449
nativeCallSerially(new Runnable() {
13771450
public void run() {
1378-
HTML5Implementation.this.pointerPressed(new int[]{x}, new int[]{y});
1451+
try {
1452+
HTML5Implementation.this.pointerPressed(new int[]{x}, new int[]{y});
1453+
} finally {
1454+
completePressInFlight();
1455+
}
13791456
}
13801457
});
13811458
if (contextListenerActive && me.getButton() == 2) {
@@ -1409,40 +1486,62 @@ public void handleEvent(Event evt) {
14091486
evt.stopPropagation();
14101487
}
14111488
pointerState.setGrabbedDrag(false);
1412-
1489+
14131490
// Prevent conflicts with touch events
14141491
// Guard against mouseUp if the mouse isn't already dwon
14151492
if (pointerState.isTouchDown()) {
14161493
debugLog("[mouseUp] touchIsDown");
14171494
pointerState.setMouseDown(false);
14181495
return;
14191496
}
1420-
1497+
14211498
if (!pointerState.isMouseDown()) {
14221499
return;
14231500
}
14241501
pointerState.setMouseDown(false);
1425-
1426-
1427-
1502+
14281503
EventUtil.removeEventListener(peersContainer, "mousemove", onMouseMoveHandle, true);
14291504
EventUtil.removeEventListener(peersContainer, "pointermove", onPointerMoveHandle, true);
1430-
1505+
14311506
pointerState.setLastTouchUpPosition(x, y);
14321507
installBacksideHooksInUserInteraction();
1433-
nativeCallSerially(new Runnable() {
1508+
1509+
final Runnable releaseDispatch = new Runnable() {
14341510
public void run() {
1435-
HTML5Implementation.this.pointerReleased(new int[]{x}, new int[]{y});
1511+
nativeCallSerially(new Runnable() {
1512+
public void run() {
1513+
HTML5Implementation.this.pointerReleased(new int[]{x}, new int[]{y});
1514+
}
1515+
});
1516+
callSerially(new Runnable() {
1517+
public void run() {
1518+
for (ActionListener l : mouseUpListeners) {
1519+
l.actionPerformed(null);
1520+
}
1521+
}
1522+
});
14361523
}
1437-
});
1438-
callSerially(new Runnable() {
1439-
public void run() {
1440-
for (ActionListener l : mouseUpListeners) {
1441-
l.actionPerformed(null);
1442-
}
1524+
};
1525+
1526+
// If the matching onMouseDown is still suspended on a JSO
1527+
// yield (so its press hasn't reached Display.inputEventStack
1528+
// yet), stash the release and let the press's completion hook
1529+
// run it. Otherwise queue the release immediately. Avoids
1530+
// blocking the worker's listener thread, which would starve
1531+
// subsequent pointerdown invocations during a Dialog modal.
1532+
boolean runNow;
1533+
synchronized (pointerEventOrderLock) {
1534+
if (pressInFlight) {
1535+
deferredRelease = releaseDispatch;
1536+
runNow = false;
1537+
} else {
1538+
runNow = true;
14431539
}
1444-
});
1445-
1540+
}
1541+
if (runNow) {
1542+
releaseDispatch.run();
1543+
}
1544+
14461545
}
14471546
};
14481547

@@ -2322,6 +2421,18 @@ public void run() {
23222421
new Thread(r).start();
23232422
}
23242423
}
2424+
2425+
private void completePressInFlight() {
2426+
Runnable pending;
2427+
synchronized (pointerEventOrderLock) {
2428+
pressInFlight = false;
2429+
pending = deferredRelease;
2430+
deferredRelease = null;
2431+
}
2432+
if (pending != null) {
2433+
pending.run();
2434+
}
2435+
}
23252436

23262437
@JSBody(params={}, script="return window.cn1WheelMultiplier || 1.0")
23272438
private static native double wheelMultiplier();

0 commit comments

Comments
 (0)