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:
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
89
packages/dashboard/app/hooks/useTouchActionGesture.ts
Normal file
89
packages/dashboard/app/hooks/useTouchActionGesture.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user