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([]);