diff --git a/AGENTS.md b/AGENTS.md
index f4e5436f..52651bb0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,6 +16,10 @@
- UI/router/search-state: targeted tests + `npm run lint`.
- Larger changes: `npm run test:once` + `npm run build`.
- Go changes: `make lint`.
+- User-facing changes (features, bug fixes, behavior changes): add an
+ `Unreleased` changelog entry in `CHANGELOG.md` under the correct section.
+- Changelog entries for user-facing changes should include a PR reference
+ link using project convention: `[PR #XXX](https://github.com/riverqueue/riverui/pull/XXX)`.
## Commits
- Title <= 50 chars (max 72); wrap body ~72.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 32c4e67d..36cf7e0f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,8 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
-- Prevent double slash in URLs for root path prefix. Thanks [Jan Kott](https://github.com/boostvolt)! [PR #487](https://github.com/riverqueue/riverui/pull/487)
+- Prevent double slash in URLs for root path prefix. Thanks [Jan Kott](https://github.com/boostvolt)! [PR #487](https://github.com/riverqueue/riverui/pull/487).
- Serve UI HTML for wildcard or missing Accept headers and return 406 for explicit non-HTML requests. Fixes #485. [PR #493](https://github.com/riverqueue/riverui/pull/493).
+- Prevent jobs detail navigation from bouncing back to `/jobs/` on slow networks due to stale filter-sync URL updates. Fixes #495. [PR #504](https://github.com/riverqueue/riverui/pull/504).
## [v0.14.0] - 2026-01-02
diff --git a/src/components/job-search/JobSearch.test.tsx b/src/components/job-search/JobSearch.test.tsx
index 6e85984f..935ff49e 100644
--- a/src/components/job-search/JobSearch.test.tsx
+++ b/src/components/job-search/JobSearch.test.tsx
@@ -135,6 +135,26 @@ describe("JobSearch", () => {
}),
]);
});
+
+ it("does not notify parent on unrelated rerenders", async () => {
+ const onFiltersChange = vi.fn();
+ const { rerender } = render(
+ ,
+ );
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ });
+ onFiltersChange.mockClear();
+
+ rerender();
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ });
+
+ expect(onFiltersChange).not.toHaveBeenCalled();
+ });
});
describe("Suggestion System", () => {
diff --git a/src/components/job-search/hooks.ts b/src/components/job-search/hooks.ts
index 277ef3e7..c35a4484 100644
--- a/src/components/job-search/hooks.ts
+++ b/src/components/job-search/hooks.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
analyzeAutocompleteContext,
@@ -50,7 +50,10 @@ export function useFilterInput({
} | null>(null);
const debounceTimeoutRef = useRef(undefined);
- const currentFilters = parseFiltersFromText(inputValue);
+ const currentFilters = useMemo(
+ () => parseFiltersFromText(inputValue),
+ [inputValue],
+ );
const {
clearSuggestions,
diff --git a/src/routes/jobs/index.tsx b/src/routes/jobs/index.tsx
index b1433e06..b2b8bc34 100644
--- a/src/routes/jobs/index.tsx
+++ b/src/routes/jobs/index.tsx
@@ -32,6 +32,14 @@ const minimumLimit = 20;
const defaultLimit = 20;
const maximumLimit = 200;
+const areStringArraysEqual = (a?: string[], b?: string[]) => {
+ if (a === b) return true;
+ if (!a && !b) return true;
+ if (!a || !b) return false;
+ if (a.length !== b.length) return false;
+ return a.every((value, index) => value === b[index]);
+};
+
export const Route = createFileRoute("/jobs/")({
validateSearch: jobSearchSchema,
// Strip default values from URLs and retain important params across navigation
@@ -165,6 +173,26 @@ function JobsIndexComponent() {
}
});
+ const currentSearchParams = {
+ id: id?.map(String),
+ kind,
+ priority: priority?.map(String),
+ queue,
+ };
+
+ // Avoid no-op navigations that can race with route transitions.
+ if (
+ areStringArraysEqual(currentSearchParams.id, searchParams.id) &&
+ areStringArraysEqual(currentSearchParams.kind, searchParams.kind) &&
+ areStringArraysEqual(
+ currentSearchParams.priority,
+ searchParams.priority,
+ ) &&
+ areStringArraysEqual(currentSearchParams.queue, searchParams.queue)
+ ) {
+ return;
+ }
+
// Update route search params, preserving other existing ones
navigate({
replace: true,
@@ -179,7 +207,7 @@ function JobsIndexComponent() {
},
});
},
- [navigate],
+ [id, kind, navigate, priority, queue],
);
// Convert current search params to initial filters