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("");