Skip to content

Commit 0cd9aa8

Browse files
committed
fix(evalboard): fix search bar clear issue
1 parent 59a240c commit 0cd9aa8

2 files changed

Lines changed: 91 additions & 4 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, expect, test, vi, beforeEach, afterEach } from "vitest";
2+
import { render, screen, act, fireEvent } from "@testing-library/react";
3+
4+
// vi.hoisted ensures these are initialized before vi.mock hoists its factory.
5+
const { mockReplace, navState } = vi.hoisted(() => ({
6+
mockReplace: vi.fn(),
7+
navState: { q: "" as string },
8+
}));
9+
10+
vi.mock("next/navigation", () => ({
11+
useRouter: () => ({ replace: mockReplace }),
12+
usePathname: () => "/",
13+
useSearchParams: () => new URLSearchParams(navState.q ? `q=${navState.q}` : ""),
14+
}));
15+
16+
const { SearchBox } = await import("../search-box");
17+
18+
describe("SearchBox — typing-ahead race condition", () => {
19+
beforeEach(() => {
20+
vi.useFakeTimers();
21+
mockReplace.mockClear();
22+
navState.q = "";
23+
});
24+
afterEach(() => {
25+
vi.useRealTimers();
26+
});
27+
28+
test("preserves in-progress input when a navigation resolves mid-typing", () => {
29+
// Reproduces the race: user types "foo" → debounce fires → user types
30+
// more → navigation for "foo" resolves → input must NOT reset to "foo".
31+
const { rerender } = render(<SearchBox />);
32+
const input = screen.getByRole("textbox");
33+
34+
fireEvent.change(input, { target: { value: "foo" } });
35+
36+
// Debounce fires; typingAhead becomes false.
37+
act(() => { vi.advanceTimersByTime(300); });
38+
expect(mockReplace).toHaveBeenCalledOnce();
39+
40+
// User types more before the navigation resolves.
41+
fireEvent.change(input, { target: { value: "foobar" } });
42+
43+
// Navigation for "foo" resolves — URL now reports "foo".
44+
navState.q = "foo";
45+
rerender(<SearchBox />);
46+
47+
// typingAhead is true, so the sync effect must NOT overwrite the input.
48+
expect(input).toHaveValue("foobar");
49+
});
50+
51+
test("syncs from URL when the user is not typing (external navigation)", () => {
52+
// Back/forward nav or a tag click should still update the input when the
53+
// user hasn't typed anything since the last URL write.
54+
const { rerender } = render(<SearchBox />);
55+
const input = screen.getByRole("textbox");
56+
57+
navState.q = "tag:alpha";
58+
rerender(<SearchBox />);
59+
60+
expect(input).toHaveValue("tag:alpha");
61+
});
62+
63+
test("clears the input when the URL is cleared externally", () => {
64+
navState.q = "foo";
65+
const { rerender } = render(<SearchBox />);
66+
const input = screen.getByRole("textbox");
67+
68+
expect(input).toHaveValue("foo");
69+
70+
navState.q = "";
71+
rerender(<SearchBox />);
72+
73+
expect(input).toHaveValue("");
74+
});
75+
});

evalboard/app/_components/search-box.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import { usePathname, useRouter, useSearchParams } from "next/navigation";
4-
import { useEffect, useState } from "react";
4+
import { useEffect, useRef, useState } from "react";
55

66
const Q_DEBOUNCE_MS = 300;
77

@@ -18,18 +18,28 @@ export function SearchBox({
1818

1919
const urlQ = searchParams.get("q") ?? "";
2020
const [q, setQ] = useState(urlQ);
21+
// True while the user has typed ahead of the last URL write. Prevents the
22+
// URL-sync effect from overwriting in-progress input when a navigation
23+
// triggered by the debounce resolves asynchronously.
24+
const typingAhead = useRef(false);
2125

2226
// Sync local state when the URL changes externally (back/forward, link
23-
// clicks). The debounced write below early-returns when state and URL
24-
// agree, so this can't loop.
27+
// clicks). Skipped while the user is ahead of the URL to avoid clobbering
28+
// in-progress input with a stale value from a just-resolved navigation.
2529
useEffect(() => {
30+
if (typingAhead.current) return;
2631
setQ((prev) => (prev.trim() === urlQ ? prev : urlQ));
2732
}, [urlQ]);
2833

2934
useEffect(() => {
3035
const trimmed = q.trim();
31-
if (trimmed === urlQ) return;
36+
if (trimmed === urlQ) {
37+
typingAhead.current = false;
38+
return;
39+
}
40+
typingAhead.current = true;
3241
const timer = setTimeout(() => {
42+
typingAhead.current = false;
3343
// Read the live URL at fire time so a concurrent write (e.g. a
3444
// tag click that landed during the debounce) isn't clobbered.
3545
const params = new URLSearchParams(window.location.search);
@@ -40,6 +50,8 @@ export function SearchBox({
4050
scroll: false,
4151
});
4252
}, Q_DEBOUNCE_MS);
53+
// Don't clear typingAhead in cleanup — the timer was cancelled because
54+
// the user typed another character, so they're still ahead of the URL.
4355
return () => clearTimeout(timer);
4456
}, [q, urlQ, pathname, router]);
4557

0 commit comments

Comments
 (0)