From 9fc01779af3bfcd58b2531b73c9828220ee54903 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Thu, 5 Feb 2026 17:08:22 -0600 Subject: [PATCH] Fix jobs detail navigation race On slower connections, opening a job from a filtered jobs list could bounce back to /jobs/. JobSearch was re-emitting debounced filter updates on unrelated rerenders, and the jobs route treated those callbacks as replace navigations even when search params were unchanged. That could override an in-flight transition to /jobs/$jobId. The filter parser result is now memoized in useFilterInput so onFiltersChange is not retriggered when input text has not changed. The jobs route now compares derived filter search params against current params and skips navigation when the update is a no-op. A regression test was added to JobSearch to ensure an unrelated rerender does not notify the parent of filter changes. Closes #495. --- AGENTS.md | 4 +++ CHANGELOG.md | 3 +- src/components/job-search/JobSearch.test.tsx | 20 +++++++++++++ src/components/job-search/hooks.ts | 7 +++-- src/routes/jobs/index.tsx | 30 +++++++++++++++++++- 5 files changed, 60 insertions(+), 4 deletions(-) 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