From ac52438554a10deb14dece01a9a299736162b2f6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 17 Jul 2026 00:29:48 -0700 Subject: [PATCH] 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) --- .../fn-8192-mobile-confirm-ghost-click.md | 7 +++ ...nfirm-dialog-mobile-ghost-click-dismiss.md | 53 +++++++++++++++++++ .../app/components/ConfirmDialog.tsx | 23 +++++++- .../__tests__/ConfirmDialog.test.tsx | 34 ++++++++++-- .../app/hooks/__tests__/useConfirm.test.ts | 32 ++++++++++- 5 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 .changeset/fn-8192-mobile-confirm-ghost-click.md create mode 100644 docs/solutions/ui-bugs/confirm-dialog-mobile-ghost-click-dismiss.md diff --git a/.changeset/fn-8192-mobile-confirm-ghost-click.md b/.changeset/fn-8192-mobile-confirm-ghost-click.md new file mode 100644 index 0000000000..c08fde3b02 --- /dev/null +++ b/.changeset/fn-8192-mobile-confirm-ghost-click.md @@ -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. diff --git a/docs/solutions/ui-bugs/confirm-dialog-mobile-ghost-click-dismiss.md b/docs/solutions/ui-bugs/confirm-dialog-mobile-ghost-click-dismiss.md new file mode 100644 index 0000000000..c00b45571c --- /dev/null +++ b/docs/solutions/ui-bugs/confirm-dialog-mobile-ghost-click-dismiss.md @@ -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. diff --git a/packages/dashboard/app/components/ConfirmDialog.tsx b/packages/dashboard/app/components/ConfirmDialog.tsx index 0d599f0f98..c2b9835cbf 100644 --- a/packages/dashboard/app/components/ConfirmDialog.tsx +++ b/packages/dashboard/app/components/ConfirmDialog.tsx @@ -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(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) => { - 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) => { 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(); } }; diff --git a/packages/dashboard/app/components/__tests__/ConfirmDialog.test.tsx b/packages/dashboard/app/components/__tests__/ConfirmDialog.test.tsx index 09e6fb5bf1..53885e808e 100644 --- a/packages/dashboard/app/components/__tests__/ConfirmDialog.test.tsx +++ b/packages/dashboard/app/components/__tests__/ConfirmDialog.test.tsx @@ -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( { 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( { // 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( + , + ); + + 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( diff --git a/packages/dashboard/app/hooks/__tests__/useConfirm.test.ts b/packages/dashboard/app/hooks/__tests__/useConfirm.test.ts index 441f9aea84..ff60fed683 100644 --- a/packages/dashboard/app/hooks/__tests__/useConfirm.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useConfirm.test.ts @@ -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(