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:
gsxdsm
2026-07-17 00:29:48 -07:00
parent 39887f5c87
commit ac52438554
5 changed files with 143 additions and 6 deletions

View 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.

View File

@@ -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.

View File

@@ -5,6 +5,8 @@ import type { ConfirmOptions } from "../hooks/useConfirm";
import { nextFloatingZ } from "./floatingWindowStack"; import { nextFloatingZ } from "./floatingWindowStack";
import "./ConfirmDialog.css"; import "./ConfirmDialog.css";
const OPENING_GESTURE_SETTLE_MS = 500;
export interface ConfirmDialogProps { export interface ConfirmDialogProps {
isOpen: boolean; isOpen: boolean;
options: ConfirmOptions | null; options: ConfirmOptions | null;
@@ -36,8 +38,13 @@ export function ConfirmDialog({
*/ */
const [overlayZ, setOverlayZ] = useState<number | undefined>(undefined); const [overlayZ, setOverlayZ] = useState<number | undefined>(undefined);
const backdropPressStartedHereRef = useRef(false); const backdropPressStartedHereRef = useRef(false);
const backdropPressStartedAtRef = useRef(0);
const openedAtRef = useRef(0);
useLayoutEffect(() => { useLayoutEffect(() => {
if (isOpen) { if (isOpen) {
openedAtRef.current = Date.now();
backdropPressStartedHereRef.current = false;
backdropPressStartedAtRef.current = 0;
setOverlayZ(nextFloatingZ()); setOverlayZ(nextFloatingZ());
} }
}, [isOpen]); }, [isOpen]);
@@ -47,15 +54,27 @@ export function ConfirmDialog({
A confirm opened from a task delete must remain visible until an explicit user 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, 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. 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>) => { 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 dismissFromBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
const startedOnBackdrop = backdropPressStartedHereRef.current; const startedOnBackdrop = backdropPressStartedHereRef.current;
const pressStartedAt = backdropPressStartedAtRef.current;
backdropPressStartedHereRef.current = false; 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(); onCancel();
} }
}; };

View File

@@ -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 { render, screen, fireEvent } from "@testing-library/react";
import { ConfirmDialog } from "../ConfirmDialog"; import { ConfirmDialog } from "../ConfirmDialog";
import { loadAllAppCss } from "../../test/cssFixture"; import { loadAllAppCss } from "../../test/cssFixture";
describe("ConfirmDialog", () => { describe("ConfirmDialog", () => {
afterEach(() => {
vi.useRealTimers();
});
it("renders title and message", () => { it("renders title and message", () => {
render( render(
<ConfirmDialog <ConfirmDialog
@@ -78,7 +82,8 @@ describe("ConfirmDialog", () => {
expect(onCancel).toHaveBeenCalledTimes(1); 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(); const onCancel = vi.fn();
render( render(
<ConfirmDialog <ConfirmDialog
@@ -92,11 +97,34 @@ describe("ConfirmDialog", () => {
// FNXC: ConfirmDialog portals to document.body, so query from document (not the render container). // FNXC: ConfirmDialog portals to document.body, so query from document (not the render container).
const overlay = document.querySelector(".modal-overlay"); const overlay = document.querySelector(".modal-overlay");
expect(overlay).toBeTruthy(); 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); fireEvent.click(overlay as Element);
expect(onCancel).toHaveBeenCalledTimes(1); 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", () => { it("renders and handles tertiary action when configured", () => {
const onTertiary = vi.fn(); const onTertiary = vi.fn();
render( render(

View File

@@ -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 { render, screen, fireEvent, waitFor } from "@testing-library/react";
import React, { useState } from "react"; import React, { useState } from "react";
import { ConfirmDialogProvider, useConfirm } from "../useConfirm"; import { ConfirmDialogProvider, useConfirm } from "../useConfirm";
@@ -88,6 +88,10 @@ function Harness() {
} }
describe("useConfirm", () => { describe("useConfirm", () => {
afterEach(() => {
vi.useRealTimers();
});
it("skips dialogs with the primary/default outcomes when enabled", async () => { it("skips dialogs with the primary/default outcomes when enabled", async () => {
render(React.createElement(ConfirmDialogProvider, { skipConfirmations: true }, React.createElement(Harness))); 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")); const overlay = await screen.findByText("Delete FN-001?").then(() => document.querySelector(".confirm-dialog-overlay"));
expect(overlay).toBeTruthy(); expect(overlay).toBeTruthy();
fireEvent.mouseDown(overlay as Element);
fireEvent.pointerUp(overlay as Element, { pointerType: "mouse" }); fireEvent.pointerUp(overlay as Element, { pointerType: "mouse" });
fireEvent.click(overlay as Element); fireEvent.click(overlay as Element);
@@ -163,6 +168,31 @@ describe("useConfirm", () => {
await waitFor(() => expect(screen.getByTestId("result")).toHaveTextContent("confirmed")); 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 () => { it("resolves false when cancel is clicked", async () => {
render( render(
React.createElement( React.createElement(