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