Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions src/components/ui/mention-textarea.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <MentionTextarea aria-label="comment" value={value} onChange={setValue} />;
}

function deferredResponse(
users: Array<{ id: string; username: string; avatar_url: string | null }>
) {
let resolve!: (value: Response) => void;
const promise = new Promise<Response>((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<Response>((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(<ControlledMentionTextarea />);
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();
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.

it("cancels pending mention search when the cursor leaves mention context", async () => {
vi.useFakeTimers();
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);

render(<ControlledMentionTextarea />);
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(<ControlledMentionTextarea />);
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();
});
});
42 changes: 30 additions & 12 deletions src/components/ui/mention-textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ interface UserSuggestion {
avatar_url: string | null;
}

export interface MentionTextareaProps
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "onChange"> {
export interface MentionTextareaProps extends Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
"onChange"
> {
value: string;
onChange: (value: string) => void;
}
Expand All @@ -35,25 +37,37 @@ const MentionTextarea = forwardRef<HTMLTextAreaElement, MentionTextareaProps>(
const [query, setQuery] = useState("");
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0 });
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const requestSeqRef = useRef(0);
const dropdownRef = useRef<HTMLDivElement>(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);
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}, []);

Expand Down Expand Up @@ -83,8 +97,12 @@ const MentionTextarea = forwardRef<HTMLTextAreaElement, MentionTextareaProps>(
});

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