Skip to content

Commit f78261a

Browse files
matt-aitkenTrigger.dev RepoOps
authored andcommitted
fix(webapp): pause dashboard pollers while the tab is hidden
Leaving the dashboard open in a background tab could leave you staring at a full-page "Failed to fetch" error on your return, cleared only by a manual refresh. Pages that poll for updates (deployments, queues, limits, branches, batches, bulk actions, errors and more) kept polling while the tab was hidden, so a tab left open for a few hours accumulated thousands of chances to catch a passing network blip: a laptop sleeping, wifi dropping, a VPN reconnecting. A single one of those was enough to replace the page with an error. Every one of those pollers now pauses while the tab is hidden and refreshes when you return to it, so a tab nobody is looking at no longer spends the day making requests that can fail, and it is up to date when you come back. Mono-RevId: 990f7770f923437981c298d70523e416341f1e67
1 parent a1b1a86 commit f78261a

13 files changed

Lines changed: 89 additions & 63 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Dashboard pages no longer keep polling for updates while their browser tab is hidden, which could leave a tab you came back to showing a connection error instead of your data. Pages refresh when you return to the tab.

‎apps/webapp/app/components/webhookDeliveries/v1/useDeliveriesLiveReload.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,6 @@ export function useDeliveriesLiveReload({
227227
useInterval({
228228
interval: DELIVERIES_POLL_INTERVAL_MS,
229229
onLoad: true,
230-
pauseWhenHidden: true,
231230
disabled: !shouldPoll,
232231
callback: () => {
233232
loadDeliveriesPoll(checkNewDeliveriesOnTick());
Lines changed: 19 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,36 @@
11
import { useRevalidator } from "@remix-run/react";
2-
import { useEffect, useRef } from "react";
2+
import { useInterval } from "./useInterval";
33

44
type UseAutoRevalidateOptions = {
55
interval?: number; // in milliseconds
66
onFocus?: boolean;
77
disabled?: boolean;
88
};
99

10+
/**
11+
* Re-runs the current route's loaders on a timer.
12+
*
13+
* Ticks are skipped while the tab is hidden. A backgrounded tab has no one watching it,
14+
* and at the default interval it would otherwise spend a working day accumulating
15+
* thousands of chances to catch a dropped connection — each one fatal to the page.
16+
* Returning to the tab fires the focus handler, so the data is still fresh on arrival.
17+
*/
1018
export function useAutoRevalidate(options: UseAutoRevalidateOptions = {}) {
1119
const { interval = 5000, onFocus = true, disabled = false } = options;
1220
const revalidator = useRevalidator();
13-
const revalidatorRef = useRef(revalidator);
14-
// oxlint-disable-next-line react/refs -- This ref intentionally coordinates an imperative integration outside React state.
15-
revalidatorRef.current = revalidator;
1621

17-
useEffect(() => {
18-
if (!interval || interval <= 0 || disabled) return;
19-
20-
const intervalId = setInterval(() => {
21-
if (revalidatorRef.current.state === "loading") {
22+
useInterval({
23+
interval,
24+
onFocus,
25+
disabled,
26+
onLoad: false,
27+
callback: () => {
28+
if (revalidator.state === "loading") {
2229
return;
2330
}
24-
revalidatorRef.current.revalidate();
25-
}, interval);
26-
27-
return () => clearInterval(intervalId);
28-
}, [interval, disabled]);
29-
30-
useEffect(() => {
31-
if (!onFocus || disabled) return;
32-
33-
const handleFocus = () => {
34-
if (document.visibilityState === "visible" && revalidatorRef.current.state !== "loading") {
35-
revalidatorRef.current.revalidate();
36-
}
37-
};
38-
39-
// Revalidate when the page becomes visible
40-
document.addEventListener("visibilitychange", handleFocus);
41-
// Revalidate when the window gains focus
42-
window.addEventListener("focus", handleFocus);
43-
44-
return () => {
45-
document.removeEventListener("visibilitychange", handleFocus);
46-
window.removeEventListener("focus", handleFocus);
47-
};
48-
}, [onFocus, disabled]);
31+
revalidator.revalidate();
32+
},
33+
});
4934

5035
return revalidator;
5136
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { describe, expect, it } from "vitest";
2+
import { shouldRunIntervalTick } from "./useInterval";
3+
4+
describe("shouldRunIntervalTick", () => {
5+
it("pauses a caller that says nothing, because the default is to pause", () => {
6+
expect(shouldRunIntervalTick(undefined, "hidden")).toBe(false);
7+
expect(shouldRunIntervalTick(undefined, "visible")).toBe(true);
8+
});
9+
10+
it("keeps ticking while hidden only when a caller opts out on purpose", () => {
11+
expect(shouldRunIntervalTick(false, "hidden")).toBe(true);
12+
expect(shouldRunIntervalTick(false, "visible")).toBe(true);
13+
});
14+
15+
it("pauses when a caller asks for it explicitly", () => {
16+
expect(shouldRunIntervalTick(true, "hidden")).toBe(false);
17+
expect(shouldRunIntervalTick(true, "visible")).toBe(true);
18+
});
19+
});

‎apps/webapp/app/hooks/useInterval.ts‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,31 @@
11
import { useEffect, useRef } from "react";
22

3+
/**
4+
* Whether a tick should fire, given the caller's `pauseWhenHidden` and the tab's current
5+
* visibility. Owns the default so there is one place it can be read or changed, and so a
6+
* test can pin it: defaulting this to false is what let a backgrounded tab poll itself
7+
* into a connection error.
8+
*/
9+
export function shouldRunIntervalTick(
10+
pauseWhenHidden: boolean | undefined,
11+
visibilityState: DocumentVisibilityState
12+
): boolean {
13+
const paused = pauseWhenHidden ?? true;
14+
return !paused || visibilityState === "visible";
15+
}
16+
317
type UseIntervalOptions = {
418
/** If passed, will refresh every interval MS */
519
interval?: number;
620
onLoad?: boolean;
721
onFocus?: boolean;
822
disabled?: boolean;
9-
/** Skip interval ticks while the document tab is hidden */
23+
/**
24+
* Skip interval ticks while the tab is hidden. Defaults to true, because a poller
25+
* nobody is looking at just accumulates chances to fail, and the focus handler below
26+
* already refreshes on return. Pass false only for work that genuinely cannot wait for
27+
* the tab to come back, and say why at the call site.
28+
*/
1029
pauseWhenHidden?: boolean;
1130
callback: () => void;
1231
};
@@ -16,7 +35,7 @@ export function useInterval({
1635
onLoad = true,
1736
onFocus = true,
1837
disabled = false,
19-
pauseWhenHidden = false,
38+
pauseWhenHidden,
2039
callback,
2140
}: UseIntervalOptions) {
2241
// Always keep the latest callback in a ref so the effects below
@@ -31,7 +50,7 @@ export function useInterval({
3150
if (!interval || interval <= 0 || disabled) return;
3251

3352
const intervalId = setInterval(() => {
34-
if (pauseWhenHidden && document.visibilityState !== "visible") {
53+
if (!shouldRunIntervalTick(pauseWhenHidden, document.visibilityState)) {
3554
return;
3655
}
3756
latestCallback.current();

‎apps/webapp/app/hooks/useMetricResourceQuery.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,6 @@ export function useMetricResourceQuery(query: string, opts: MetricResourceQueryO
223223
interval: refreshIntervalMs,
224224
onLoad: false,
225225
onFocus: true,
226-
pauseWhenHidden: true,
227226
callback: load,
228227
});
229228

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -909,7 +909,6 @@ function useConcurrencyKeys(opts: {
909909
interval: 30_000,
910910
onLoad: false,
911911
onFocus: true,
912-
pauseWhenHidden: true,
913912
callback: load,
914913
});
915914

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,6 @@ export function useRunsLiveReload({
284284
useInterval({
285285
interval: RUNS_POLL_INTERVAL_MS,
286286
onLoad: true,
287-
pauseWhenHidden: true,
288287
disabled: !shouldPoll,
289288
callback: () => {
290289
loadRunsPoll(checkNewRunsOnTick());

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.deliveries.$deliveryParam/route.tsx‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,6 @@ export default function Page() {
154154
const inFlight = delivery?.status === "PENDING" || delivery?.status === "PROCESSING";
155155
useInterval({
156156
interval: 3000,
157-
pauseWhenHidden: true,
158157
disabled: !inFlight,
159158
callback: () => revalidator.revalidate(),
160159
});

‎apps/webapp/app/routes/resources.incidents.tsx‎

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives
1010
import { SimpleTooltip } from "~/components/primitives/Tooltip";
1111
import { useFeatures } from "~/hooks/useFeatures";
1212
import { BetterStackClient, type AggregateState } from "~/services/betterstack/betterstack.server";
13+
import { useInterval } from "~/hooks/useInterval";
1314

1415
// Prevent Remix from revalidating this route when other fetchers submit
1516
export const shouldRevalidate: ShouldRevalidateFunction = () => false;
@@ -56,18 +57,16 @@ export function useIncidentStatus() {
5657
}
5758
}, [isManagedCloud, load, state]);
5859

59-
useEffect(() => {
60-
if (!isManagedCloud) return;
61-
62-
// Poll every 60 seconds
63-
const interval = setInterval(() => {
60+
useInterval({
61+
interval: POLL_INTERVAL_MS,
62+
onLoad: false,
63+
disabled: !isManagedCloud,
64+
callback: () => {
6465
if (stateRef.current === "idle") {
6566
load("/resources/incidents");
6667
}
67-
}, POLL_INTERVAL_MS);
68-
69-
return () => clearInterval(interval);
70-
}, [isManagedCloud, load, stateRef]);
68+
},
69+
});
7170

7271
return {
7372
status: fetcher.data?.status ?? "operational",

0 commit comments

Comments
 (0)