Skip to content

Fix mobile composer focus race on expand#2499

Open
juliusmarminge wants to merge 2 commits intomainfrom
t3code/mobile-composer-keyboard-race
Open

Fix mobile composer focus race on expand#2499
juliusmarminge wants to merge 2 commits intomainfrom
t3code/mobile-composer-keyboard-race

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented May 4, 2026

Summary

  • Extracted the mobile composer expansion sequence into a dedicated helper to make the focus flow deterministic.
  • Switched the expansion path to flushSync so the focused state commits before the editor is focused.
  • Cancelled any pending blur, expand-focus, and release frames before re-expanding, and triggered expansion on pointer down as well as click to avoid mobile tap races.
  • Added a unit test covering the exact call order for the mobile composer expansion sequence.

Testing

  • bun fmt
  • bun lint
  • bun typecheck
  • bun run test

Note

Medium Risk
Moderate risk: rewires mobile expand/collapse behavior from React focus state to CSS/data attributes and changes event timing (pointerdown + RAF), which could regress mobile-only composer visibility/focus edge cases.

Overview
Fixes a mobile chat composer expand/focus race by removing the isComposerFocused state + blur/expand RAF bookkeeping and instead driving collapsed/expanded UI purely via CSS group-focus-within plus a temporary data-mobile-focus-primed attribute.

Extracts the expansion sequence into expandMobileComposerForKeyboard and triggers expansion on pointerdown (not just click) for collapsed prompt/custom-answer interactions; adds a small unit test asserting the helper’s call order.

Reviewed by Cursor Bugbot for commit 699caa7. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Fix mobile composer focus race on expand by replacing state with CSS and a primed data attribute

  • Removes React state (isComposerFocused) and multiple animation frame refs used to manage mobile composer expand/collapse, eliminating the race condition they caused.
  • Collapsed/expanded visibility is now controlled by CSS group focus-within and a data-mobile-focus-primed attribute on the composer surface, set in ChatComposer.tsx.
  • Extracts the expansion sequence into mobileComposerFocus.ts: cancel pending release → prime expanded state → focus editor → schedule release.
  • Tapping the collapsed prompt row or custom answer field now primes expansion on pointerDown before the click fires.
  • Behavioral Change: onFocusCapture/onBlurCapture handlers are removed; expand/collapse is no longer driven by focus events on the composer surface.

Macroscope summarized 699caa7.

- Extract keyboard expand flow into a helper
- Use flushSync to commit focus state before focusing the editor
- Add a regression test for the expand sequence
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 4, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b1bd6e09-3d05-48f2-9d14-e3a99bc01a85

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/mobile-composer-keyboard-race

Comment @coderabbitai help to get the list of available commands and usage tips.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 4, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
t3code-app Ready Ready Preview, Comment May 4, 2026 4:55pm

Request Review

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels May 4, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Dead ref never assigned a non-null value
    • Removed the dead mobileComposerExpandFrameRef, the always-no-op cancelPendingExpandFocus callback, its interface member, and the cleanup effect entry since the ref was never assigned a non-null value after the synchronous focus refactoring.

Create PR

Or push these changes by commenting:

@cursor push 12084242b0
Preview (12084242b0)
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -813,7 +813,6 @@
     const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);
     const activeComposerMenuItemRef = useRef<ComposerCommandItem | null>(null);
     const composerBlurFrameRef = useRef<number | null>(null);
-    const mobileComposerExpandFrameRef = useRef<number | null>(null);
     const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null);
     const mobileComposerExpandInFlightRef = useRef(false);
     const dragDepthRef = useRef(0);
@@ -1636,12 +1635,6 @@
             composerBlurFrameRef.current = null;
           }
         },
-        cancelPendingExpandFocus: () => {
-          if (mobileComposerExpandFrameRef.current !== null) {
-            window.cancelAnimationFrame(mobileComposerExpandFrameRef.current);
-            mobileComposerExpandFrameRef.current = null;
-          }
-        },
         cancelPendingRelease: () => {
           if (mobileComposerExpandReleaseFrameRef.current !== null) {
             window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current);
@@ -1844,9 +1837,6 @@
         if (composerBlurFrameRef.current !== null) {
           window.cancelAnimationFrame(composerBlurFrameRef.current);
         }
-        if (mobileComposerExpandFrameRef.current !== null) {
-          window.cancelAnimationFrame(mobileComposerExpandFrameRef.current);
-        }
         if (mobileComposerExpandReleaseFrameRef.current !== null) {
           window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current);
         }

