Skip to content

Commit 44be8ad

Browse files
committed
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.
1 parent e52a194 commit 44be8ad

10 files changed

Lines changed: 141 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- 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).
13+
1014
## [v0.16.0] - 2026-05-19
1115

1216
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.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import {
2+
focusManager,
3+
QueryClient,
4+
QueryClientProvider,
5+
useQuery,
6+
} from "@tanstack/react-query";
7+
import { act, render, waitFor } from "@testing-library/react";
8+
import { afterEach, describe, expect, it, vi } from "vitest";
9+
10+
import { refreshQueryOptions } from "./RefreshSettings.query";
11+
12+
describe("refreshQueryOptions", () => {
13+
afterEach(() => {
14+
focusManager.setFocused(undefined);
15+
vi.restoreAllMocks();
16+
});
17+
18+
it("prevents automatic focus refetches when live updates are paused", async () => {
19+
const queryFn = vi.fn<() => Promise<string>>().mockResolvedValue("loaded");
20+
const queryClient = renderQuery({ intervalMs: 0, queryFn });
21+
22+
await waitFor(() => expect(queryFn).toHaveBeenCalledTimes(1));
23+
24+
await act(async () => {
25+
focusManager.setFocused(false);
26+
focusManager.setFocused(true);
27+
});
28+
29+
expect(queryFn).toHaveBeenCalledTimes(1);
30+
queryClient.clear();
31+
});
32+
33+
it("allows automatic focus refetches when live updates are enabled", async () => {
34+
const queryFn = vi.fn<() => Promise<string>>().mockResolvedValue("loaded");
35+
const queryClient = renderQuery({ intervalMs: 2000, queryFn });
36+
37+
await waitFor(() => expect(queryFn).toHaveBeenCalledTimes(1));
38+
39+
await act(async () => {
40+
focusManager.setFocused(false);
41+
focusManager.setFocused(true);
42+
});
43+
44+
await waitFor(() => expect(queryFn).toHaveBeenCalledTimes(2));
45+
queryClient.clear();
46+
});
47+
});
48+
49+
const renderQuery = ({
50+
intervalMs,
51+
queryFn,
52+
}: {
53+
intervalMs: number;
54+
queryFn: () => Promise<string>;
55+
}): QueryClient => {
56+
const queryClient = new QueryClient({
57+
defaultOptions: {
58+
queries: {
59+
retry: false,
60+
},
61+
},
62+
});
63+
64+
const Probe = () => {
65+
useQuery({
66+
queryFn,
67+
queryKey: ["refresh-settings-test"],
68+
...refreshQueryOptions(intervalMs),
69+
});
70+
return null;
71+
};
72+
73+
render(
74+
<QueryClientProvider client={queryClient}>
75+
<Probe />
76+
</QueryClientProvider>,
77+
);
78+
79+
return queryClient;
80+
};
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export type RefreshQueryOptions = {
2+
refetchInterval: false | number;
3+
refetchOnReconnect: boolean;
4+
refetchOnWindowFocus: boolean;
5+
};
6+
7+
export const refreshQueryOptions = (
8+
intervalMs: number,
9+
): RefreshQueryOptions => {
10+
const enabled = intervalMs > 0;
11+
12+
return {
13+
refetchInterval: enabled ? intervalMs : false,
14+
refetchOnReconnect: enabled,
15+
refetchOnWindowFocus: enabled,
16+
};
17+
};

