FN-7953: fix mobile OAuth code submit taps

Submit Anthropic OAuth manual codes on the first mobile tap instead of requiring keyboard dismissal first.

- Add a reusable touch action gesture hook that handles touch/pointer activation before synthetic clicks.
- Wire the OAuth manual code Submit button to invoke submission on the first touch while preventing duplicate click handling.
- Cover the mobile double-tap regression and document the UI bug pattern for future fixes.

Files changed:
 .../oauth-manual-code-mobile-double-tap-submit.md  |  60 +++++++++++
 .../app/components/OAuthManualCodeForm.tsx         |  31 +++++-
 .../__tests__/OAuthManualCodeForm.test.tsx         | 110 +++++++++++++++++++++
 .../hooks/__tests__/useTouchActionGesture.test.ts  | 110 +++++++++++++++++++++
 .../dashboard/app/hooks/useTouchActionGesture.ts   |  89 +++++++++++++++++
 5 files changed, 399 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-7953

Fusion-Task-Lineage: d387cdbd-25a7-4b7d-add6-27a1ded5cbea

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-14 17:18:06 -07:00
parent 6aff4958ad
commit d0ce7829c0
5 changed files with 399 additions and 1 deletions

View File

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

View File

@@ -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<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLTextAreaElement | null>(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({
<button
type="button"
className="btn btn-sm"
onClick={onSubmit}
onPointerDown={(event) => {
if (event.pointerType && event.pointerType !== "mouse") {
event.preventDefault();
if (!beginTouchActionGesture()) return;
markHandledSendTouch();
if (!disabled) onSubmit();
}
}}
onTouchStart={(event) => {
event.preventDefault();
if (!beginTouchActionGesture()) return;
markHandledSendTouch();
if (!disabled) onSubmit();
}}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
if (consumeHandledSendTouch()) return;
if (!disabled) onSubmit();
}}
disabled={disabled}
style={{ touchAction: "manipulation" }}
>
{submitLabel}
</button>

View File

@@ -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(
<OAuthManualCodeForm
value="pasted-code"
onChange={vi.fn()}
onSubmit={onSubmit}
prompt="Paste code"
/>,
);
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(
<OAuthManualCodeForm
value="pasted-code"
onChange={vi.fn()}
onSubmit={onSubmit}
prompt="Paste code"
/>,
);
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(
<OAuthManualCodeForm
value="pasted-code"
onChange={vi.fn()}
onSubmit={onSubmit}
prompt="Paste code"
/>,
);
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(
<OAuthManualCodeForm
value="pasted-code"
onChange={vi.fn()}
onSubmit={onSubmit}
prompt="Paste code"
disabled
/>,
);
const button = screen.getByRole("button", { name: "Submit code" });
fireEvent.pointerDown(button, { pointerType: "touch" });
fireEvent.touchStart(button);
fireEvent.click(button);
expect(onSubmit).not.toHaveBeenCalled();
});
});
});

View File

@@ -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();
});
});

View File

@@ -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<number | null>(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 };
}