diff --git a/src/app/dashboard/error.test.tsx b/src/app/dashboard/error.test.tsx
new file mode 100644
index 0000000..2dd4533
--- /dev/null
+++ b/src/app/dashboard/error.test.tsx
@@ -0,0 +1,50 @@
+/**
+ * Tests for the dashboard route error boundary.
+ *
+ * Verifies that:
+ * - The boundary renders the RouteError fallback with the correct title.
+ * - Clicking "Try again" fires the Next.js `reset` callback (not a full
+ * page reload via window.location.reload).
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import DashboardError from "./error";
+
+// Stub SiteHeader so we don't need a WalletProvider in boundary tests.
+vi.mock("@/components/SiteHeader", () => ({
+ SiteHeader: () => ,
+}));
+
+describe("DashboardError boundary", () => {
+ let reloadMock: ReturnType;
+
+ beforeEach(() => {
+ reloadMock = vi.fn();
+ vi.stubGlobal("location", { ...window.location, reload: reloadMock });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("renders the 'Could not load the dashboard' title", () => {
+ render( {}} />);
+ expect(
+ screen.getByText("Could not load the dashboard"),
+ ).toBeInTheDocument();
+ });
+
+ it("calls the reset callback when 'Try again' is clicked", () => {
+ const reset = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
+ expect(reset).toHaveBeenCalledTimes(1);
+ });
+
+ it("does NOT call window.location.reload when 'Try again' is clicked", () => {
+ const reset = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
+ expect(reloadMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/SettlementsPanel.test.tsx b/src/components/SettlementsPanel.test.tsx
index cdbc076..3a78262 100644
--- a/src/components/SettlementsPanel.test.tsx
+++ b/src/components/SettlementsPanel.test.tsx
@@ -5,6 +5,7 @@ import {
fireEvent,
waitFor,
within,
+ act,
} from "@testing-library/react";
import { SettlementsPanel } from "./SettlementsPanel";
import { ToastProvider } from "./ToastProvider";
@@ -115,8 +116,55 @@ describe("SettlementsPanel", () => {
target: { value: "anchorA" },
});
+ // Filtering is debounced, so the non-matching row only drops out once the
+ // debounce delay has elapsed.
+ await waitFor(() =>
+ expect(screen.queryByText("other")).not.toBeInTheDocument(),
+ );
expect(screen.getByText("anchorA")).toBeInTheDocument();
- expect(screen.queryByText("other")).not.toBeInTheDocument();
+ });
+
+ it("debounces the search filter, updating the list only after the delay", async () => {
+ vi.useFakeTimers();
+ try {
+ vi.mocked(fetchSettlements).mockResolvedValue(
+ page([sample, { ...sample, id: 2, anchor: "other" }]),
+ );
+
+ renderPanel();
+
+ // Flush the mocked fetch promise and mount effects so the list renders.
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ expect(screen.getByText("anchorA")).toBeInTheDocument();
+ expect(screen.getByText("other")).toBeInTheDocument();
+
+ fireEvent.change(screen.getByLabelText("Search settlements"), {
+ target: { value: "anchorA" },
+ });
+
+ // The input reflects the keystroke immediately (no typing lag)...
+ expect(screen.getByLabelText("Search settlements")).toHaveValue("anchorA");
+ // ...but the filtered list has not been recomputed yet.
+ expect(screen.getByText("other")).toBeInTheDocument();
+
+ // Just before the debounce elapses, the list is still unchanged.
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(199);
+ });
+ expect(screen.getByText("other")).toBeInTheDocument();
+
+ // Once the debounce delay elapses, the non-matching row is filtered out.
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1);
+ });
+ expect(screen.queryByText("other")).not.toBeInTheDocument();
+ expect(screen.getByText("anchorA")).toBeInTheDocument();
+ } finally {
+ vi.useRealTimers();
+ }
});
it("shows the no-data empty state without a clear-filters action", async () => {
@@ -142,14 +190,19 @@ describe("SettlementsPanel", () => {
target: { value: "zzz" },
});
- expect(
- screen.getByText("No settlements match your search."),
- ).toBeInTheDocument();
+ // The no-results state appears only after the debounce delay elapses.
+ await waitFor(() =>
+ expect(
+ screen.getByText("No settlements match your search."),
+ ).toBeInTheDocument(),
+ );
expect(screen.queryByText("No settlements yet.")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
- expect(screen.getByText("anchorA")).toBeInTheDocument();
+ await waitFor(() =>
+ expect(screen.getByText("anchorA")).toBeInTheDocument(),
+ );
expect(screen.getByLabelText("Search settlements")).toHaveValue("");
});
diff --git a/src/components/SettlementsPanel.tsx b/src/components/SettlementsPanel.tsx
index 4487aec..94bed15 100644
--- a/src/components/SettlementsPanel.tsx
+++ b/src/components/SettlementsPanel.tsx
@@ -12,6 +12,7 @@ import { Settlement, Pagination, Pool } from "@/lib/types";
import { fetchPools } from "@/lib/api";
import { pluralize } from "@/lib/format";
import { matchesQuery } from "@/lib/search";
+import { useDebouncedValue } from "@/hooks/useDebouncedValue";
import { useToast } from "@/hooks/useToast";
import { useFocusShortcut } from "@/hooks/useFocusShortcut";
import { useQueryState } from "@/hooks/useQueryState";
@@ -22,6 +23,12 @@ import { SettlementTable } from "./SettlementTable";
import { ConfirmDialog } from "./ConfirmDialog";
import { EmptyState } from "./EmptyState";
+/**
+ * Delay (ms) before a paused search query is applied to the filtered list.
+ * The input itself stays bound to the immediate value, so typing never lags.
+ */
+const SEARCH_DEBOUNCE_MS = 200;
+
/** Selectable page sizes for the settlements list; the first is the default. */
const PAGE_SIZE_OPTIONS = [10, 25, 50];
@@ -215,10 +222,14 @@ export function SettlementsPanel() {
}
}
+ // Debounce only the value that drives filtering so large settlement lists
+ // aren't re-filtered on every keystroke; the input stays bound to `query`.
+ const debouncedQuery = useDebouncedValue(query, SEARCH_DEBOUNCE_MS);
+
const visibleSettlements =
state.status === "ready"
? state.settlements.filter((s) =>
- matchesQuery([s.id, s.anchor, s.asset], query),
+ matchesQuery([s.id, s.anchor, s.asset], debouncedQuery),
)
: [];