src/routes/jobs/$jobId.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import JobDetail from "@components/JobDetail";
22
import JobNotFound from "@components/JobNotFound";
33
import { useRefreshSetting } from "@contexts/RefreshSettings.hook";
4+
import { refreshQueryOptions } from "@contexts/RefreshSettings.query";
45
import {
56
cancelJobs,
67
deleteJobs,
@@ -30,7 +31,6 @@ export const Route = createFileRoute("/jobs/$jobId")({
3031
queryOptions: {
3132
queryKey: getJobKey(jobId),
3233
queryFn: getJob,
33-
refetchInterval: 2000,
3434
signal: abortController.signal,
3535
},
3636
};
@@ -58,7 +58,10 @@ function JobComponent() {
5858
const { queryOptions } = Route.useRouteContext();
5959
const refreshSettings = useRefreshSetting();
6060
const queryOptionsWithRefresh = useMemo(
61-
() => ({ ...queryOptions, refetchInterval: refreshSettings.intervalMs }),
61+
() => ({
62+
...queryOptions,
63+
...refreshQueryOptions(refreshSettings.intervalMs),
64+
}),
6265
[queryOptions, refreshSettings.intervalMs],
6366
);
6467

src/routes/jobs/index.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { Filter, FilterTypeId } from "@components/job-search/JobSearch";
22
import JobList from "@components/JobList";
33
import { useRefreshSetting } from "@contexts/RefreshSettings.hook";
4+
import {
5+
type RefreshQueryOptions,
6+
refreshQueryOptions,
7+
} from "@contexts/RefreshSettings.query";
48
import { defaultValues, jobSearchSchema } from "@routes/jobs/index.schema";
59
import {
610
cancelJobs,
@@ -82,7 +86,7 @@ function JobsIndexComponent() {
8286
const navigate = Route.useNavigate();
8387
const { id, limit, state, kind, queue, priority } = Route.useLoaderDeps();
8488
const refreshSettings = useRefreshSetting();
85-
const refetchInterval = refreshSettings.intervalMs;
89+
const refreshOptions = refreshQueryOptions(refreshSettings.intervalMs);
8690
const [pauseRefetches, setJobRefetchesPaused] = useState(false);
8791
const queryClient = useQueryClient();
8892

@@ -98,11 +102,11 @@ function JobsIndexComponent() {
98102
},
99103
{
100104
pauseRefetches,
101-
refetchInterval,
105+
refreshOptions,
102106
},
103107
),
104108
);
105-
const statesQuery = useQuery(statesQueryOptions({ refetchInterval }));
109+
const statesQuery = useQuery(statesQueryOptions(refreshOptions));
106110

107111
const canShowFewer = limit > minimumLimit;
108112
const canShowMore = limit < maximumLimit;
@@ -339,7 +343,7 @@ const jobsQueryOptions = (
339343
queue?: string[];
340344
state: JobState;
341345
},
342-
opts?: { pauseRefetches: boolean; refetchInterval: number },
346+
opts?: { pauseRefetches: boolean; refreshOptions: RefreshQueryOptions },
343347
) => {
344348
const keepPreviousDataUnlessStateChanged: PlaceholderDataFunction<
345349
JobMinimal[],
@@ -363,13 +367,17 @@ const jobsQueryOptions = (
363367
}),
364368
queryFn: listJobs,
365369
placeholderData: keepPreviousDataUnlessStateChanged,
366-
refetchInterval: !opts?.pauseRefetches && opts?.refetchInterval,
370+
...(opts
371+
? opts.pauseRefetches
372+
? refreshQueryOptions(0)
373+
: opts.refreshOptions
374+
: {}),
367375
});
368376
};
369377

370-
const statesQueryOptions = (opts?: { refetchInterval: number }) =>
378+
const statesQueryOptions = (opts?: RefreshQueryOptions) =>
371379
queryOptions({
372380
queryKey: countsByStateKey(),
373381
queryFn: countsByState,
374-
refetchInterval: opts?.refetchInterval,
382+
...opts,
375383
});

