diff --git a/src/components/ui/mention-textarea.test.tsx b/src/components/ui/mention-textarea.test.tsx new file mode 100644 index 00000000..b2c7e51b --- /dev/null +++ b/src/components/ui/mention-textarea.test.tsx @@ -0,0 +1,145 @@ +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"; + +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), + }; +} + +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(); + vi.restoreAllMocks(); + }); + + 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 + .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); + act(() => { + vi.advanceTimersByTime(300); + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + fireEvent.change(textarea, { target: { value: "@ad" } }); + textarea.setSelectionRange(3, 3); + 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"); + + await act(async () => { + newQuery.resolve(); + await newQuery.promise; + }); + expect(screen.getByText("@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(); + }); + + 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 bd8b797b..2b6603af 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,37 @@ 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(); - 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 { - setSuggestions([]); + if (requestSeqRef.current === seq) { + setSuggestions([]); + setShowDropdown(false); + } } }, []); @@ -83,8 +97,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("");