From 9d14462fad245d4e59821e3706631457494b86e1 Mon Sep 17 00:00:00 2001
From: Okorie Chigozie Jehoshaphat
Date: Sun, 19 Jul 2026 19:09:37 +0100
Subject: [PATCH 1/6] Sync anchors/settlements filter and search state to the
URL querystring
---
package-lock.json | 34 +++---
src/app/anchors/page.tsx | 8 +-
src/app/settlements/page.tsx | 8 +-
src/components/AnchorsPanel.test.tsx | 121 +++++++++++++++++++++
src/components/AnchorsPanel.tsx | 18 +++-
src/components/SettlementsPanel.test.tsx | 110 +++++++++++++++++++
src/components/SettlementsPanel.tsx | 24 ++++-
src/hooks/useQueryState.test.ts | 128 +++++++++++++++++++++++
src/hooks/useQueryState.ts | 63 +++++++++++
9 files changed, 485 insertions(+), 29 deletions(-)
create mode 100644 src/hooks/useQueryState.test.ts
create mode 100644 src/hooks/useQueryState.ts
diff --git a/package-lock.json b/package-lock.json
index 1348ce4..5747d5e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -129,7 +129,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -450,7 +449,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -499,7 +497,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -2506,6 +2503,7 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -2526,6 +2524,7 @@
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"dequal": "^2.0.3"
}
@@ -2601,7 +2600,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@types/estree": {
"version": "1.0.9",
@@ -2630,7 +2630,6 @@
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -2641,7 +2640,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2652,7 +2650,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -2702,7 +2699,6 @@
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.57.0",
"@typescript-eslint/types": "8.57.0",
@@ -3341,7 +3337,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3382,6 +3377,7 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=8"
}
@@ -3715,7 +3711,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4106,6 +4101,7 @@
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=6"
}
@@ -4138,7 +4134,8 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/dunder-proto": {
"version": "1.0.1",
@@ -4449,7 +4446,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4635,7 +4631,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -5921,7 +5916,6 @@
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
@@ -6394,6 +6388,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -6972,6 +6967,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -6987,6 +6983,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=10"
},
@@ -6999,7 +6996,8 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/prop-types": {
"version": "15.8.1",
@@ -7049,7 +7047,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7059,7 +7056,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -7885,7 +7881,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -8124,7 +8119,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -8275,7 +8269,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -8642,7 +8635,6 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/src/app/anchors/page.tsx b/src/app/anchors/page.tsx
index ea9edcc..2f2f89e 100644
--- a/src/app/anchors/page.tsx
+++ b/src/app/anchors/page.tsx
@@ -1,6 +1,8 @@
+import { Suspense } from "react";
import type { Metadata } from "next";
import { SiteHeader } from "@/components/SiteHeader";
import { AnchorsPanel } from "@/components/AnchorsPanel";
+import { TableSkeleton } from "@/components/TableSkeleton";
export const metadata: Metadata = {
title: "Anchors – AnchorNet",
@@ -17,7 +19,11 @@ export default function AnchorsPage() {
Approved anchors that provide liquidity to the network.
-
+ {/* Suspense is required by Next.js App Router when a child client
+ component reads useSearchParams() at render time. */}
+
}>
+
+
diff --git a/src/app/settlements/page.tsx b/src/app/settlements/page.tsx
index a6cf918..7dc3913 100644
--- a/src/app/settlements/page.tsx
+++ b/src/app/settlements/page.tsx
@@ -1,6 +1,8 @@
+import { Suspense } from "react";
import type { Metadata } from "next";
import { SiteHeader } from "@/components/SiteHeader";
import { SettlementsPanel } from "@/components/SettlementsPanel";
+import { TableSkeleton } from "@/components/TableSkeleton";
export const metadata: Metadata = {
title: "Settlements – AnchorNet",
@@ -19,7 +21,11 @@ export default function SettlementsPage() {
Reserve pool liquidity to settle cross-anchor payments.
-
+ {/* Suspense is required by Next.js App Router when a child client
+ component reads useSearchParams() at render time. */}
+ }>
+
+
diff --git a/src/components/AnchorsPanel.test.tsx b/src/components/AnchorsPanel.test.tsx
index bbc0a5c..b1cf050 100644
--- a/src/components/AnchorsPanel.test.tsx
+++ b/src/components/AnchorsPanel.test.tsx
@@ -14,6 +14,19 @@ import {
deregisterAnchor,
} from "@/lib/anchorsApi";
+// ---------------------------------------------------------------------------
+// Mock next/navigation so the panel can run in jsdom.
+// ---------------------------------------------------------------------------
+
+const mockReplace = vi.fn();
+let mockSearchParamsString = "";
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ replace: mockReplace }),
+ useSearchParams: () => new URLSearchParams(mockSearchParamsString),
+ usePathname: () => "/anchors",
+}));
+
vi.mock("@/lib/anchorsApi", () => ({
fetchAnchors: vi.fn(),
registerAnchor: vi.fn(),
@@ -22,6 +35,7 @@ vi.mock("@/lib/anchorsApi", () => ({
beforeEach(() => {
vi.clearAllMocks();
+ mockSearchParamsString = "";
});
function renderPanel() {
@@ -140,4 +154,111 @@ describe("AnchorsPanel", () => {
fireEvent.click(within(dialog).getByRole("button", { name: "Deactivate" }));
await waitFor(() => expect(deregisterAnchor).toHaveBeenCalledWith("a"));
});
+
+ // -------------------------------------------------------------------------
+ // URL querystring hydration tests
+ // -------------------------------------------------------------------------
+
+ it("hydrates the status filter from the URL querystring on load", async () => {
+ mockSearchParamsString = "status=active";
+ vi.mocked(fetchAnchors).mockResolvedValue([
+ { id: "a", name: "Anchor A", registeredAt: "", active: true },
+ { id: "b", name: "Anchor B", registeredAt: "", active: false },
+ ]);
+
+ renderPanel();
+ await screen.findByText("Anchor A");
+
+ // Only the active anchor should be visible; the Active tab should be pressed
+ expect(screen.getByText("Anchor A")).toBeInTheDocument();
+ expect(screen.queryByText("Anchor B")).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Active" }),
+ ).toHaveAttribute("aria-pressed", "true");
+ });
+
+ it("hydrates the search query from the URL querystring on load", async () => {
+ mockSearchParamsString = "q=vault";
+ vi.mocked(fetchAnchors).mockResolvedValue([
+ { id: "stellar-anchor", name: "Stellar Vault", registeredAt: "", active: true },
+ { id: "other", name: "Something Else", registeredAt: "", active: true },
+ ]);
+
+ renderPanel();
+ await screen.findByText("Stellar Vault");
+
+ // Only matching anchor visible; search box pre-filled
+ expect(screen.getByText("Stellar Vault")).toBeInTheDocument();
+ expect(screen.queryByText("Something Else")).not.toBeInTheDocument();
+ expect(screen.getByLabelText("Search anchors")).toHaveValue("vault");
+ });
+
+ it("updates the URL querystring when the status filter changes", async () => {
+ mockSearchParamsString = "";
+ vi.mocked(fetchAnchors).mockResolvedValue([
+ { id: "a", name: "Anchor A", registeredAt: "", active: true },
+ ]);
+
+ renderPanel();
+ await screen.findByText("Anchor A");
+
+ fireEvent.click(screen.getByRole("button", { name: "Active" }));
+
+ expect(mockReplace).toHaveBeenCalledWith(
+ expect.stringContaining("status=active"),
+ { scroll: false },
+ );
+ });
+
+ it("updates the URL querystring when the search query changes", async () => {
+ mockSearchParamsString = "";
+ vi.mocked(fetchAnchors).mockResolvedValue([
+ { id: "a", name: "Anchor A", registeredAt: "", active: true },
+ ]);
+
+ renderPanel();
+ await screen.findByText("Anchor A");
+
+ fireEvent.change(screen.getByLabelText("Search anchors"), {
+ target: { value: "foo" },
+ });
+
+ expect(mockReplace).toHaveBeenCalledWith(
+ expect.stringContaining("q=foo"),
+ { scroll: false },
+ );
+ });
+
+ it("removes the status param from the URL when All is selected", async () => {
+ mockSearchParamsString = "status=active";
+ vi.mocked(fetchAnchors).mockResolvedValue([
+ { id: "a", name: "Anchor A", registeredAt: "", active: true },
+ ]);
+
+ renderPanel();
+ await screen.findByText("Anchor A");
+
+ fireEvent.click(screen.getByRole("button", { name: "All" }));
+
+ // 'all' is the default so the param should be stripped
+ expect(mockReplace).toHaveBeenCalledWith("/anchors", { scroll: false });
+ });
+
+ it("ignores an unrecognised status param and falls back to 'all'", async () => {
+ mockSearchParamsString = "status=unknown";
+ vi.mocked(fetchAnchors).mockResolvedValue([
+ { id: "a", name: "Anchor A", registeredAt: "", active: true },
+ { id: "b", name: "Anchor B", registeredAt: "", active: false },
+ ]);
+
+ renderPanel();
+ await screen.findByText("Anchor A");
+
+ // Both anchors visible because filter falls back to 'all'
+ expect(screen.getByText("Anchor A")).toBeInTheDocument();
+ expect(screen.getByText("Anchor B")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "All" }),
+ ).toHaveAttribute("aria-pressed", "true");
+ });
});
diff --git a/src/components/AnchorsPanel.tsx b/src/components/AnchorsPanel.tsx
index f9f9446..c6dda03 100644
--- a/src/components/AnchorsPanel.tsx
+++ b/src/components/AnchorsPanel.tsx
@@ -11,6 +11,7 @@ import { matchesQuery } from "@/lib/search";
import { useAsync } from "@/hooks/useAsync";
import { useToast } from "@/hooks/useToast";
import { useFocusShortcut } from "@/hooks/useFocusShortcut";
+import { useQueryState } from "@/hooks/useQueryState";
import { Card } from "./Card";
import { TableSkeleton } from "./TableSkeleton";
import { AnchorForm } from "./AnchorForm";
@@ -25,6 +26,11 @@ const FILTERS: { value: StatusFilter; label: string }[] = [
{ value: "inactive", label: "Inactive" },
];
+/** Returns true if `value` is a valid {@link StatusFilter}. */
+function isStatusFilter(value: string): value is StatusFilter {
+ return value === "all" || value === "active" || value === "inactive";
+}
+
/** Filters anchors by lifecycle status for the client-side status tabs. */
function filterAnchors(anchors: Anchor[], filter: StatusFilter): Anchor[] {
return anchors.filter((anchor) => {
@@ -40,13 +46,19 @@ export function AnchorsPanel() {
const { state, reload } = useAsync(load);
const { notify } = useToast();
const [pending, setPending] = useState(false);
- const [filter, setFilter] = useState("all");
- const [query, setQuery] = useState("");
const [pendingDeregisterId, setPendingDeregisterId] = useState<
string | null
>(null);
const searchRef = useRef(null);
useFocusShortcut("/", searchRef);
+
+ // Sync status filter and search query to the URL querystring.
+ // Initial values are hydrated from the URL on first render.
+ const [rawStatus, setStatus] = useQueryState("status", "all");
+ const filter: StatusFilter = isStatusFilter(rawStatus) ? rawStatus : "all";
+
+ const [query, setQuery] = useQueryState("q", "");
+
const filteredAnchors =
state.status === "ready"
? filterAnchors(state.data, filter).filter((anchor) =>
@@ -97,7 +109,7 @@ export function AnchorsPanel() {
{FILTERS.map((f) => (