From b24dc92179aded2f6e2b65ab6e7fc604653da3cc Mon Sep 17 00:00:00 2001 From: adliebe <133605035+adliebe@users.noreply.github.com> Date: Wed, 27 May 2026 11:34:32 -0400 Subject: [PATCH 1/3] fix: ignore stale mention search responses --- src/components/ui/mention-textarea.test.tsx | 90 +++++++++++++++++++++ src/components/ui/mention-textarea.tsx | 26 ++++-- 2 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 src/components/ui/mention-textarea.test.tsx diff --git a/src/components/ui/mention-textarea.test.tsx b/src/components/ui/mention-textarea.test.tsx new file mode 100644 index 00000000..6e663fd9 --- /dev/null +++ b/src/components/ui/mention-textarea.test.tsx @@ -0,0 +1,90 @@ +import { fireEvent, render, screen, waitFor, act } from "@testing-library/react"; +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { MentionTextarea } from "./mention-textarea"; + +function ControlledMentionTextarea() { + const [value, setValue] = React.useState(""); + return ; +} + +function deferredResponse( + users: Array<{ id: string; username: string; avatar_url: string | null }> +) { + let resolve!: (value: Response) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { + promise, + resolve: () => + resolve({ + ok: true, + json: async () => ({ users }), + } as Response), + }; +} + +describe("MentionTextarea", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("ignores stale mention search responses after a newer query resolves", async () => { + const oldQuery = deferredResponse([{ id: "1", username: "alice", avatar_url: null }]); + const newQuery = deferredResponse([{ id: "2", username: "adliebe", avatar_url: null }]); + const fetchMock = vi + .fn() + .mockReturnValueOnce(oldQuery.promise) + .mockReturnValueOnce(newQuery.promise); + vi.stubGlobal("fetch", fetchMock); + + render(); + const textarea = screen.getByLabelText("comment") as HTMLTextAreaElement; + + fireEvent.change(textarea, { target: { value: "@a" } }); + textarea.setSelectionRange(2, 2); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + + fireEvent.change(textarea, { target: { value: "@ad" } }); + textarea.setSelectionRange(3, 3); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expect(fetchMock.mock.calls[0][0]).toContain("q=a"); + expect(fetchMock.mock.calls[1][0]).toContain("q=ad"); + + await act(async () => { + newQuery.resolve(); + await newQuery.promise; + }); + expect(await screen.findByText("@adliebe")).toBeInTheDocument(); + + await act(async () => { + oldQuery.resolve(); + await oldQuery.promise; + }); + + expect(screen.getByText("@adliebe")).toBeInTheDocument(); + expect(screen.queryByText("@alice")).not.toBeInTheDocument(); + }); + + it("cancels pending mention search when the cursor leaves mention context", async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + render(); + const textarea = screen.getByLabelText("comment") as HTMLTextAreaElement; + + fireEvent.change(textarea, { target: { value: "@a" } }); + textarea.setSelectionRange(2, 2); + fireEvent.change(textarea, { target: { value: "@a done" } }); + textarea.setSelectionRange(7, 7); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/ui/mention-textarea.tsx b/src/components/ui/mention-textarea.tsx index bd8b797b..85d7e578 100644 --- a/src/components/ui/mention-textarea.tsx +++ b/src/components/ui/mention-textarea.tsx @@ -17,8 +17,10 @@ interface UserSuggestion { avatar_url: string | null; } -export interface MentionTextareaProps - extends Omit, "onChange"> { +export interface MentionTextareaProps extends Omit< + React.TextareaHTMLAttributes, + "onChange" +> { value: string; onChange: (value: string) => void; } @@ -35,25 +37,31 @@ const MentionTextarea = forwardRef( const [query, setQuery] = useState(""); const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0 }); const debounceRef = useRef | null>(null); + const requestSeqRef = useRef(0); const dropdownRef = useRef(null); // Fetch user suggestions - const fetchSuggestions = useCallback(async (q: string) => { + const fetchSuggestions = useCallback(async (q: string, seq: number) => { if (q.length < 1) { - setSuggestions([]); - setShowDropdown(false); + if (requestSeqRef.current === seq) { + setSuggestions([]); + setShowDropdown(false); + } return; } try { const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}&limit=8`); if (res.ok) { const data = await res.json(); + if (requestSeqRef.current !== seq) return; setSuggestions(data.users || []); setShowDropdown((data.users || []).length > 0); setSelectedIndex(0); } } catch { - setSuggestions([]); + if (requestSeqRef.current === seq) { + setSuggestions([]); + } } }, []); @@ -83,8 +91,12 @@ const MentionTextarea = forwardRef( }); if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => fetchSuggestions(q), 300); + const seq = ++requestSeqRef.current; + debounceRef.current = setTimeout(() => fetchSuggestions(q, seq), 300); } else { + if (debounceRef.current) clearTimeout(debounceRef.current); + requestSeqRef.current++; + setSuggestions([]); setShowDropdown(false); setMentionStart(null); setQuery(""); From 96ec1808e76468161cadecb95cc644348f31a079 Mon Sep 17 00:00:00 2001 From: adliebe <133605035+adliebe@users.noreply.github.com> Date: Wed, 27 May 2026 11:41:45 -0400 Subject: [PATCH 2/3] test: harden mention suggestion race coverage --- src/components/ui/mention-textarea.test.tsx | 15 +++++++++++---- src/components/ui/mention-textarea.tsx | 1 + 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/ui/mention-textarea.test.tsx b/src/components/ui/mention-textarea.test.tsx index 6e663fd9..e2b0ce81 100644 --- a/src/components/ui/mention-textarea.test.tsx +++ b/src/components/ui/mention-textarea.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor, act } from "@testing-library/react"; +import { fireEvent, render, screen, act } from "@testing-library/react"; import React from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { MentionTextarea } from "./mention-textarea"; @@ -32,6 +32,7 @@ describe("MentionTextarea", () => { }); it("ignores stale mention search responses after a newer query resolves", async () => { + vi.useFakeTimers(); const oldQuery = deferredResponse([{ id: "1", username: "alice", avatar_url: null }]); const newQuery = deferredResponse([{ id: "2", username: "adliebe", avatar_url: null }]); const fetchMock = vi @@ -45,11 +46,17 @@ describe("MentionTextarea", () => { fireEvent.change(textarea, { target: { value: "@a" } }); textarea.setSelectionRange(2, 2); - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + act(() => { + vi.advanceTimersByTime(300); + }); + expect(fetchMock).toHaveBeenCalledTimes(1); fireEvent.change(textarea, { target: { value: "@ad" } }); textarea.setSelectionRange(3, 3); - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + act(() => { + vi.advanceTimersByTime(300); + }); + expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock.mock.calls[0][0]).toContain("q=a"); expect(fetchMock.mock.calls[1][0]).toContain("q=ad"); @@ -57,7 +64,7 @@ describe("MentionTextarea", () => { newQuery.resolve(); await newQuery.promise; }); - expect(await screen.findByText("@adliebe")).toBeInTheDocument(); + expect(screen.getByText("@adliebe")).toBeInTheDocument(); await act(async () => { oldQuery.resolve(); diff --git a/src/components/ui/mention-textarea.tsx b/src/components/ui/mention-textarea.tsx index 85d7e578..401ac4c0 100644 --- a/src/components/ui/mention-textarea.tsx +++ b/src/components/ui/mention-textarea.tsx @@ -61,6 +61,7 @@ const MentionTextarea = forwardRef( } catch { if (requestSeqRef.current === seq) { setSuggestions([]); + setShowDropdown(false); } } }, []); From d1c39548aabc709fd8e2de8010006adaadc30f9a Mon Sep 17 00:00:00 2001 From: adliebe <133605035+adliebe@users.noreply.github.com> Date: Wed, 27 May 2026 11:46:47 -0400 Subject: [PATCH 3/3] fix: clear mention suggestions on failed search --- src/components/ui/mention-textarea.test.tsx | 48 +++++++++++++++++++++ src/components/ui/mention-textarea.tsx | 17 +++++--- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/components/ui/mention-textarea.test.tsx b/src/components/ui/mention-textarea.test.tsx index e2b0ce81..b2c7e51b 100644 --- a/src/components/ui/mention-textarea.test.tsx +++ b/src/components/ui/mention-textarea.test.tsx @@ -25,6 +25,17 @@ function deferredResponse( }; } +function deferredHttpError() { + let resolve!: (value: Response) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { + promise, + resolve: () => resolve({ ok: false } as Response), + }; +} + describe("MentionTextarea", () => { afterEach(() => { vi.useRealTimers(); @@ -94,4 +105,41 @@ describe("MentionTextarea", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + + it("clears stale suggestions when the active mention search returns an HTTP error", async () => { + vi.useFakeTimers(); + const goodQuery = deferredResponse([{ id: "1", username: "alice", avatar_url: null }]); + const failedQuery = deferredHttpError(); + const fetchMock = vi + .fn() + .mockReturnValueOnce(goodQuery.promise) + .mockReturnValueOnce(failedQuery.promise); + vi.stubGlobal("fetch", fetchMock); + + render(); + const textarea = screen.getByLabelText("comment") as HTMLTextAreaElement; + + fireEvent.change(textarea, { target: { value: "@a" } }); + textarea.setSelectionRange(2, 2); + act(() => { + vi.advanceTimersByTime(300); + }); + await act(async () => { + goodQuery.resolve(); + await goodQuery.promise; + }); + expect(screen.getByText("@alice")).toBeInTheDocument(); + + fireEvent.change(textarea, { target: { value: "@ad" } }); + textarea.setSelectionRange(3, 3); + act(() => { + vi.advanceTimersByTime(300); + }); + await act(async () => { + failedQuery.resolve(); + await failedQuery.promise; + }); + + expect(screen.queryByText("@alice")).not.toBeInTheDocument(); + }); }); diff --git a/src/components/ui/mention-textarea.tsx b/src/components/ui/mention-textarea.tsx index 401ac4c0..2b6603af 100644 --- a/src/components/ui/mention-textarea.tsx +++ b/src/components/ui/mention-textarea.tsx @@ -51,13 +51,18 @@ const MentionTextarea = forwardRef( } try { const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}&limit=8`); - if (res.ok) { - const data = await res.json(); - if (requestSeqRef.current !== seq) return; - setSuggestions(data.users || []); - setShowDropdown((data.users || []).length > 0); - setSelectedIndex(0); + if (!res.ok) { + if (requestSeqRef.current === seq) { + setSuggestions([]); + setShowDropdown(false); + } + return; } + const data = await res.json(); + if (requestSeqRef.current !== seq) return; + setSuggestions(data.users || []); + setShowDropdown((data.users || []).length > 0); + setSelectedIndex(0); } catch { if (requestSeqRef.current === seq) { setSuggestions([]);