FN-8192: prevent mobile confirmation ghost-click dismissal
Keep mobile task-delete confirmations open through delayed compatibility clicks. - Gate backdrop dismissal until the opening gesture settles. - Cover deliberate backdrop dismissal and mobile ghost-click behavior. - Document the regression and add a patch changeset. Files changed: .changeset/fn-8192-mobile-confirm-ghost-click.md | 7 +++ .../confirm-dialog-mobile-ghost-click-dismiss.md | 53 ++++++++++++++++++++++ .../dashboard/app/components/ConfirmDialog.tsx | 23 +++++++++- .../components/__tests__/ConfirmDialog.test.tsx | 34 ++++++++++++-- .../app/hooks/__tests__/useConfirm.test.ts | 32 ++++++++++++- 5 files changed, 143 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-8192 Fusion-Task-Lineage: ea08a6c0-0f2e-4ec3-8ad7-23cdc4a5d7f1 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8192-mobile-confirm-ghost-click.md
Normal file
7
.changeset/fn-8192-mobile-confirm-ghost-click.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Keep mobile task delete confirmations open through synthesized ghost clicks.
|
||||
category: fix
|
||||
dev: Confirms now ignore opening-gesture backdrop presses while retaining deliberate outside dismissal.
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: "Confirm dialog mobile ghost-click dismissal"
|
||||
date: 2026-07-17
|
||||
category: ui-bugs
|
||||
module: packages/dashboard/app/components/ConfirmDialog.tsx
|
||||
problem_type: touch_event_compatibility
|
||||
applies_when:
|
||||
- "A portal-mounted dialog opens from a touch target and immediately cancels itself"
|
||||
- "A backdrop press-origin guard is bypassed by delayed compatibility mouse events"
|
||||
tags:
|
||||
- confirm-dialog
|
||||
- mobile
|
||||
- touch
|
||||
- ghost-click
|
||||
- portal
|
||||
- backdrop-dismissal
|
||||
---
|
||||
|
||||
# Confirm dialog mobile ghost-click dismissal
|
||||
|
||||
## Problem
|
||||
|
||||
A task Delete tap on mobile opened the shared confirm dialog and then immediately dismissed it. FN-8073 already required a backdrop dismissal to begin on the backdrop, preventing a desktop trigger click from cancelling a freshly portaled dialog. That condition alone was insufficient for touch input.
|
||||
|
||||
After the touch-triggered click mounts the portal, mobile browsers can dispatch delayed compatibility mouse events (`mousedown` → `mouseup` → `click`) at the original tap coordinates. Because the overlay now occupies those coordinates, the synthetic `mousedown` begins on the backdrop and satisfies the FN-8073 press-origin check. Its following click incorrectly resolves the confirm as cancel.
|
||||
|
||||
## Solution
|
||||
|
||||
`ConfirmDialog` records when it opens and when a backdrop press begins. A backdrop click may cancel only when its matching press both began on the backdrop and began after the short opening-gesture settle window. The guard uses stored `Date.now()` timestamps; it adds no timeout, listener, or queue state.
|
||||
|
||||
```tsx
|
||||
const wasPostOpenPress = pressStartedAt - openedAtRef.current >= OPENING_GESTURE_SETTLE_MS;
|
||||
if (startedOnBackdrop && wasPostOpenPress && event.target === event.currentTarget) {
|
||||
onCancel();
|
||||
}
|
||||
```
|
||||
|
||||
The existing `nextFloatingZ()` call remains in the opening `useLayoutEffect`, so the portaled overlay receives its floating-stack z-index before paint. Investigation found no mobile CSS or z-order fault.
|
||||
|
||||
## Regression test pattern
|
||||
|
||||
Use fake timers and reproduce browser ordering explicitly, since JSDOM does not synthesize a click from touch events:
|
||||
|
||||
1. Dispatch `touchstart` and `touchend` on the delete trigger.
|
||||
2. Dispatch the trigger `click` that opens the dialog.
|
||||
3. Dispatch `mousedown`, `mouseup`, and `click` on the mounted `.confirm-dialog-overlay`.
|
||||
4. Assert the dialog stays visible and delete remains pending; then explicitly click Confirm.
|
||||
|
||||
Also advance fake time past the settle window and assert a real backdrop press-and-release still cancels. Keep the existing desktop trailing-click coverage, plus Cancel, header close, Escape, choice, checkbox, queue, and floating-z tests.
|
||||
|
||||
## Prevention
|
||||
|
||||
For dialogs opened from touch-affordances, never rely only on `event.target === event.currentTarget` or whether a press began on the backdrop. A compatibility mouse burst can meet both conditions after a portal mounts. Guard the shared primitive using its opening boundary, rather than adding per-delete-trigger suppression, so every confirm caller receives identical protection while deliberate outside dismissal remains available.
|
||||
@@ -5,6 +5,8 @@ import type { ConfirmOptions } from "../hooks/useConfirm";
|
||||
import { nextFloatingZ } from "./floatingWindowStack";
|
||||
import "./ConfirmDialog.css";
|
||||
|
||||
const OPENING_GESTURE_SETTLE_MS = 500;
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
options: ConfirmOptions | null;
|
||||
@@ -36,8 +38,13 @@ export function ConfirmDialog({
|
||||
*/
|
||||
const [overlayZ, setOverlayZ] = useState<number | undefined>(undefined);
|
||||
const backdropPressStartedHereRef = useRef(false);
|
||||
const backdropPressStartedAtRef = useRef(0);
|
||||
const openedAtRef = useRef(0);
|
||||
useLayoutEffect(() => {
|
||||
if (isOpen) {
|
||||
openedAtRef.current = Date.now();
|
||||
backdropPressStartedHereRef.current = false;
|
||||
backdropPressStartedAtRef.current = 0;
|
||||
setOverlayZ(nextFloatingZ());
|
||||
}
|
||||
}, [isOpen]);
|
||||
@@ -47,15 +54,27 @@ export function ConfirmDialog({
|
||||
A confirm opened from a task delete must remain visible until an explicit user
|
||||
action. The trigger's trailing click can reach this newly portaled backdrop,
|
||||
so outside-dismiss is valid only after a press that began on the backdrop.
|
||||
|
||||
FNXC:Confirm 2026-07-17-00:15 (FN-8192):
|
||||
Mobile touch activation may emit a delayed touch-to-mouse compatibility burst
|
||||
after the confirm portal mounts. Its synthetic mousedown starts on the
|
||||
backdrop and defeats the press-origin guard, so only accept backdrop dismissal
|
||||
when that press began after the opening gesture settle window. This uses stored
|
||||
timestamps rather than a timer and preserves deliberate post-open dismissal.
|
||||
*/
|
||||
const recordBackdropPress = (event: React.SyntheticEvent<HTMLDivElement>) => {
|
||||
backdropPressStartedHereRef.current = event.target === event.currentTarget;
|
||||
const startedOnBackdrop = event.target === event.currentTarget;
|
||||
backdropPressStartedHereRef.current = startedOnBackdrop;
|
||||
backdropPressStartedAtRef.current = startedOnBackdrop ? Date.now() : 0;
|
||||
};
|
||||
|
||||
const dismissFromBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const startedOnBackdrop = backdropPressStartedHereRef.current;
|
||||
const pressStartedAt = backdropPressStartedAtRef.current;
|
||||
backdropPressStartedHereRef.current = false;
|
||||
if (startedOnBackdrop && event.target === event.currentTarget) {
|
||||
backdropPressStartedAtRef.current = 0;
|
||||
const wasPostOpenPress = pressStartedAt - openedAtRef.current >= OPENING_GESTURE_SETTLE_MS;
|
||||
if (startedOnBackdrop && wasPostOpenPress && event.target === event.currentTarget) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { ConfirmDialog } from "../ConfirmDialog";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
|
||||
describe("ConfirmDialog", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders title and message", () => {
|
||||
render(
|
||||
<ConfirmDialog
|
||||
@@ -78,7 +82,8 @@ describe("ConfirmDialog", () => {
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onCancel when a backdrop press and click both originate on the overlay", () => {
|
||||
it("calls onCancel when a deliberate post-settle backdrop press and click both originate on the overlay", () => {
|
||||
vi.useFakeTimers();
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog
|
||||
@@ -92,11 +97,34 @@ describe("ConfirmDialog", () => {
|
||||
// FNXC: ConfirmDialog portals to document.body, so query from document (not the render container).
|
||||
const overlay = document.querySelector(".modal-overlay");
|
||||
expect(overlay).toBeTruthy();
|
||||
fireEvent.pointerDown(overlay as Element);
|
||||
vi.advanceTimersByTime(500);
|
||||
fireEvent.pointerDown(overlay as Element, { pointerType: "mouse", isPrimary: true });
|
||||
fireEvent.click(overlay as Element);
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores the opening touch-to-mouse ghost burst even when it starts and ends on the overlay", () => {
|
||||
vi.useFakeTimers();
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog
|
||||
isOpen={true}
|
||||
options={{ title: "Delete Task", message: "Delete FN-001?", danger: true }}
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
const overlay = document.querySelector(".confirm-dialog-overlay");
|
||||
expect(overlay).toBeTruthy();
|
||||
fireEvent.mouseDown(overlay as Element);
|
||||
fireEvent.mouseUp(overlay as Element);
|
||||
fireEvent.click(overlay as Element);
|
||||
|
||||
expect(screen.getByRole("dialog", { name: "Delete Task" })).toBeInTheDocument();
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders and handles tertiary action when configured", () => {
|
||||
const onTertiary = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import React, { useState } from "react";
|
||||
import { ConfirmDialogProvider, useConfirm } from "../useConfirm";
|
||||
@@ -88,6 +88,10 @@ function Harness() {
|
||||
}
|
||||
|
||||
describe("useConfirm", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("skips dialogs with the primary/default outcomes when enabled", async () => {
|
||||
render(React.createElement(ConfirmDialogProvider, { skipConfirmations: true }, React.createElement(Harness)));
|
||||
|
||||
@@ -134,6 +138,7 @@ describe("useConfirm", () => {
|
||||
|
||||
const overlay = await screen.findByText("Delete FN-001?").then(() => document.querySelector(".confirm-dialog-overlay"));
|
||||
expect(overlay).toBeTruthy();
|
||||
fireEvent.mouseDown(overlay as Element);
|
||||
fireEvent.pointerUp(overlay as Element, { pointerType: "mouse" });
|
||||
fireEvent.click(overlay as Element);
|
||||
|
||||
@@ -163,6 +168,31 @@ describe("useConfirm", () => {
|
||||
await waitFor(() => expect(screen.getByTestId("result")).toHaveTextContent("confirmed"));
|
||||
});
|
||||
|
||||
it("keeps a mobile touch-opened confirm visible through its delayed ghost mouse burst and only deletes after Confirm", async () => {
|
||||
vi.useFakeTimers();
|
||||
render(React.createElement(ConfirmDialogProvider, null, React.createElement(Harness)));
|
||||
|
||||
const trigger = screen.getByText("open");
|
||||
fireEvent.touchStart(trigger);
|
||||
fireEvent.touchEnd(trigger);
|
||||
// JSDOM does not synthesize this click from touch events, unlike the browser.
|
||||
fireEvent.click(trigger);
|
||||
|
||||
expect(screen.getByText("Delete FN-001?")).toBeInTheDocument();
|
||||
const overlay = document.querySelector(".confirm-dialog-overlay");
|
||||
expect(overlay).toBeTruthy();
|
||||
fireEvent.mouseDown(overlay as Element);
|
||||
fireEvent.mouseUp(overlay as Element);
|
||||
fireEvent.click(overlay as Element);
|
||||
|
||||
expect(screen.getByRole("dialog", { name: "Delete Task" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("result")).toHaveTextContent("idle");
|
||||
|
||||
vi.useRealTimers();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
|
||||
await waitFor(() => expect(screen.getByTestId("result")).toHaveTextContent("confirmed"));
|
||||
});
|
||||
|
||||
it("resolves false when cancel is clicked", async () => {
|
||||
render(
|
||||
React.createElement(
|
||||
|
||||
Reference in New Issue
Block a user