DEV Community

MartinDelophy
MartinDelophy

Posted on

Fixing a Mobile Click-Through Bug Caused by Unmounting a React Portal on PointerDown

Summer Bug Smash: Clear the Lineup 🐛🛹

This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.

Project Overview

Timeline Studio is an open-source, local-first AI video editor that runs in the browser. It combines a multi-track timeline, captions, voice generation, overlays, stickers, and deterministic export in a responsive React interface.

The source is available on GitHub.

One editing workflow opens the preview in a large-canvas modal rendered through a React portal. On mobile, that surface had an especially confusing bug: tapping its close button could also open the Settings panel underneath it.

The two controls occupied almost the same screen coordinates. To the user, one tap appeared to perform two unrelated actions.

Bug Fix or Performance Improvement

The original close button changed React state during pointerdown:

onPointerDown={(event) => {
  event.preventDefault();
  event.stopPropagation();
  setIsFocusPreviewOpen(false);
}}
onClick={(event) => event.stopPropagation()}
Enter fullscreen mode Exit fullscreen mode

At first glance, this looked safe. The event was prevented and propagation was stopped. But the failure was not ordinary bubbling.

A tap is a sequence, not a single event:

touchstart / pointerdown
          ↓
close state changes
          ↓
the portal unmounts
          ↓
touchend / pointerup / synthesized click
          ↓
the browser hit-tests the same screen coordinates again
          ↓
the Settings button underneath receives the rest of the gesture
Enter fullscreen mode Exit fullscreen mode

By removing the topmost interactive surface during the first phase of the gesture, the application exposed a different control before the gesture was complete. stopPropagation() could not protect a DOM node that had already disappeared.

This made the bug more than a small visual annoyance. Mobile users closing an editing mode were unexpectedly moved into another panel, which broke spatial predictability and made the interface feel unreliable.

Code

The fix was deliberately small:

onPointerDown={(event) => {
  event.stopPropagation();
}}
onClick={(event) => {
  event.preventDefault();
  event.stopPropagation();
  setIsFocusPreviewOpen(false);
}}
Enter fullscreen mode Exit fullscreen mode

The important change is timing. The modal remains mounted through pointerdown, and React closes it only after the complete click resolves to the close button.

There is no timeout, debounce, invisible blocker, or coordinate-specific workaround. The lifecycle of the UI now matches the lifecycle of the user's gesture.

Reproducing the real input sequence

A mouse-only test would have missed the most valuable part of this fix, so I added a Playwright regression test at a 412 × 915 mobile viewport using Chrome DevTools Protocol touch events.

The test performs these checks:

  1. Open the large-canvas editor.
  2. Dispatch touchStart on the close button.
  3. Assert that the dialog is still visible.
  4. Dispatch touchEnd.
  5. Assert that the dialog is closed.
  6. Assert that the Settings panel did not open.

A shortened version of the core assertion looks like this:

await client.send("Input.dispatchTouchEvent", {
  type: "touchStart",
  touchPoints: [{ x, y }],
});

await expect(focusDialog).toBeVisible();

await client.send("Input.dispatchTouchEvent", {
  type: "touchEnd",
  touchPoints: [],
});

await expect(focusDialog).toBeHidden();
await expect(page.locator(".settings-panel")).toHaveCount(0);
Enter fullscreen mode Exit fullscreen mode

The assertion between touchStart and touchEnd is the key. It failed with the old implementation because the portal disappeared too early. It passes after the fix and documents the input-lifecycle requirement directly.

You can inspect the implementation in the merged pull request and try the current build in the live editor. The fix shipped in Timeline Studio v0.6.0.

My Improvements

1. Fixed the event boundary instead of masking the symptom

A delay might have appeared to solve the issue, but it would introduce timing sensitivity. Moving the state change to click expresses the actual rule: do not remove the interaction surface before the gesture completes.

2. Tested touch semantics, not just mouse semantics

The regression test sends a real start/end touch sequence. That makes it sensitive to the exact premature-unmount behavior and prevents a future refactor from quietly moving the close action back to pointerdown.

3. Verified the supported phone layout

The test runs at 412 × 915, one of the project's supported mobile viewports, so the hit targets and overlapping coordinates reflect the layout where the bug occurred.

4. Preserved the other closing paths

The header close button, the secondary corner control, and Escape still exit large-canvas mode reliably. The change only corrects when the pointer-driven close mutates state.

What I Learned

The most useful lesson is that event propagation and event targeting are different problems.

Propagation controls how one event travels through the current DOM tree. It does not guarantee that later events in the same physical gesture will target the same element—especially when React state changes remove a portal between pointerdown and pointerup.

For modal surfaces, drawers, menus, and popovers, a good defensive rule is:

Do not synchronously remove the topmost interactive surface during the first phase of a multi-event gesture when another actionable control is aligned behind it.

Closing on click or pointerup, keeping the background inert while the modal is active, and testing the full touch sequence all make this class of bug much harder to reintroduce.

This was a tiny diff with an outsized UX impact—the kind of bug fix that makes a touch interface feel trustworthy again.

Top comments (0)