diff --git a/apps/web/src/components/chat/mobileComposerFocus.test.ts b/apps/web/src/components/chat/mobileComposerFocus.test.ts
--- a/apps/web/src/components/chat/mobileComposerFocus.test.ts
+++ b/apps/web/src/components/chat/mobileComposerFocus.test.ts
@@ -7,7 +7,6 @@
 
     expandMobileComposerForKeyboard({
       cancelPendingBlur: vi.fn(() => calls.push("cancel-blur")),
-      cancelPendingExpandFocus: vi.fn(() => calls.push("cancel-expand-focus")),
       cancelPendingRelease: vi.fn(() => calls.push("cancel-release")),
       setExpandInFlight: vi.fn((inFlight) => calls.push(`in-flight:${inFlight}`)),
       commitExpandedState: vi.fn(() => calls.push("commit-expanded")),
@@ -17,7 +16,6 @@
 
     expect(calls).toEqual([
       "cancel-blur",
-      "cancel-expand-focus",
       "cancel-release",
       "in-flight:true",
       "commit-expanded",

diff --git a/apps/web/src/components/chat/mobileComposerFocus.ts b/apps/web/src/components/chat/mobileComposerFocus.ts
--- a/apps/web/src/components/chat/mobileComposerFocus.ts
+++ b/apps/web/src/components/chat/mobileComposerFocus.ts
@@ -1,6 +1,5 @@
 export interface MobileComposerExpandOptions {
   cancelPendingBlur: () => void;
-  cancelPendingExpandFocus: () => void;
   cancelPendingRelease: () => void;
   setExpandInFlight: (inFlight: boolean) => void;
   commitExpandedState: () => void;
@@ -10,7 +9,6 @@
 
 export function expandMobileComposerForKeyboard(options: MobileComposerExpandOptions) {
   options.cancelPendingBlur();
-  options.cancelPendingExpandFocus();
   options.cancelPendingRelease();
   options.setExpandInFlight(true);
   options.commitExpandedState();

You can send follow-ups to the cloud agent here.

Comment thread apps/web/src/components/chat/ChatComposer.tsx Outdated
macroscopeapp[bot]
macroscopeapp Bot previously approved these changes May 4, 2026
@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp Bot commented May 4, 2026

Approvability

Verdict: Approved

Targeted bug fix for mobile composer focus race condition. The refactoring simplifies state management by using CSS-based visibility instead of React state, with changes contained to mobile UI behavior and no sensitive code paths affected.

You can customize Macroscope's approvability policy. Learn more.

- Replace JS-driven collapse state with a primed mobile focus attribute
- Simplify keyboard expansion flow and update focus sequencing tests
@macroscopeapp macroscopeapp Bot dismissed their stale review May 4, 2026 16:55

Dismissing prior approval to re-evaluate 699caa7

@github-actions github-actions Bot added size:L 100-499 changed lines (additions + deletions). and removed size:M 30-99 changed lines (additions + deletions). labels May 4, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared a fix for 1 of the 2 issues found in the latest run.

  • ✅ Fixed: Portaled floating layers cause mobile composer collapse
    • Added data-mobile-floating-open attribute to the composer surface that tracks when CompactComposerControlsMenu or ProviderModelPicker is open, and included group-data-[mobile-floating-open=true]/composer in all mobile expanded/collapsed CSS conditions to prevent collapse while portaled layers are active.

Create PR

Or push these changes by commenting:

@cursor push ee9cabf430
Preview (ee9cabf430)
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -785,13 +785,15 @@
     const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);
     const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);
     const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);
+    const [isComposerCompactMenuOpen, setIsComposerCompactMenuOpen] = useState(false);
     const isMobileViewport = useMediaQuery("max-sm");
+    const isMobileFloatingLayerOpen = isComposerModelPickerOpen || isComposerCompactMenuOpen;
     const mobileCollapsedOnlyClassName =
-      "sm:hidden max-sm:group-focus-within/composer:hidden max-sm:group-data-[mobile-focus-primed=true]/composer:hidden";
+      "sm:hidden max-sm:group-focus-within/composer:hidden max-sm:group-data-[mobile-focus-primed=true]/composer:hidden max-sm:group-data-[mobile-floating-open=true]/composer:hidden";
     const mobileExpandedOnlyClassName =
-      "max-sm:hidden max-sm:group-focus-within/composer:block max-sm:group-data-[mobile-focus-primed=true]/composer:block";
+      "max-sm:hidden max-sm:group-focus-within/composer:block max-sm:group-data-[mobile-focus-primed=true]/composer:block max-sm:group-data-[mobile-floating-open=true]/composer:block";
     const mobileExpandedFlexOnlyClassName =
-      "max-sm:hidden max-sm:group-focus-within/composer:flex max-sm:group-data-[mobile-focus-primed=true]/composer:flex";
+      "max-sm:hidden max-sm:group-focus-within/composer:flex max-sm:group-data-[mobile-focus-primed=true]/composer:flex max-sm:group-data-[mobile-floating-open=true]/composer:flex";
 
     // ------------------------------------------------------------------
     // Refs
@@ -1915,6 +1917,7 @@
           <div
             ref={composerSurfaceRef}
             data-mobile-focus-primed="false"
+            data-mobile-floating-open={isMobileFloatingLayerOpen ? "true" : "false"}
             className={cn(
               "group/composer rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45",
               isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border",
@@ -2324,6 +2327,7 @@
                       runtimeMode={runtimeMode}
                       showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}
                       traitsMenuContent={providerTraitsMenuContent}
+                      onOpenChange={setIsComposerCompactMenuOpen}
                       onToggleInteractionMode={toggleInteractionMode}
                       onTogglePlanSidebar={togglePlanSidebar}
                       onRuntimeModeChange={handleRuntimeModeChange}

diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx
--- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx
+++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx
@@ -20,12 +20,13 @@
   runtimeMode: RuntimeMode;
   showInteractionModeToggle: boolean;
   traitsMenuContent?: ReactNode;
+  onOpenChange?: (open: boolean) => void;
   onToggleInteractionMode: () => void;
   onTogglePlanSidebar: () => void;
   onRuntimeModeChange: (mode: RuntimeMode) => void;
 }) {
   return (
-    <Menu>
+    <Menu onOpenChange={(open) => props.onOpenChange?.(open)}>
       <MenuTrigger
         render={
           <Button

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 699caa7. Configure here.

data-mobile-focus-primed="false"
className={cn(
"rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45",
"group/composer rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Portaled floating layers cause mobile composer collapse

Medium Severity

Removing isInsideComposerFloatingLayer and the onBlurCapture handler without a CSS-based replacement means that when mobile users open portaled floating layers (e.g., CompactComposerControlsMenu via MenuPrimitive.Portal or ProviderModelPicker via PopoverPrimitive.Portal), focus moves outside the group/composer div, focus-within deactivates, and the composer collapses. The footer containing the trigger disappears, breaking the interaction. The old code explicitly guarded against this via COMPOSER_FLOATING_LAYER_SELECTOR.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 699caa7. Configure here.

options.primeExpandedState();
options.focusEditorAtEnd();
options.scheduleRelease();
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trivial helper adds unnecessary indirection over inline calls

Low Severity

expandMobileComposerForKeyboard is a 4-line function that sequentially calls 4 callbacks. Since JavaScript is single-threaded, the call order is inherently deterministic without a wrapper. The associated test only verifies that JS executes statements top-to-bottom — a language guarantee. This adds indirection and a separate file without meaningful safety or encapsulation benefit beyond what inline calls already provide.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 699caa7. Configure here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bugbot Autofix determined this is a false positive.

This is a code style preference, not a functional bug — the helper correctly orchestrates the focus sequence and the extracted module provides a clear contract boundary for the mobile focus flow.

You can send follow-ups to the cloud agent here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant