Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
80 changes: 80 additions & 0 deletions src/contexts/RefreshSettings.query.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string>>().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<string>>().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<string>;
}): QueryClient => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});

const Probe = () => {
useQuery({
queryFn,
queryKey: ["refresh-settings-test"],
...refreshQueryOptions(intervalMs),
});
return null;
};

render(
<QueryClientProvider client={queryClient}>
<Probe />
</QueryClientProvider>,
);

return queryClient;
};
17 changes: 17 additions & 0 deletions src/contexts/RefreshSettings.query.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
7 changes: 5 additions & 2 deletions src/routes/jobs/$jobId.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -30,7 +31,6 @@ export const Route = createFileRoute("/jobs/$jobId")({
queryOptions: {
queryKey: getJobKey(jobId),
queryFn: getJob,
refetchInterval: 2000,
signal: abortController.signal,
},
};
Expand Down Expand Up @@ -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],
);

Expand Down
22 changes: 15 additions & 7 deletions src/routes/jobs/index.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();

Expand All @@ -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;
Expand Down Expand Up @@ -339,7 +343,7 @@ const jobsQueryOptions = (
queue?: string[];
state: JobState;
},
opts?: { pauseRefetches: boolean; refetchInterval: number },
opts?: { pauseRefetches: boolean; refreshOptions: RefreshQueryOptions },
) => {
const keepPreviousDataUnlessStateChanged: PlaceholderDataFunction<
JobMinimal[],
Expand All @@ -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,
});
5 changes: 3 additions & 2 deletions src/routes/periodic-jobs/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 <PeriodicJobListEmptyState hasAny={false} />;
Expand Down
6 changes: 4 additions & 2 deletions src/routes/queues/$name.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 =
Expand Down
7 changes: 5 additions & 2 deletions src/routes/queues/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import QueueList from "@components/QueueList";
import { useRefreshSetting } from "@contexts/RefreshSettings.hook";
import { refreshQueryOptions } from "@contexts/RefreshSettings.query";
import {
listQueues,
listQueuesKey,
Expand All @@ -16,7 +17,6 @@ export const Route = createFileRoute("/queues/")({
queryOptions: {
queryKey: listQueuesKey(),
queryFn: listQueues,
refetchInterval: 2000,
signal: abortController.signal,
},
};
Expand All @@ -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],
);

Expand Down
7 changes: 5 additions & 2 deletions src/routes/workflows/$workflowId.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -37,7 +38,6 @@ export const Route = createFileRoute("/workflows/$workflowId")({
enabled: features.workflowQueries,
queryKey: getWorkflowKey(workflowId),
queryFn: getWorkflow,
refetchInterval: 1000,
signal: abortController.signal,
},
};
Expand Down Expand Up @@ -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],
);

Expand Down
5 changes: 3 additions & 2 deletions src/routes/workflows/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 (
Expand Down
Loading