From 44be8add7d19611f9eb6b168bad4567014953295 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Mon, 1 Jun 2026 21:09:30 -0500 Subject: [PATCH] honor pause for query refresh Paused live updates stopped scheduled polling, but React Query still used its default focus and reconnect refetch behavior. On workflow detail pages, that could make wait data refresh when the browser regained focus even though the page-wide interval was paused. Centralize the query refresh options derived from the global interval and use them anywhere route queries already honor that setting. A paused interval now disables scheduled, focus, and reconnect refetches, while a nonzero interval preserves the existing automatic refresh behavior. Add focused coverage for the paused and enabled focus-refresh cases so the pause semantics stay tied to data fetching rather than display-only timer updates. --- CHANGELOG.md | 4 ++ src/contexts/RefreshSettings.query.test.tsx | 80 +++++++++++++++++++++ src/contexts/RefreshSettings.query.ts | 17 +++++ src/routes/jobs/$jobId.tsx | 7 +- src/routes/jobs/index.tsx | 22 ++++-- src/routes/periodic-jobs/index.tsx | 5 +- src/routes/queues/$name.tsx | 6 +- src/routes/queues/index.tsx | 7 +- src/routes/workflows/$workflowId.tsx | 7 +- src/routes/workflows/index.tsx | 5 +- 10 files changed, 141 insertions(+), 19 deletions(-) create mode 100644 src/contexts/RefreshSettings.query.test.tsx create mode 100644 src/contexts/RefreshSettings.query.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b9427dfc..d8e574c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Global live update pause: disable automatic query refreshes on browser focus and reconnect, preventing paused workflow detail pages from re-fetching wait data outside the configured refresh interval. [PR #584](https://github.com/riverqueue/riverui/pull/584). + ## [v0.16.0] - 2026-05-19 Version 0.16.0 includes support for the all new workflow engine in River Pro v0.24.0, including signals, timers, and greater introspection capabilities. diff --git a/src/contexts/RefreshSettings.query.test.tsx b/src/contexts/RefreshSettings.query.test.tsx new file mode 100644 index 00000000..d28e64a3 --- /dev/null +++ b/src/contexts/RefreshSettings.query.test.tsx @@ -0,0 +1,80 @@ +import { + focusManager, + QueryClient, + QueryClientProvider, + useQuery, +} from "@tanstack/react-query"; +import { act, render, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { refreshQueryOptions } from "./RefreshSettings.query"; + +describe("refreshQueryOptions", () => { + afterEach(() => { + focusManager.setFocused(undefined); + vi.restoreAllMocks(); + }); + + it("prevents automatic focus refetches when live updates are paused", async () => { + const queryFn = vi.fn<() => Promise>().mockResolvedValue("loaded"); + const queryClient = renderQuery({ intervalMs: 0, queryFn }); + + await waitFor(() => expect(queryFn).toHaveBeenCalledTimes(1)); + + await act(async () => { + focusManager.setFocused(false); + focusManager.setFocused(true); + }); + + expect(queryFn).toHaveBeenCalledTimes(1); + queryClient.clear(); + }); + + it("allows automatic focus refetches when live updates are enabled", async () => { + const queryFn = vi.fn<() => Promise>().mockResolvedValue("loaded"); + const queryClient = renderQuery({ intervalMs: 2000, queryFn }); + + await waitFor(() => expect(queryFn).toHaveBeenCalledTimes(1)); + + await act(async () => { + focusManager.setFocused(false); + focusManager.setFocused(true); + }); + + await waitFor(() => expect(queryFn).toHaveBeenCalledTimes(2)); + queryClient.clear(); + }); +}); + +const renderQuery = ({ + intervalMs, + queryFn, +}: { + intervalMs: number; + queryFn: () => Promise; +}): QueryClient => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + const Probe = () => { + useQuery({ + queryFn, + queryKey: ["refresh-settings-test"], + ...refreshQueryOptions(intervalMs), + }); + return null; + }; + + render( + + + , + ); + + return queryClient; +}; diff --git a/src/contexts/RefreshSettings.query.ts b/src/contexts/RefreshSettings.query.ts new file mode 100644 index 00000000..54cc3ab2 --- /dev/null +++ b/src/contexts/RefreshSettings.query.ts @@ -0,0 +1,17 @@ +export type RefreshQueryOptions = { + refetchInterval: false | number; + refetchOnReconnect: boolean; + refetchOnWindowFocus: boolean; +}; + +export const refreshQueryOptions = ( + intervalMs: number, +): RefreshQueryOptions => { + const enabled = intervalMs > 0; + + return { + refetchInterval: enabled ? intervalMs : false, + refetchOnReconnect: enabled, + refetchOnWindowFocus: enabled, + }; +}; diff --git a/src/routes/jobs/$jobId.tsx b/src/routes/jobs/$jobId.tsx index d7b48377..b3b65979 100644 --- a/src/routes/jobs/$jobId.tsx +++ b/src/routes/jobs/$jobId.tsx @@ -1,6 +1,7 @@ import JobDetail from "@components/JobDetail"; import JobNotFound from "@components/JobNotFound"; import { useRefreshSetting } from "@contexts/RefreshSettings.hook"; +import { refreshQueryOptions } from "@contexts/RefreshSettings.query"; import { cancelJobs, deleteJobs, @@ -30,7 +31,6 @@ export const Route = createFileRoute("/jobs/$jobId")({ queryOptions: { queryKey: getJobKey(jobId), queryFn: getJob, - refetchInterval: 2000, signal: abortController.signal, }, }; @@ -58,7 +58,10 @@ function JobComponent() { const { queryOptions } = Route.useRouteContext(); const refreshSettings = useRefreshSetting(); const queryOptionsWithRefresh = useMemo( - () => ({ ...queryOptions, refetchInterval: refreshSettings.intervalMs }), + () => ({ + ...queryOptions, + ...refreshQueryOptions(refreshSettings.intervalMs), + }), [queryOptions, refreshSettings.intervalMs], ); diff --git a/src/routes/jobs/index.tsx b/src/routes/jobs/index.tsx index b2b8bc34..e7426da6 100644 --- a/src/routes/jobs/index.tsx +++ b/src/routes/jobs/index.tsx @@ -1,6 +1,10 @@ import { Filter, FilterTypeId } from "@components/job-search/JobSearch"; import JobList from "@components/JobList"; import { useRefreshSetting } from "@contexts/RefreshSettings.hook"; +import { + type RefreshQueryOptions, + refreshQueryOptions, +} from "@contexts/RefreshSettings.query"; import { defaultValues, jobSearchSchema } from "@routes/jobs/index.schema"; import { cancelJobs, @@ -82,7 +86,7 @@ function JobsIndexComponent() { const navigate = Route.useNavigate(); const { id, limit, state, kind, queue, priority } = Route.useLoaderDeps(); const refreshSettings = useRefreshSetting(); - const refetchInterval = refreshSettings.intervalMs; + const refreshOptions = refreshQueryOptions(refreshSettings.intervalMs); const [pauseRefetches, setJobRefetchesPaused] = useState(false); const queryClient = useQueryClient(); @@ -98,11 +102,11 @@ function JobsIndexComponent() { }, { pauseRefetches, - refetchInterval, + refreshOptions, }, ), ); - const statesQuery = useQuery(statesQueryOptions({ refetchInterval })); + const statesQuery = useQuery(statesQueryOptions(refreshOptions)); const canShowFewer = limit > minimumLimit; const canShowMore = limit < maximumLimit; @@ -339,7 +343,7 @@ const jobsQueryOptions = ( queue?: string[]; state: JobState; }, - opts?: { pauseRefetches: boolean; refetchInterval: number }, + opts?: { pauseRefetches: boolean; refreshOptions: RefreshQueryOptions }, ) => { const keepPreviousDataUnlessStateChanged: PlaceholderDataFunction< JobMinimal[], @@ -363,13 +367,17 @@ const jobsQueryOptions = ( }), queryFn: listJobs, placeholderData: keepPreviousDataUnlessStateChanged, - refetchInterval: !opts?.pauseRefetches && opts?.refetchInterval, + ...(opts + ? opts.pauseRefetches + ? refreshQueryOptions(0) + : opts.refreshOptions + : {}), }); }; -const statesQueryOptions = (opts?: { refetchInterval: number }) => +const statesQueryOptions = (opts?: RefreshQueryOptions) => queryOptions({ queryKey: countsByStateKey(), queryFn: countsByState, - refetchInterval: opts?.refetchInterval, + ...opts, }); diff --git a/src/routes/periodic-jobs/index.tsx b/src/routes/periodic-jobs/index.tsx index 617aa676..430bf319 100644 --- a/src/routes/periodic-jobs/index.tsx +++ b/src/routes/periodic-jobs/index.tsx @@ -1,6 +1,7 @@ import PeriodicJobList from "@components/PeriodicJobList"; import PeriodicJobListEmptyState from "@components/PeriodicJobListEmptyState"; import { useRefreshSetting } from "@contexts/RefreshSettings.hook"; +import { refreshQueryOptions } from "@contexts/RefreshSettings.query"; import { listPeriodicJobs, listPeriodicJobsKey } from "@services/periodicJobs"; import { queryOptions, useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; @@ -25,9 +26,9 @@ export const Route = createFileRoute("/periodic-jobs/")({ function PeriodicJobsIndexComponent() { const { jobsQueryOptions } = Route.useRouteContext(); const refreshSettings = useRefreshSetting(); - const refetchInterval = refreshSettings.intervalMs; + const refreshOptions = refreshQueryOptions(refreshSettings.intervalMs); - const query = useQuery({ ...jobsQueryOptions, refetchInterval }); + const query = useQuery({ ...jobsQueryOptions, ...refreshOptions }); if (!jobsQueryOptions.enabled) { return ; diff --git a/src/routes/queues/$name.tsx b/src/routes/queues/$name.tsx index 92eb1e68..dbe55bbb 100644 --- a/src/routes/queues/$name.tsx +++ b/src/routes/queues/$name.tsx @@ -1,5 +1,6 @@ import QueueDetail from "@components/QueueDetail"; import { useRefreshSetting } from "@contexts/RefreshSettings.hook"; +import { refreshQueryOptions } from "@contexts/RefreshSettings.query"; import { listProducers, listProducersKey } from "@services/producers"; import { type ConcurrencyConfig, @@ -64,16 +65,17 @@ function QueueComponent() { const { name } = Route.useParams(); const { queueQueryOptions, producersQueryOptions } = Route.useRouteContext(); const refreshSettings = useRefreshSetting(); + const refreshOptions = refreshQueryOptions(refreshSettings.intervalMs); const { features } = Route.useRouteContext(); const queryClient = useQueryClient(); const queueQuery = useQuery({ ...queueQueryOptions, - refetchInterval: refreshSettings.intervalMs, + ...refreshOptions, }); const producersQuery = useQuery({ ...producersQueryOptions, - refetchInterval: refreshSettings.intervalMs, + ...refreshOptions, }); const loading = diff --git a/src/routes/queues/index.tsx b/src/routes/queues/index.tsx index 61900092..3d21cca0 100644 --- a/src/routes/queues/index.tsx +++ b/src/routes/queues/index.tsx @@ -1,5 +1,6 @@ import QueueList from "@components/QueueList"; import { useRefreshSetting } from "@contexts/RefreshSettings.hook"; +import { refreshQueryOptions } from "@contexts/RefreshSettings.query"; import { listQueues, listQueuesKey, @@ -16,7 +17,6 @@ export const Route = createFileRoute("/queues/")({ queryOptions: { queryKey: listQueuesKey(), queryFn: listQueues, - refetchInterval: 2000, signal: abortController.signal, }, }; @@ -32,7 +32,10 @@ function QueuesIndexComponent() { const { queryOptions } = Route.useRouteContext(); const refreshSettings = useRefreshSetting(); const queryOptionsWithRefresh = useMemo( - () => ({ ...queryOptions, refetchInterval: refreshSettings.intervalMs }), + () => ({ + ...queryOptions, + ...refreshQueryOptions(refreshSettings.intervalMs), + }), [queryOptions, refreshSettings.intervalMs], ); diff --git a/src/routes/workflows/$workflowId.tsx b/src/routes/workflows/$workflowId.tsx index 30fd22d6..32ed67d1 100644 --- a/src/routes/workflows/$workflowId.tsx +++ b/src/routes/workflows/$workflowId.tsx @@ -1,5 +1,6 @@ import WorkflowDetail from "@components/WorkflowDetail"; import { useRefreshSetting } from "@contexts/RefreshSettings.hook"; +import { refreshQueryOptions } from "@contexts/RefreshSettings.query"; import { toastSuccess } from "@services/toast"; import { cancelJobs, @@ -37,7 +38,6 @@ export const Route = createFileRoute("/workflows/$workflowId")({ enabled: features.workflowQueries, queryKey: getWorkflowKey(workflowId), queryFn: getWorkflow, - refetchInterval: 1000, signal: abortController.signal, }, }; @@ -66,7 +66,10 @@ function WorkflowComponent() { const refreshSettings = useRefreshSetting(); const queryClient = useQueryClient(); const queryOptionsWithRefresh = useMemo( - () => ({ ...queryOptions, refetchInterval: refreshSettings.intervalMs }), + () => ({ + ...queryOptions, + ...refreshQueryOptions(refreshSettings.intervalMs), + }), [queryOptions, refreshSettings.intervalMs], ); diff --git a/src/routes/workflows/index.tsx b/src/routes/workflows/index.tsx index ece1047a..7d2e8ef8 100644 --- a/src/routes/workflows/index.tsx +++ b/src/routes/workflows/index.tsx @@ -1,5 +1,6 @@ import WorkflowList from "@components/WorkflowList"; import { useRefreshSetting } from "@contexts/RefreshSettings.hook"; +import { refreshQueryOptions } from "@contexts/RefreshSettings.query"; import { WorkflowState } from "@services/types"; import { listWorkflows, listWorkflowsKey } from "@services/workflows"; import { queryOptions, useQuery } from "@tanstack/react-query"; @@ -47,11 +48,11 @@ export const Route = createFileRoute("/workflows/")({ function WorkflowsIndexComponent() { const refreshSettings = useRefreshSetting(); - const refetchInterval = refreshSettings.intervalMs; + const refreshOptions = refreshQueryOptions(refreshSettings.intervalMs); const { features, workflowsQueryOptions } = Route.useRouteContext(); const workflowsQuery = useQuery({ ...workflowsQueryOptions, - refetchInterval, + ...refreshOptions, }); return (