diff --git a/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.test.tsx b/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.test.tsx
index 7aaaa663..5d504c6d 100644
--- a/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.test.tsx
+++ b/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.test.tsx
@@ -15,7 +15,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
-import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { JavaInstrumentationListPage } from "./java-instrumentation-list-page";
import type { InstrumentationData } from "@/types/javaagent";
@@ -31,6 +31,11 @@ vi.mock("@/components/ui/back-button", () => ({
import { useVersions, useInstrumentations } from "@/hooks/use-javaagent-data";
+function LocationDisplay() {
+ const location = useLocation();
+ return
{location.pathname + location.search}
;
+}
+
function renderPage(initialPath = "/java-agent/instrumentation/2.0.0") {
return render(
@@ -41,94 +46,185 @@ function renderPage(initialPath = "/java-agent/instrumentation/2.0.0") {
element={}
/>
+
);
}
-describe("JavaInstrumentationListPage - Filtering", () => {
- const mockInstrumentations: InstrumentationData[] = [
- {
- name: "http-client",
- display_name: "HTTP Client",
- description: "Instrumentation for HTTP clients",
- scope: { name: "http" },
- has_javaagent: true,
- javaagent_target_versions: ["1.0.0"],
- telemetry: [{ when: "always", spans: [{ span_kind: "CLIENT" }] }],
- semantic_conventions: ["http"],
- features: ["stable"],
- },
- {
- name: "jdbc",
- display_name: "JDBC",
- description: "Database instrumentation for JDBC",
- scope: { name: "jdbc" },
- has_standalone_library: true,
- telemetry: [
- {
- when: "always",
- metrics: [
- {
- name: "db.connections",
- description: "DB connections",
- instrument: "counter",
- data_type: "LONG_SUM",
- unit: "1",
- },
- ],
- },
- ],
- semantic_conventions: ["db"],
- },
- {
- name: "kafka-client",
- display_name: "Kafka Client",
- description: "Messaging instrumentation for Kafka",
- scope: { name: "kafka" },
- has_javaagent: true,
- javaagent_target_versions: ["1.0.0"],
- has_standalone_library: true,
- telemetry: [
- {
- when: "always",
- spans: [{ span_kind: "PRODUCER" }],
- metrics: [
- {
- name: "kafka.messages",
- description: "Messages sent",
- data_type: "COUNTER",
- instrument: "counter",
- unit: "1",
- },
- ],
- },
- ],
- semantic_conventions: ["messaging"],
- features: ["stable"],
- },
- {
- name: "spring-web",
- display_name: "Spring Web",
- description: "Instrumentation for Spring Web applications",
- scope: { name: "spring" },
- has_javaagent: true,
- javaagent_target_versions: ["1.0.0"],
- features: ["experimental"],
- },
- ];
-
- beforeEach(() => {
- vi.mocked(useVersions).mockReturnValue({
- data: {
- versions: [
- { version: "2.0.0", is_latest: true },
- { version: "1.9.0", is_latest: false },
+const mockVersions = {
+ data: {
+ versions: [
+ { version: "2.0.0", is_latest: true },
+ { version: "1.9.0", is_latest: false },
+ ],
+ },
+ loading: false,
+ error: null,
+};
+
+const mockInstrumentations: InstrumentationData[] = [
+ {
+ name: "http-client",
+ display_name: "HTTP Client",
+ description: "Instrumentation for HTTP clients",
+ scope: { name: "http" },
+ has_javaagent: true,
+ javaagent_target_versions: ["1.0.0"],
+ telemetry: [{ when: "always", spans: [{ span_kind: "CLIENT" }] }],
+ semantic_conventions: ["http"],
+ features: ["stable"],
+ },
+ {
+ name: "jdbc",
+ display_name: "JDBC",
+ description: "Database instrumentation for JDBC",
+ scope: { name: "jdbc" },
+ has_standalone_library: true,
+ telemetry: [
+ {
+ when: "always",
+ metrics: [
+ {
+ name: "db.connections",
+ description: "DB connections",
+ instrument: "counter",
+ data_type: "LONG_SUM",
+ unit: "1",
+ },
+ ],
+ },
+ ],
+ semantic_conventions: ["db"],
+ },
+ {
+ name: "kafka-client",
+ display_name: "Kafka Client",
+ description: "Messaging instrumentation for Kafka",
+ scope: { name: "kafka" },
+ has_javaagent: true,
+ javaagent_target_versions: ["1.0.0"],
+ has_standalone_library: true,
+ telemetry: [
+ {
+ when: "always",
+ spans: [{ span_kind: "PRODUCER" }],
+ metrics: [
+ {
+ name: "kafka.messages",
+ description: "Messages sent",
+ data_type: "COUNTER",
+ instrument: "counter",
+ unit: "1",
+ },
],
},
+ ],
+ semantic_conventions: ["messaging"],
+ features: ["stable"],
+ },
+ {
+ name: "spring-web",
+ display_name: "Spring Web",
+ description: "Instrumentation for Spring Web applications",
+ scope: { name: "spring" },
+ has_javaagent: true,
+ javaagent_target_versions: ["1.0.0"],
+ features: ["experimental"],
+ },
+];
+
+describe("JavaInstrumentationListPage - URL Persistence", () => {
+ beforeEach(() => {
+ vi.mocked(useVersions).mockReturnValue(mockVersions);
+ vi.mocked(useInstrumentations).mockReturnValue({
+ data: mockInstrumentations,
loading: false,
error: null,
});
+ });
+
+ it("reads search query from URL on mount", async () => {
+ renderPage("/java-agent/instrumentation/2.0.0?search=kafka");
+
+ await waitFor(() => {
+ expect(screen.getByText("Kafka Client")).toBeInTheDocument();
+ expect(screen.queryByText("HTTP Client")).not.toBeInTheDocument();
+ });
+
+ const searchInput = screen.getByPlaceholderText("Search instrumentations...");
+ expect(searchInput).toHaveValue("kafka");
+ });
+
+ it("writes search query to URL when user types", async () => {
+ const user = userEvent.setup();
+ renderPage();
+
+ const searchInput = await screen.findByPlaceholderText("Search instrumentations...");
+ await user.type(searchInput, "http");
+
+ await waitFor(() => {
+ expect(screen.getByTestId("location").textContent).toContain("search=http");
+ });
+ });
+
+ it("reads telemetry filter from URL on mount", async () => {
+ renderPage("/java-agent/instrumentation/2.0.0?telemetry=spans");
+
+ await waitFor(() => {
+ expect(screen.getByText("HTTP Client")).toBeInTheDocument();
+ expect(screen.getByText("Kafka Client")).toBeInTheDocument();
+ expect(screen.queryByText("JDBC")).not.toBeInTheDocument();
+ });
+ });
+
+ it("writes telemetry filter to URL when toggled", async () => {
+ const user = userEvent.setup();
+ renderPage();
+
+ const spansButton = await screen.findByRole("button", { name: "Spans" });
+ await user.click(spansButton);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("location").textContent).toContain("telemetry=spans");
+ });
+ });
+
+ it("reads type filter from URL on mount", async () => {
+ renderPage("/java-agent/instrumentation/2.0.0?type=javaagent");
+
+ await waitFor(() => {
+ expect(screen.getByText("HTTP Client")).toBeInTheDocument();
+ expect(screen.queryByText("JDBC")).not.toBeInTheDocument();
+ });
+ });
+ it("writes type filter to URL when toggled", async () => {
+ const user = userEvent.setup();
+ renderPage();
+
+ const javaAgentButton = await screen.findByRole("button", { name: "Java Agent" });
+ await user.click(javaAgentButton);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("location").textContent).toContain("type=javaagent");
+ });
+ });
+
+ it("preserves existing search params when redirecting no-version to latest", async () => {
+ renderPage("/java-agent/instrumentation/latest?search=kafka&telemetry=spans");
+
+ await waitFor(() => {
+ const loc = screen.getByTestId("location").textContent ?? "";
+ expect(loc).toContain("2.0.0");
+ expect(loc).toContain("search=kafka");
+ expect(loc).toContain("telemetry=spans");
+ });
+ });
+});
+
+describe("JavaInstrumentationListPage - Filtering", () => {
+ beforeEach(() => {
+ vi.mocked(useVersions).mockReturnValue(mockVersions);
vi.mocked(useInstrumentations).mockReturnValue({
data: mockInstrumentations,
loading: false,
diff --git a/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.tsx b/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.tsx
index 9e4e72e2..57337e43 100644
--- a/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.tsx
+++ b/ecosystem-explorer/src/features/java-agent/java-instrumentation-list-page.tsx
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { AlertCircle, Loader2 } from "lucide-react";
+import { AlertCircle, Loader2, X } from "lucide-react";
import { BackButton } from "@/components/ui/back-button";
import { useVersions, useInstrumentations } from "@/hooks/use-javaagent-data";
import {
@@ -21,8 +21,7 @@ import {
InstrumentationFilterBar,
} from "@/features/java-agent/components/instrumentation-filter-bar.tsx";
import { useMemo, useState, useEffect } from "react";
-import { useParams, useNavigate, useSearchParams } from "react-router-dom";
-import { X } from "lucide-react";
+import { useParams, useNavigate, useSearchParams, useLocation } from "react-router-dom";
import { InstrumentationGroupCard } from "@/features/java-agent/components/instrumentation-group-card.tsx";
import { VersionSelector } from "@/features/java-agent/components/version-selector";
import { getInstrumentationDisplayName } from "./utils/format";
@@ -32,12 +31,13 @@ import { PageContainer } from "@/components/layout/page-container";
export function JavaInstrumentationListPage() {
const { version: versionParam } = useParams<{ version?: string }>();
const navigate = useNavigate();
+ const location = useLocation();
const { data: versionsData, loading: versionsLoading, error: versionsError } = useVersions();
const latestVersion = versionsData?.versions.find((v) => v.is_latest)?.version ?? "";
- const [searchParams] = useSearchParams();
+ const [searchParams, setSearchParams] = useSearchParams();
const invalidVersion = searchParams.get("redirectedFrom");
const [bannerDismissed, setBannerDismissed] = useState(false);
@@ -46,19 +46,21 @@ export function JavaInstrumentationListPage() {
versionParam === "latest" ||
(!!versionsData && versionsData.versions.some((v) => v.version === versionParam));
- // Redirect /java-agent/instrumentation (no version) or /latest to the actual latest version
- // Also redirect invalid versions to latest and show a dismissible inline alert
+ // Redirect: no-version/latest → latest (preserving query params so shared filter URLs survive).
+ // Invalid version → latest with redirectedFrom banner.
useEffect(() => {
if (versionsData && latestVersion) {
if (!versionParam || versionParam === "latest") {
- navigate(`/java-agent/instrumentation/${latestVersion}`, { replace: true });
+ navigate(`/java-agent/instrumentation/${latestVersion}${location.search}`, {
+ replace: true,
+ });
} else if (!isVersionValid) {
navigate(`/java-agent/instrumentation/${latestVersion}?redirectedFrom=${versionParam}`, {
replace: true,
});
}
}
- }, [versionParam, versionsData, latestVersion, navigate, isVersionValid]);
+ }, [versionParam, versionsData, latestVersion, navigate, isVersionValid, location.search]);
const resolvedVersion = versionParam && versionParam !== "latest" ? versionParam : "";
@@ -68,13 +70,95 @@ export function JavaInstrumentationListPage() {
error,
} = useInstrumentations(resolvedVersion);
- const [filters, setFilters] = useState({
- search: "",
- telemetry: new Set(),
- target: new Set(),
- semantic: [],
- features: [],
- });
+ const urlSearch = searchParams.get("search") ?? "";
+
+ // Local state for the search input — updates immediately on every keystroke,
+ // avoiding the URL round-trip lag that causes flicker on CTRL+A + type.
+ const [localSearch, setLocalSearch] = useState(urlSearch);
+
+ // Detect external URL changes (back/forward) and sync the input using
+ // React's documented derived state during render pattern.
+ const [syncedUrlSearch, setSyncedUrlSearch] = useState(urlSearch);
+ if (syncedUrlSearch !== urlSearch) {
+ setSyncedUrlSearch(urlSearch);
+ setLocalSearch(urlSearch);
+ }
+
+ const filters: FilterState = useMemo(() => {
+ const telemetryParam = searchParams.get("telemetry");
+ const typeParam = searchParams.get("type");
+ const semanticParam = searchParams.get("semantic");
+ const featuresParam = searchParams.get("features");
+
+ const telemetry = new Set<"spans" | "metrics">();
+ if (telemetryParam) {
+ telemetryParam.split(",").forEach((v) => {
+ if (v === "spans" || v === "metrics") telemetry.add(v);
+ });
+ }
+
+ const target = new Set<"javaagent" | "library">();
+ if (typeParam) {
+ typeParam.split(",").forEach((v) => {
+ if (v === "javaagent" || v === "library") target.add(v);
+ });
+ }
+
+ const semantic = semanticParam ? semanticParam.split(",").filter(Boolean) : [];
+ const features = featuresParam ? featuresParam.split(",").filter(Boolean) : [];
+
+ return { search: localSearch, telemetry, target, semantic, features };
+ }, [searchParams, localSearch]);
+
+ const handleFiltersChange = (newFilters: FilterState) => {
+ // Update local search state immediately so the input never lags behind keystrokes.
+ setLocalSearch(newFilters.search);
+
+ // Use replace for high-frequency search typing to avoid polluting browser history.
+ // Use push (replace: false) for discrete toggle changes so back button works.
+ const setsUnchanged =
+ newFilters.telemetry.size === filters.telemetry.size &&
+ newFilters.target.size === filters.target.size &&
+ newFilters.semantic.length === filters.semantic.length &&
+ newFilters.features.length === filters.features.length &&
+ [...newFilters.telemetry].every((v) => filters.telemetry.has(v)) &&
+ [...newFilters.target].every((v) => filters.target.has(v)) &&
+ newFilters.semantic.every((v, i) => filters.semantic[i] === v) &&
+ newFilters.features.every((v, i) => filters.features[i] === v);
+
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ if (newFilters.search) {
+ next.set("search", newFilters.search);
+ } else {
+ next.delete("search");
+ }
+ if (newFilters.telemetry.size > 0) {
+ next.set("telemetry", [...newFilters.telemetry].join(","));
+ } else {
+ next.delete("telemetry");
+ }
+ if (newFilters.target.size > 0) {
+ next.set("type", [...newFilters.target].join(","));
+ } else {
+ next.delete("type");
+ }
+ if (newFilters.semantic.length > 0) {
+ next.set("semantic", newFilters.semantic.join(","));
+ } else {
+ next.delete("semantic");
+ }
+ if (newFilters.features.length > 0) {
+ next.set("features", newFilters.features.join(","));
+ } else {
+ next.delete("features");
+ }
+ return next;
+ },
+ { replace: setsUnchanged }
+ );
+ };
const filteredInstrumentations = useMemo(() => {
if (!instrumentations) return [];
@@ -125,9 +209,7 @@ export function JavaInstrumentationListPage() {
}
if (filters.features.length > 0) {
- const hasMatch = filters.features.some((f) => {
- return instr.features?.includes(f);
- });
+ const hasMatch = filters.features.some((f) => instr.features?.includes(f));
if (!hasMatch) return false;
}
@@ -153,7 +235,10 @@ export function JavaInstrumentationListPage() {
);
const handleVersionChange = (newVersion: string) => {
- navigate(`/java-agent/instrumentation/${newVersion}`);
+ const params = new URLSearchParams(location.search);
+ params.delete("redirectedFrom");
+ const query = params.toString();
+ navigate(`/java-agent/instrumentation/${newVersion}${query ? `?${query}` : ""}`);
};
return (
@@ -201,7 +286,7 @@ export function JavaInstrumentationListPage() {