src/routes/periodic-jobs/index.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import PeriodicJobList from "@components/PeriodicJobList";
22
import PeriodicJobListEmptyState from "@components/PeriodicJobListEmptyState";
33
import { useRefreshSetting } from "@contexts/RefreshSettings.hook";
4+
import { refreshQueryOptions } from "@contexts/RefreshSettings.query";
45
import { listPeriodicJobs, listPeriodicJobsKey } from "@services/periodicJobs";
56
import { queryOptions, useQuery } from "@tanstack/react-query";
67
import { createFileRoute } from "@tanstack/react-router";
@@ -25,9 +26,9 @@ export const Route = createFileRoute("/periodic-jobs/")({
2526
function PeriodicJobsIndexComponent() {
2627
const { jobsQueryOptions } = Route.useRouteContext();
2728
const refreshSettings = useRefreshSetting();
28-
const refetchInterval = refreshSettings.intervalMs;
29+
const refreshOptions = refreshQueryOptions(refreshSettings.intervalMs);
2930

30-
const query = useQuery({ ...jobsQueryOptions, refetchInterval });
31+
const query = useQuery({ ...jobsQueryOptions, ...refreshOptions });
3132

3233
if (!jobsQueryOptions.enabled) {
3334
return <PeriodicJobListEmptyState hasAny={false} />;

src/routes/queues/$name.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import QueueDetail from "@components/QueueDetail";
22
import { useRefreshSetting } from "@contexts/RefreshSettings.hook";
3+
import { refreshQueryOptions } from "@contexts/RefreshSettings.query";
34
import { listProducers, listProducersKey } from "@services/producers";
45
import {
56
type ConcurrencyConfig,
@@ -64,16 +65,17 @@ function QueueComponent() {
6465
const { name } = Route.useParams();
6566
const { queueQueryOptions, producersQueryOptions } = Route.useRouteContext();
6667
const refreshSettings = useRefreshSetting();
68+
const refreshOptions = refreshQueryOptions(refreshSettings.intervalMs);
6769
const { features } = Route.useRouteContext();
6870
const queryClient = useQueryClient();
6971

7072
const queueQuery = useQuery({
7173
...queueQueryOptions,
72-
refetchInterval: refreshSettings.intervalMs,
74+
...refreshOptions,
7375
});
7476
const producersQuery = useQuery({
7577
...producersQueryOptions,
76-
refetchInterval: refreshSettings.intervalMs,
78+
...refreshOptions,
7779
});
7880

7981
const loading =

src/routes/queues/index.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import QueueList from "@components/QueueList";
22
import { useRefreshSetting } from "@contexts/RefreshSettings.hook";
3+
import { refreshQueryOptions } from "@contexts/RefreshSettings.query";
34
import {
45
listQueues,
56
listQueuesKey,
@@ -16,7 +17,6 @@ export const Route = createFileRoute("/queues/")({
1617
queryOptions: {
1718
queryKey: listQueuesKey(),
1819
queryFn: listQueues,
19-
refetchInterval: 2000,
2020
signal: abortController.signal,
2121
},
2222
};
@@ -32,7 +32,10 @@ function QueuesIndexComponent() {
3232
const { queryOptions } = Route.useRouteContext();
3333
const refreshSettings = useRefreshSetting();
3434
const queryOptionsWithRefresh = useMemo(
35-
() => ({ ...queryOptions, refetchInterval: refreshSettings.intervalMs }),
35+
() => ({
36+
...queryOptions,
37+
...refreshQueryOptions(refreshSettings.intervalMs),
38+
}),
3639
[queryOptions, refreshSettings.intervalMs],
3740
);
3841

src/routes/workflows/$workflowId.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import WorkflowDetail from "@components/WorkflowDetail";
22
import { useRefreshSetting } from "@contexts/RefreshSettings.hook";
3+
import { refreshQueryOptions } from "@contexts/RefreshSettings.query";
34
import { toastSuccess } from "@services/toast";
45
import {
56
cancelJobs,
@@ -37,7 +38,6 @@ export const Route = createFileRoute("/workflows/$workflowId")({
3738
enabled: features.workflowQueries,
3839
queryKey: getWorkflowKey(workflowId),
3940
queryFn: getWorkflow,
40-
refetchInterval: 1000,
4141
signal: abortController.signal,
4242
},
4343
};
@@ -66,7 +66,10 @@ function WorkflowComponent() {
6666
const refreshSettings = useRefreshSetting();
6767
const queryClient = useQueryClient();
6868
const queryOptionsWithRefresh = useMemo(
69-
() => ({ ...queryOptions, refetchInterval: refreshSettings.intervalMs }),
69+
() => ({
70+
...queryOptions,
71+
...refreshQueryOptions(refreshSettings.intervalMs),
72+
}),
7073
[queryOptions, refreshSettings.intervalMs],
7174
);
7275

src/routes/workflows/index.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import WorkflowList from "@components/WorkflowList";
22
import { useRefreshSetting } from "@contexts/RefreshSettings.hook";
3+
import { refreshQueryOptions } from "@contexts/RefreshSettings.query";
34
import { WorkflowState } from "@services/types";
45
import { listWorkflows, listWorkflowsKey } from "@services/workflows";
56
import { queryOptions, useQuery } from "@tanstack/react-query";
@@ -47,11 +48,11 @@ export const Route = createFileRoute("/workflows/")({
4748

4849
function WorkflowsIndexComponent() {
4950
const refreshSettings = useRefreshSetting();
50-
const refetchInterval = refreshSettings.intervalMs;
51+
const refreshOptions = refreshQueryOptions(refreshSettings.intervalMs);
5152
const { features, workflowsQueryOptions } = Route.useRouteContext();
5253
const workflowsQuery = useQuery({
5354
...workflowsQueryOptions,
54-
refetchInterval,
55+
...refreshOptions,
5556
});
5657

5758
return (

0 commit comments

Comments
 (0)