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 (