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
30 useRef,30 useRef,
31 useState,31 useState,
32} from "react";32} from "react";
33import { flushSync } from "react-dom";
33import { useQuery } from "@tanstack/react-query";34import { useQuery } from "@tanstack/react-query";
34import { useDebouncedValue } from "@tanstack/react-pacer";35import { useDebouncedValue } from "@tanstack/react-pacer";
35import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";36import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
@@ -71,6 +72,7 @@ import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; · 35 unmodified lines
71import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";72import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";
72import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight";73import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight";
73import { searchSlashCommandItems } from "./composerSlashCommandSearch";74import { searchSlashCommandItems } from "./composerSlashCommandSearch";
75import { expandMobileComposerForKeyboard } from "./mobileComposerFocus";
74import {76import {
75 getComposerProviderState,77 getComposerProviderState,
76 renderProviderTraitsMenuContent,78 renderProviderTraitsMenuContent,
@@ -1627,25 +1629,42 @@ export const ChatComposer = memo( · 1550 unmodified lines
1627 [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit],1629 [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit],
1628 );1630 );
1629 const expandMobileComposer = useCallback(() => {1631 const expandMobileComposer = useCallback(() => {
1630 if (composerBlurFrameRef.current !== null) {1632 expandMobileComposerForKeyboard({
1631 window.cancelAnimationFrame(composerBlurFrameRef.current);1633 cancelPendingBlur: () => {
1632 composerBlurFrameRef.current = null;1634 if (composerBlurFrameRef.current !== null) {
1633 }1635 window.cancelAnimationFrame(composerBlurFrameRef.current);
1634 if (mobileComposerExpandFrameRef.current !== null) {1636 composerBlurFrameRef.current = null;
1635 window.cancelAnimationFrame(mobileComposerExpandFrameRef.current);1637 }
1636 }1638 },
1637 if (mobileComposerExpandReleaseFrameRef.current !== null) {1639 cancelPendingExpandFocus: () => {
1638 window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current);1640 if (mobileComposerExpandFrameRef.current !== null) {
1639 }1641 window.cancelAnimationFrame(mobileComposerExpandFrameRef.current);
1640 mobileComposerExpandInFlightRef.current = true;1642 mobileComposerExpandFrameRef.current = null;
1641 setIsComposerFocused(true);1643 }
1642 mobileComposerExpandFrameRef.current = window.requestAnimationFrame(() => {1644 },
1643 mobileComposerExpandFrameRef.current = null;1645 cancelPendingRelease: () => {
1644 composerEditorRef.current?.focusAtEnd();1646 if (mobileComposerExpandReleaseFrameRef.current !== null) {
1645 mobileComposerExpandReleaseFrameRef.current = window.requestAnimationFrame(() => {1647 window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current);
1646 mobileComposerExpandReleaseFrameRef.current = null;1648 mobileComposerExpandReleaseFrameRef.current = null;
1647 mobileComposerExpandInFlightRef.current = false;1649 }
1648 });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 },
1649 });1668 });
1650 }, []);1669 }, []);
1651 1670
@@ -2063,7 +2082,10 @@ export const ChatComposer = memo( · 411 unmodified lines
2063 : "text-muted-foreground/60",2082 : "text-muted-foreground/60",
2064 !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2",2083 !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2",
2065 )}2084 )}
2066 onPointerDown={(event) => event.preventDefault()}2085 onPointerDown={(event) => {
2086 event.preventDefault();
2087 expandMobileComposer();
2088 }}
2067 onClick={expandMobileComposer}2089 onClick={expandMobileComposer}
2068 aria-label="Write custom answer"2090 aria-label="Write custom answer"
2069 >2091 >
@@ -2102,7 +2124,10 @@ export const ChatComposer = memo( · 32 unmodified lines
2102 ? "text-foreground"2124 ? "text-foreground"
2103 : "text-muted-foreground/35",2125 : "text-muted-foreground/35",
2104 )}2126 )}
2105 onPointerDown={(event) => event.preventDefault()}2127 onPointerDown={(event) => {
2128 event.preventDefault();
2129 expandMobileComposer();
2130 }}
2106 onClick={expandMobileComposer}2131 onClick={expandMobileComposer}
2107 aria-label="Expand composer"2132 aria-label="Expand composer"
2108 >2133 >
apps/web/src/components/chat/mobileComposerFocus.test.ts+28−0
@@ -0,0 +1,28 @@
1import { describe, expect, it, vi } from "vitest";
2import { expandMobileComposerForKeyboard } from "./mobileComposerFocus";
3
4describe("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 @@
1export 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
11export 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}