diff --git a/docs/solutions/ui-bugs/oauth-manual-code-mobile-double-tap-submit.md b/docs/solutions/ui-bugs/oauth-manual-code-mobile-double-tap-submit.md new file mode 100644 index 0000000000..8f570939fd --- /dev/null +++ b/docs/solutions/ui-bugs/oauth-manual-code-mobile-double-tap-submit.md @@ -0,0 +1,60 @@ +--- +title: "OAuth manual code mobile double-tap submit" +date: 2026-07-14 +category: ui-bugs +module: packages/dashboard/app/components/OAuthManualCodeForm +problem_type: ui_bug +component: frontend_auth_onboarding +symptoms: + - "On mobile, tapping \"Submit code\" for a manual OAuth code (e.g. Anthropic subscription OAuth) while the textarea still has focus only dismisses the on-screen keyboard on the first tap" + - "onSubmit does not fire until a second, separate tap lands on the button" + - "Desktop mouse-click submission is unaffected — only touch/mobile browsers exhibit the double-tap requirement" +root_cause: touch_event_ordering +resolution_type: code_fix +severity: medium +related_components: + - packages/dashboard/app/hooks/useTouchActionGesture.ts + - packages/dashboard/app/components/OAuthManualCodeForm.tsx + - packages/dashboard/app/components/StandardChatSurface.tsx + - packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx + - packages/dashboard/app/components/ModelOnboardingModal.tsx + - FN-7953 +tags: + - touch-events + - mobile-double-tap + - oauth + - keyboard-dismiss + - gesture-handling + - onboarding +--- + +# OAuth manual code mobile double-tap submit + +## Problem + +On mobile/touch viewports, `OAuthManualCodeForm`'s "Submit code" button relied solely on a plain `onClick={onSubmit}` handler. When the user typed or pasted a manual OAuth code (Anthropic subscription OAuth, or any other provider using `manualCodeConfigs`) and tapped "Submit code" while the textarea still held focus, some mobile browsers consumed that first physical tap solely to blur the focused textarea and dismiss the on-screen keyboard — suppressing or delaying the resulting synthetic `click` event. The button's `onClick` handler did not fire until a second, separate tap landed on the same button, so users had to tap "Submit code" twice to actually submit their auth code. + +This is the same mobile-web event-ordering bug class already solved for the chat Send/Stop button (`useStandardChatActionGesture` + `StandardChatActionButton` in `StandardChatSurface.tsx`), but `OAuthManualCodeForm` had not been wired up to that pattern. + +## Solution + +Extracted the proven gesture-handling logic from `useStandardChatActionGesture` into a new, generic, non-chat-coupled hook, `useTouchActionGesture()` (`packages/dashboard/app/hooks/useTouchActionGesture.ts`), and wired it into `OAuthManualCodeForm`'s submit button: + +- `onPointerDown` (touch pointer types only, via `event.pointerType !== "mouse"`) and `onTouchStart` both call `event.preventDefault()`, guard with `beginTouchActionGesture()` (a same-tick re-entrancy guard so a single physical tap that dispatches both `pointerdown` and `touchstart` only fires the action once), then `markHandledSendTouch()` and invoke `onSubmit()` immediately (when not `disabled`) — this is what makes the *first* tap submit instead of only dismissing the keyboard. +- `onMouseDown={(event) => event.preventDefault()}` avoids an equivalent mouse-driven blur race. +- `onClick` calls `consumeHandledSendTouch()` first; if it returns `true` the tap was already handled via the touch path above, so the synthetic click that follows is skipped — preventing a double `onSubmit` invocation for one physical tap. Otherwise (a genuine non-touch/mouse click, or a touch environment where the touch handlers didn't fire) it calls `onSubmit()` directly when not `disabled`. +- `style={{ touchAction: "manipulation" }}` on the button avoids double-firing from the browser's native double-tap-to-zoom gesture handling, matching `StandardChatActionButton`. + +`StandardChatSurface.tsx` and its existing `useStandardChatActionGesture`/`StandardChatActionButton` were left untouched — the new hook is a standalone extraction so non-chat consumers can reuse the same fix without depending on the chat component, keeping blast radius isolated to auth-code submission. Because the fix lives in the shared `OAuthManualCodeForm` component (rendered by both `AuthenticationSection.tsx`'s Settings → Authentication tab and `ModelOnboardingModal.tsx`'s onboarding "Connect AI providers" flow), every manual-OAuth-code provider inherits the single-tap fix without any caller changes. + +## Regression coverage + +`OAuthManualCodeForm.test.tsx` covers the invariant, not just the reported repro: + +- Mobile single-tap: `matchMedia` mocked mobile, textarea focused, then the real mobile browser sequence (`touchstart` → `blur` on the textarea → `click`) is dispatched once and `onSubmit` is asserted to have fired exactly once. +- Duplicate event dispatch for one physical tap: `pointerdown` (`pointerType: "touch"`) and `touchstart` both fired for the same tap, followed by `click`, still yields exactly one `onSubmit` call — proving the same-tick re-entrancy guard prevents double-firing when a browser dispatches both event families for one gesture. +- Desktop/non-touch regression: mobile `matchMedia` not matched, a plain `click` with no preceding touch/pointer events still submits exactly once — proving mouse-driven desktop submission is unaffected. +- `disabled` state: the same touch/pointer/click sequence never invokes `onSubmit` while `disabled` is true, from any path. +- The two pre-existing `scrollIntoView` viewport-assist tests continue to pass unmodified, proving the mobile keyboard viewport-assist behavior is untouched by this fix. + +`useTouchActionGesture.test.ts` unit-tests the hook directly: `beginTouchActionGesture()` returns `true` once and `false` on a same-tick re-entrant call (with fake timers proving it clears on the next tick); `markHandledSendTouch()` sets a flag that `consumeHandledSendTouch()` reads and clears exactly once; the handled flag auto-expires after its ~700ms timeout; and the pending timer is cleared on unmount. diff --git a/packages/dashboard/app/components/OAuthManualCodeForm.tsx b/packages/dashboard/app/components/OAuthManualCodeForm.tsx index add58b2224..30eed7898f 100644 --- a/packages/dashboard/app/components/OAuthManualCodeForm.tsx +++ b/packages/dashboard/app/components/OAuthManualCodeForm.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { useTouchActionGesture } from "../hooks/useTouchActionGesture"; import "./OAuthManualCodeForm.css"; interface OAuthManualCodeFormProps { @@ -27,6 +28,15 @@ export function OAuthManualCodeForm({ const formRef = useRef(null); const inputRef = useRef(null); const [inputFocused, setInputFocused] = useState(false); + // FNXC:OAuthManualCodeForm 2026-07-14-00:00: on mobile, tapping "Submit code" + // while the textarea above still has focus can cause the browser to consume + // that first tap solely to blur the textarea and dismiss the on-screen + // keyboard, suppressing the resulting click — requiring a second, separate + // tap to actually submit (FN-7953). Reuse the same gesture handling already + // proven for the chat Send/Stop button (`useStandardChatActionGesture` in + // StandardChatSurface.tsx) via the generic, non-chat-coupled + // useTouchActionGesture() hook so a single tap submits immediately. + const { beginTouchActionGesture, markHandledSendTouch, consumeHandledSendTouch } = useTouchActionGesture(); const shouldUseMobileScrollAssist = useCallback(() => { if (typeof window === "undefined" || typeof window.matchMedia !== "function") { @@ -116,8 +126,27 @@ export function OAuthManualCodeForm({ diff --git a/packages/dashboard/app/components/__tests__/OAuthManualCodeForm.test.tsx b/packages/dashboard/app/components/__tests__/OAuthManualCodeForm.test.tsx index 352703de06..6e573ac074 100644 --- a/packages/dashboard/app/components/__tests__/OAuthManualCodeForm.test.tsx +++ b/packages/dashboard/app/components/__tests__/OAuthManualCodeForm.test.tsx @@ -108,4 +108,114 @@ describe("OAuthManualCodeForm", () => { expect(scrollIntoView).not.toHaveBeenCalled(); }); + + // FNXC:OAuthManualCodeForm 2026-07-14-00:00: regression coverage for FN-7953 — + // on mobile, tapping "Submit code" while the textarea still has focus used to + // only blur/dismiss the keyboard on the first tap, requiring a second tap to + // actually submit. These tests reproduce the real mobile event sequence + // (touch/pointer event → blur → click) and assert a single tap submits + // immediately, with no double-submit and no desktop regression. + describe("single-tap submit (FN-7953)", () => { + it("submits from a single mobile tap even though the textarea still holds focus when the tap lands", () => { + mockMatchMedia({ mobile: true }); + + const onSubmit = vi.fn(); + render( + , + ); + + const textarea = screen.getByRole("textbox"); + const button = screen.getByRole("button", { name: "Submit code" }); + + // Textarea still has focus (keyboard open) when the tap lands. + fireEvent.focus(textarea); + + // Real mobile browser sequence for a single physical tap on the button: + // the touch first blurs the still-focused textarea (dismissing the + // keyboard) before any click is dispatched. + fireEvent.touchStart(button); + fireEvent.blur(textarea); + fireEvent.click(button); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it("does not double-submit when pointerdown and touchstart both fire for a single physical tap", () => { + mockMatchMedia({ mobile: true }); + + const onSubmit = vi.fn(); + render( + , + ); + + const textarea = screen.getByRole("textbox"); + const button = screen.getByRole("button", { name: "Submit code" }); + + fireEvent.focus(textarea); + + // Some mobile browsers dispatch both a pointerdown and a touchstart for + // the same physical tap; the gesture guard must dedupe these so only one + // onSubmit call results from the combined sequence. + fireEvent.pointerDown(button, { pointerType: "touch" }); + fireEvent.touchStart(button); + fireEvent.blur(textarea); + fireEvent.click(button); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it("still submits exactly once on a plain desktop mouse click with no touch/pointer events", () => { + mockMatchMedia({ mobile: false, coarse: false }); + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1280 }); + + const onSubmit = vi.fn(); + render( + , + ); + + const button = screen.getByRole("button", { name: "Submit code" }); + + fireEvent.click(button); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it("never invokes onSubmit from any tap path while disabled", () => { + mockMatchMedia({ mobile: true }); + + const onSubmit = vi.fn(); + render( + , + ); + + const button = screen.getByRole("button", { name: "Submit code" }); + + fireEvent.pointerDown(button, { pointerType: "touch" }); + fireEvent.touchStart(button); + fireEvent.click(button); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/dashboard/app/hooks/__tests__/useTouchActionGesture.test.ts b/packages/dashboard/app/hooks/__tests__/useTouchActionGesture.test.ts new file mode 100644 index 0000000000..030a4968e5 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useTouchActionGesture.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useTouchActionGesture } from "../useTouchActionGesture"; + +describe("useTouchActionGesture", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("beginTouchActionGesture returns true once and false on a same-tick re-entrant call", () => { + const { result } = renderHook(() => useTouchActionGesture()); + + let first: boolean | undefined; + let second: boolean | undefined; + act(() => { + first = result.current.beginTouchActionGesture(); + second = result.current.beginTouchActionGesture(); + }); + + expect(first).toBe(true); + expect(second).toBe(false); + }); + + it("beginTouchActionGesture allows another gesture after the guard clears on the next tick", () => { + const { result } = renderHook(() => useTouchActionGesture()); + + let first: boolean | undefined; + act(() => { + first = result.current.beginTouchActionGesture(); + }); + expect(first).toBe(true); + + act(() => { + vi.advanceTimersByTime(0); + }); + + let second: boolean | undefined; + act(() => { + second = result.current.beginTouchActionGesture(); + }); + expect(second).toBe(true); + }); + + it("markHandledSendTouch sets a flag that consumeHandledSendTouch reads and clears exactly once", () => { + const { result } = renderHook(() => useTouchActionGesture()); + + act(() => { + result.current.markHandledSendTouch(); + }); + + let firstConsume: boolean | undefined; + let secondConsume: boolean | undefined; + act(() => { + firstConsume = result.current.consumeHandledSendTouch(); + secondConsume = result.current.consumeHandledSendTouch(); + }); + + expect(firstConsume).toBe(true); + expect(secondConsume).toBe(false); + }); + + it("consumeHandledSendTouch returns false when no touch has been marked handled", () => { + const { result } = renderHook(() => useTouchActionGesture()); + + let consumed: boolean | undefined; + act(() => { + consumed = result.current.consumeHandledSendTouch(); + }); + + expect(consumed).toBe(false); + }); + + it("the handled flag auto-expires after its timeout", () => { + const { result } = renderHook(() => useTouchActionGesture()); + + act(() => { + result.current.markHandledSendTouch(); + }); + + act(() => { + vi.advanceTimersByTime(700); + }); + + let consumed: boolean | undefined; + act(() => { + consumed = result.current.consumeHandledSendTouch(); + }); + + expect(consumed).toBe(false); + }); + + it("cleans up the pending handled-touch timer on unmount", () => { + const clearTimeoutSpy = vi.spyOn(global, "clearTimeout"); + + const { result, unmount } = renderHook(() => useTouchActionGesture()); + + act(() => { + result.current.markHandledSendTouch(); + }); + + unmount(); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + clearTimeoutSpy.mockRestore(); + }); +}); diff --git a/packages/dashboard/app/hooks/useTouchActionGesture.ts b/packages/dashboard/app/hooks/useTouchActionGesture.ts new file mode 100644 index 0000000000..d8034e65b4 --- /dev/null +++ b/packages/dashboard/app/hooks/useTouchActionGesture.ts @@ -0,0 +1,89 @@ +import { useCallback, useEffect, useRef } from "react"; + +/** + * FNXC:TouchActionGesture 2026-07-14-00:00: + * Root cause this hook fixes: on mobile, tapping a button while a nearby text + * input (e.g. a textarea) still has focus can cause the browser to consume + * that first touch solely to blur the input and dismiss the on-screen + * keyboard, suppressing or delaying the resulting synthetic `click` event. + * The visible symptom is "I have to tap twice" — the first tap only closes + * the keyboard, and only a second, separate tap actually fires the button's + * `onClick` handler (FN-7953, reported against the Anthropic manual OAuth + * code "Submit code" button). + * + * This hook generalizes the pattern already proven for the chat Send/Stop + * button (`useStandardChatActionGesture` in `StandardChatSurface.tsx`), + * extracted here as a standalone, non-chat-coupled hook so other touch + * targets (like OAuth manual code submission) can reuse the same fix without + * importing from or depending on `StandardChatSurface.tsx`. `StandardChatSurface.tsx` + * keeps its own separate, unmodified implementation — this hook does not + * replace it, to keep blast radius isolated per FN-7953's Do NOT list. + * + * Consumers wire the three returned callbacks onto a button element: + * - `onPointerDown` (touch pointer types only) / `onTouchStart`: call + * `event.preventDefault()`, guard with `beginTouchActionGesture()`, then + * `markHandledSendTouch()` before invoking the action immediately — this is + * what makes the FIRST tap submit instead of only blurring/dismissing the + * keyboard. + * - `onClick`: call `consumeHandledSendTouch()` first; if it returns `true` + * the action was already handled by the touch path above, so skip firing + * again (this is what prevents the synthetic click that follows a handled + * touch from double-invoking the action). + * - `onMouseDown`: call `event.preventDefault()` to avoid a mouse-driven + * blur race equivalent to the touch case. + */ +export function useTouchActionGesture() { + // Short-lived "this touch/pointer interaction already fired the action" + // flag. Read and cleared by consumeHandledSendTouch() inside onClick so the + // browser's follow-up synthetic click (fired after touchstart/pointerdown) + // does not invoke the action a second time. + const handledSendTouchRef = useRef(false); + const handledSendTouchTimerRef = useRef(null); + // Same-tick re-entrancy guard: onPointerDown and onTouchStart can both fire + // for a single physical tap on some browsers/devices; beginTouchActionGesture() + // ensures only the first of that pair proceeds. + const touchActionGestureRef = useRef(false); + + const markHandledSendTouch = useCallback(() => { + handledSendTouchRef.current = true; + if (handledSendTouchTimerRef.current != null) { + clearTimeout(handledSendTouchTimerRef.current); + } + // Auto-clear after 700ms so a later, genuinely separate tap is not + // silently swallowed if a click event never arrives for some reason. + handledSendTouchTimerRef.current = window.setTimeout(() => { + handledSendTouchRef.current = false; + handledSendTouchTimerRef.current = null; + }, 700); + }, []); + + const beginTouchActionGesture = useCallback(() => { + if (touchActionGestureRef.current) return false; + touchActionGestureRef.current = true; + window.setTimeout(() => { + touchActionGestureRef.current = false; + }, 0); + return true; + }, []); + + const consumeHandledSendTouch = useCallback(() => { + if (!handledSendTouchRef.current) return false; + handledSendTouchRef.current = false; + if (handledSendTouchTimerRef.current != null) { + clearTimeout(handledSendTouchTimerRef.current); + handledSendTouchTimerRef.current = null; + } + return true; + }, []); + + useEffect( + () => () => { + if (handledSendTouchTimerRef.current != null) { + clearTimeout(handledSendTouchTimerRef.current); + } + }, + [], + ); + + return { beginTouchActionGesture, markHandledSendTouch, consumeHandledSendTouch }; +}