History

Fix mobile composer focus race on expand

- 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

Julius Marminge committed 6 months ago

Commit 34f46461 parent 91a03e03 files · +93 −21Browse files

Unified · Split

3 files
  1. apps/web/src/components/chat/ChatComposer.tsx +46 −21
  2. apps/web/src/components/chat/mobileComposerFocus.test.ts +28 −0
  3. apps/web/src/components/chat/mobileComposerFocus.ts +19 −0
apps/web/src/components/chat/ChatComposer.tsx+46−21
@@ -30,6 +30,7 @@ import { · 29 unmodified lines
3030 useRef,
3131 useState,
3232 } from "react";
33+import { flushSync } from "react-dom";
3334 import { useQuery } from "@tanstack/react-query";
3435 import { useDebouncedValue } from "@tanstack/react-pacer";
3536 import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
@@ -71,6 +72,7 @@ import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; · 35 unmodified lines
7172 import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";
7273 import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight";
7374 import { searchSlashCommandItems } from "./composerSlashCommandSearch";
75+import { expandMobileComposerForKeyboard } from "./mobileComposerFocus";
7476 import {
7577 getComposerProviderState,
7678 renderProviderTraitsMenuContent,
@@ -1627,25 +1629,42 @@ export const ChatComposer = memo( · 1550 unmodified lines
16271629 [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit],
16281630 );
16291631 const expandMobileComposer = useCallback(() => {
1630− if (composerBlurFrameRef.current !== null) {
1631− window.cancelAnimationFrame(composerBlurFrameRef.current);
1632− composerBlurFrameRef.current = null;
1633− }
1634− if (mobileComposerExpandFrameRef.current !== null) {
1635− window.cancelAnimationFrame(mobileComposerExpandFrameRef.current);
1636− }
1637− if (mobileComposerExpandReleaseFrameRef.current !== null) {
1638− window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current);
1639− }
1640− mobileComposerExpandInFlightRef.current = true;
1641− setIsComposerFocused(true);
1642− mobileComposerExpandFrameRef.current = window.requestAnimationFrame(() => {
1643− mobileComposerExpandFrameRef.current = null;
1644− composerEditorRef.current?.focusAtEnd();
1645− mobileComposerExpandReleaseFrameRef.current = window.requestAnimationFrame(() => {
1646− mobileComposerExpandReleaseFrameRef.current = null;
1647− mobileComposerExpandInFlightRef.current = false;
1648− });
1632+ expandMobileComposerForKeyboard({
1633+ cancelPendingBlur: () => {
1634+ if (composerBlurFrameRef.current !== null) {
1635+ window.cancelAnimationFrame(composerBlurFrameRef.current);
1636+ composerBlurFrameRef.current = null;
1637+ }
1638+ },
1639+ cancelPendingExpandFocus: () => {
1640+ if (mobileComposerExpandFrameRef.current !== null) {
1641+ window.cancelAnimationFrame(mobileComposerExpandFrameRef.current);
1642+ mobileComposerExpandFrameRef.current = null;
1643+ }
1644+ },
1645+ cancelPendingRelease: () => {
1646+ if (mobileComposerExpandReleaseFrameRef.current !== null) {
1647+ window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current);
1648+ mobileComposerExpandReleaseFrameRef.current = null;
1649+ }
1650+ },
1651+ setExpandInFlight: (inFlight) => {
1652+ mobileComposerExpandInFlightRef.current = inFlight;
1653+ },
1654+ commitExpandedState: () => {
1655+ flushSync(() => {
1656+ setIsComposerFocused(true);
1657+ });
1658+ },
1659+ focusEditorAtEnd: () => {
1660+ composerEditorRef.current?.focusAtEnd();
1661+ },
1662+ scheduleRelease: () => {
1663+ mobileComposerExpandReleaseFrameRef.current = window.requestAnimationFrame(() => {
1664+ mobileComposerExpandReleaseFrameRef.current = null;
1665+ mobileComposerExpandInFlightRef.current = false;
1666+ });
1667+ },
16491668 });
16501669 }, []);
16511670
@@ -2063,7 +2082,10 @@ export const ChatComposer = memo( · 411 unmodified lines
20632082 : "text-muted-foreground/60",
20642083 !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2",
20652084 )}
2066− onPointerDown={(event) => event.preventDefault()}
2085+ onPointerDown={(event) => {
2086+ event.preventDefault();
2087+ expandMobileComposer();
2088+ }}
20672089 onClick={expandMobileComposer}
20682090 aria-label="Write custom answer"
20692091 >
@@ -2102,7 +2124,10 @@ export const ChatComposer = memo( · 32 unmodified lines
21022124 ? "text-foreground"
21032125 : "text-muted-foreground/35",
21042126 )}
2105− onPointerDown={(event) => event.preventDefault()}
2127+ onPointerDown={(event) => {
2128+ event.preventDefault();
2129+ expandMobileComposer();
2130+ }}
21062131 onClick={expandMobileComposer}
21072132 aria-label="Expand composer"
21082133 >
apps/web/src/components/chat/mobileComposerFocus.test.ts+28−0
@@ -0,0 +1,28 @@
1+import { describe, expect, it, vi } from "vitest";
2+import { expandMobileComposerForKeyboard } from "./mobileComposerFocus";
3+
4+describe("expandMobileComposerForKeyboard", () => {
5+ it("focuses after the expanded composer is committed and before release is scheduled", () => {
6+ const calls: string[] = [];
7+
8+ expandMobileComposerForKeyboard({
9+ cancelPendingBlur: vi.fn(() => calls.push("cancel-blur")),
10+ cancelPendingExpandFocus: vi.fn(() => calls.push("cancel-expand-focus")),
11+ cancelPendingRelease: vi.fn(() => calls.push("cancel-release")),
12+ setExpandInFlight: vi.fn((inFlight) => calls.push(`in-flight:${inFlight}`)),
13+ commitExpandedState: vi.fn(() => calls.push("commit-expanded")),
14+ focusEditorAtEnd: vi.fn(() => calls.push("focus")),
15+ scheduleRelease: vi.fn(() => calls.push("schedule-release")),
16+ });
17+
18+ expect(calls).toEqual([
19+ "cancel-blur",
20+ "cancel-expand-focus",
21+ "cancel-release",
22+ "in-flight:true",
23+ "commit-expanded",
24+ "focus",
25+ "schedule-release",
26+ ]);
27+ });
28+});
apps/web/src/components/chat/mobileComposerFocus.ts+19−0
@@ -0,0 +1,19 @@
1+export interface MobileComposerExpandOptions {
2+ cancelPendingBlur: () => void;
3+ cancelPendingExpandFocus: () => void;
4+ cancelPendingRelease: () => void;
5+ setExpandInFlight: (inFlight: boolean) => void;
6+ commitExpandedState: () => void;
7+ focusEditorAtEnd: () => void;
8+ scheduleRelease: () => void;
9+}
10+
11+export function expandMobileComposerForKeyboard(options: MobileComposerExpandOptions) {
12+ options.cancelPendingBlur();
13+ options.cancelPendingExpandFocus();
14+ options.cancelPendingRelease();
15+ options.setExpandInFlight(true);
16+ options.commitExpandedState();
17+ options.focusEditorAtEnd();
18+ options.scheduleRelease();
19+}