Skip to content
Merged
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
120 changes: 120 additions & 0 deletions evalboard/app/_components/__tests__/search-box.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, expect, test, vi, beforeEach, afterEach } from "vitest";
import { render, screen, act, fireEvent } from "@testing-library/react";

// vi.hoisted ensures these are initialized before vi.mock hoists its factory.
const { mockReplace, navState } = vi.hoisted(() => ({
mockReplace: vi.fn(),
navState: { q: "" as string },
}));

vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: mockReplace }),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(navState.q ? `q=${navState.q}` : ""),
}));

const { SearchBox } = await import("../search-box");

describe("SearchBox — typing-ahead race condition", () => {
beforeEach(() => {
vi.useFakeTimers();
mockReplace.mockClear();
navState.q = "";
});
afterEach(() => {
vi.useRealTimers();
});

test("preserves in-progress input when a navigation resolves mid-typing", () => {
// Reproduces the race: user types "foo" → debounce fires → user types
// more → navigation for "foo" resolves → input must NOT reset to "foo".
const { rerender } = render(<SearchBox />);
const input = screen.getByRole("textbox");

fireEvent.change(input, { target: { value: "foo" } });

// Debounce fires; typingAhead becomes false.
act(() => { vi.advanceTimersByTime(300); });
expect(mockReplace).toHaveBeenCalledOnce();

// User types more before the navigation resolves.
fireEvent.change(input, { target: { value: "foobar" } });

// Navigation for "foo" resolves — URL now reports "foo".
navState.q = "foo";
rerender(<SearchBox />);

// typingAhead is true, so the sync effect must NOT overwrite the input.
expect(input).toHaveValue("foobar");
});

test("syncs from URL when the user is not typing (external navigation)", () => {
// Back/forward nav or a tag click should still update the input when the
// user hasn't typed anything since the last URL write.
const { rerender } = render(<SearchBox />);
const input = screen.getByRole("textbox");

navState.q = "tag:alpha";
rerender(<SearchBox />);

expect(input).toHaveValue("tag:alpha");
});

test("clears the input when the URL is cleared externally", () => {
navState.q = "foo";
const { rerender } = render(<SearchBox />);
const input = screen.getByRole("textbox");

expect(input).toHaveValue("foo");

navState.q = "";
rerender(<SearchBox />);

expect(input).toHaveValue("");
});

test("clear button does not get repopulated when a stale navigation resolves", () => {
// The PR's named fix: user clicks × to clear a settled search, then a
// navigation that had already resolved with the old value re-renders the
// component — the input must stay empty, not snap back to the old value.
navState.q = "foo";
const { rerender } = render(<SearchBox />);
const input = screen.getByRole("textbox");
expect(input).toHaveValue("foo");

// User clicks ×.
fireEvent.click(screen.getByRole("button", { name: /clear search/i }));
expect(input).toHaveValue("");

// A stale navigation reports q="foo" — must NOT repopulate the input.
navState.q = "foo";
rerender(<SearchBox />);

expect(input).toHaveValue("");
});

test("typingAhead latch releases after the debounce fires so later external nav syncs", () => {
// Guards against a stuck-true latch: after the debounce settles (user
// stops typing, timer fires, typingAhead → false), a subsequent genuine
// external navigation must still update the input.
const { rerender } = render(<SearchBox />);
const input = screen.getByRole("textbox");

fireEvent.change(input, { target: { value: "foo" } });

// Let the debounce fire — typingAhead resets to false.
act(() => { vi.advanceTimersByTime(300); });

// Simulate the navigation resolving (URL catches up).
navState.q = "foo";
rerender(<SearchBox />);
expect(input).toHaveValue("foo");

// Now a genuine external navigation changes q (e.g. browser Back).
navState.q = "bar";
rerender(<SearchBox />);

// typingAhead is false, so the sync must apply.
expect(input).toHaveValue("bar");
});
});
24 changes: 20 additions & 4 deletions evalboard/app/_components/search-box.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";

const Q_DEBOUNCE_MS = 300;

Expand All @@ -18,18 +18,31 @@ export function SearchBox({

const urlQ = searchParams.get("q") ?? "";
const [q, setQ] = useState(urlQ);
// Invariant: true iff local input is ahead of the last URL write.
// Written in three places by the debounce effect (cleared on catch-up at
// the early-return, set when a new timer arms, cleared when the timer
// fires). Read by the sync effect to decide whether to accept a URL change.
const typingAhead = useRef(false);

// Sync local state when the URL changes externally (back/forward, link
// clicks). The debounced write below early-returns when state and URL
// agree, so this can't loop.
// clicks). Skipped while the user is ahead of the URL to avoid clobbering
// in-progress input with a stale value from a just-resolved navigation.
// Note: external q changes that arrive during an active debounce window are
// intentionally deferred — the user's in-progress typing takes priority.
useEffect(() => {
if (typingAhead.current) return;
setQ((prev) => (prev.trim() === urlQ ? prev : urlQ));
}, [urlQ]);

useEffect(() => {
const trimmed = q.trim();
if (trimmed === urlQ) return;
if (trimmed === urlQ) {
typingAhead.current = false;
return;
}
typingAhead.current = true;
const timer = setTimeout(() => {
typingAhead.current = false;
// Read the live URL at fire time so a concurrent write (e.g. a
// tag click that landed during the debounce) isn't clobbered.
const params = new URLSearchParams(window.location.search);
Expand All @@ -40,6 +53,9 @@ export function SearchBox({
scroll: false,
});
}, Q_DEBOUNCE_MS);
// Don't reset typingAhead in cleanup — cleanup fires on any dep change
// (q, urlQ, pathname, router). The effect body re-run re-establishes
// the correct value: false on catch-up, true when a new timer arms.
return () => clearTimeout(timer);
}, [q, urlQ, pathname, router]);

Expand Down
Loading