Commit

A click away from a live page arrives: pages stop refreshing themselves while a navigation is pending

Pages with something running revalidated every few seconds, checking only that no revalidation was in flight. During a navigation that check passes, and React Router restarts a pending navigation on revalidate(), aborting its loaders. With the root loader waiting on billing for over two seconds, a click to Agents from a live page could restart again and again and never arrive (seen 2026-10-06). One hook, useRefreshWhile (lib/refresh.ts), now does every page's timer: it pauses while a navigation is pending and never stacks revalidations. Agents, workflows, a workflow run, issues, plans, pull requests, the merge queue and custom domains use it.

syntaqxcommitted Parentec6f51eBrowse files
9 files+50−820/9 viewed
+3−9
66 */
77 import { Bot, CircleSlash, Clock, Coins, Gauge, Loader2, MessageSquare, OctagonX, Square, TriangleAlert } from "lucide-react";
88 import { type ReactNode, useEffect, useMemo, useState } from "react";
9−import { Link, useFetcher, useRevalidator } from "react-router";
9+import { Link, useFetcher } from "react-router";
10+import { useRefreshWhile } from "../lib/refresh";
1011
1112 import {
1213 type AgentRun,
4142
4243 /** Revalidates the page every few seconds while `live`, as the merge queue does. */
4344 export function useLiveRefresh(live: boolean) {
44− const revalidator = useRevalidator();
45− useEffect(() => {
46− if (!live) return;
47− const timer = setInterval(() => {
48− if (document.visibilityState === "visible" && revalidator.state === "idle") revalidator.revalidate();
49− }, LIVE_MS);
50− return () => clearInterval(timer);
51− }, [live, revalidator]);
45+ useRefreshWhile(live, LIVE_MS);
5246 }
5347
5448 export function formatCost(usd: number | null | undefined): string | null {
+23−0
1+import { useEffect } from "react";
2+import { useNavigation, useRevalidator } from "react-router";
3+
4+/** How often a page with something moving asks again. */
5+export const REFRESH_MS = 4000;
6+
7+/**
8+ * Revalidates the page every `everyMs` while `on` and the tab is visible.
9+ * Never while a navigation is pending: a revalidation then restarts the
10+ * navigation, aborting its loaders, so a click to a page slower than the
11+ * interval would never arrive. Never on top of a revalidation still going.
12+ */
13+export function useRefreshWhile(on: boolean, everyMs = REFRESH_MS) {
14+ const revalidator = useRevalidator();
15+ const navigating = useNavigation().state !== "idle";
16+ useEffect(() => {
17+ if (!on || navigating) return;
18+ const timer = setInterval(() => {
19+ if (document.visibilityState === "visible" && revalidator.state === "idle") revalidator.revalidate();
20+ }, everyMs);
21+ return () => clearInterval(timer);
22+ }, [on, navigating, everyMs, revalidator]);
23+}
+4−10
11 import { AlertTriangle, ChevronRight, Cloud, Download, GitBranch, GitCommitHorizontal, Info, Package, RotateCw, ServerCog, Square, XCircle } from "lucide-react";
2−import { type ReactNode, useEffect } from "react";
3−import { Form, Link, useNavigation, useRevalidator, useSearchParams } from "react-router";
2+import { type ReactNode } from "react";
3+import { Form, Link, useNavigation, useSearchParams } from "react-router";
44
55 import type { Annotation, Job, StepState } from "@g1t/contracts";
66
1212 import { actions } from "../../lib/services.server";
1313 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
1414 import { accessTo, refusal } from "../../lib/access.server";
15+import { useRefreshWhile } from "../../lib/refresh";
1516
1617 export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
1718 const run = loaderData?.detail.run;
170171 const base = `/${params.owner}/${params.repo}`;
171172 const [search] = useSearchParams();
172173 const busy = useNavigation().state === "submitting";
173− const revalidator = useRevalidator();
174174 const live = run.status !== "completed";
175− useEffect(() => {
176− if (!live) return;
177− const timer = setInterval(() => {
178− if (revalidator.state === "idle" && document.visibilityState === "visible") revalidator.revalidate();
179− }, 2500);
180− return () => clearInterval(timer);
181− }, [live, revalidator]);
175+ useRefreshWhile(live, 2500);
182176
183177 // The job asked for, else one that failed, is running, or the first.
184178 const selected =
+4−10
11 import { AlertTriangle, FileCode2, GitBranch, Play, PlayCircle } from "lucide-react";
2−import { useEffect, useState } from "react";
3−import { Form, Link, useNavigation, useRevalidator, useSearchParams } from "react-router";
2+import { useState } from "react";
3+import { Form, Link, useNavigation, useSearchParams } from "react-router";
44
55 import type { DispatchInput, Workflow, WorkflowRun } from "@g1t/contracts";
66
1515 import { actions } from "../../lib/services.server";
1616 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
1717 import { accessFor, refusal, repoFor } from "../../lib/access.server";
18+import { useRefreshWhile } from "../../lib/refresh";
1819
1920 export function meta({ params, ...args }: Route.MetaArgs) {
2021 return page(args, { title: `Workflows · ${params.owner}/${params.repo} · g1t` });
7879
7980 /** Re-reads the page every few seconds while something is still running. */
8081 function useLiveWhile(running: boolean) {
81− const revalidator = useRevalidator();
82− useEffect(() => {
83− if (!running) return;
84− const timer = setInterval(() => {
85− if (revalidator.state === "idle" && document.visibilityState === "visible") revalidator.revalidate();
86− }, 3000);
87− return () => clearInterval(timer);
88− }, [running, revalidator]);
82+ useRefreshWhile(running, 3000);
8983 }
9084
9185 const EVENT_WORDS: Record<string, string> = {
+3−11
11 import { env } from "cloudflare:workers";
22 import { Bot, ExternalLink, GitCommitHorizontal, GitMerge, Play, Sparkles } from "lucide-react";
3−import { useEffect } from "react";
4−import { Form, Link, redirect, useNavigation, useRevalidator } from "react-router";
3+import { Form, Link, redirect, useNavigation } from "react-router";
54
65 import { type Pull, PROVIDERS, workOwner } from "@g1t/contracts";
76
4140 import { identity, integrations, work } from "../../lib/services.server";
4241 import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
4342 import { accessTo, refusal } from "../../lib/access.server";
43+import { useRefreshWhile } from "../../lib/refresh";
4444
45−const REFRESH_MS = 4000;
4645
4746 export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
4847 const issue = loaderData?.issue;
236235 const { issue, pulls, comments, viewer, labels, agentsEnabled, members, canManage, can } = loaderData;
237236
238237 // Follow agents at work without a manual reload.
239− const revalidator = useRevalidator();
240238 const navigation = useNavigation();
241239 const running = pulls.some(
242240 (pull) =>
253251 pull.runtime === "hosted" &&
254252 (pull.status === "draft" || pull.status === "open"),
255253 );
256− useEffect(() => {
257− if (!running && !assigned) return;
258− const timer = setInterval(() => {
259− if (document.visibilityState === "visible") revalidator.revalidate();
260− }, REFRESH_MS);
261− return () => clearInterval(timer);
262− }, [running, assigned, revalidator]);
254+ useRefreshWhile(Boolean(running || assigned));
263255
264256 const starting = navigation.formData?.get("action") === "run-hosted";
265257 const base = `/${params.owner}/${params.repo}`;
+3−11
11 import { env } from "cloudflare:workers";
22 import type { G1tEvent } from "@g1t/contracts";
33 import { ArrowRight, CircleCheck, FileCode2, Sparkles } from "lucide-react";
4−import { useEffect } from "react";
5−import { Form, Link, data, useNavigation, useRevalidator } from "react-router";
4+import { Form, Link, data, useNavigation } from "react-router";
65
76 import type { Route } from "./+types/plan";
87 import { refusal, requireRepo } from "../../lib/access.server";
2120 requireUser,
2221 unwrap,
2322 } from "../../lib/session.server";
23+import { useRefreshWhile } from "../../lib/refresh";
2424
25−const REFRESH_MS = 4000;
2625
2726 export function meta({ params, ...args }: Route.MetaArgs) {
2827 return page(args, { title: `Plan · ${params.owner}/${params.repo} · g1t` });
9594 const applying = useNavigation().state === "submitting";
9695
9796 // The agent is still writing it.
98− const revalidator = useRevalidator();
9997 const planning = plan.status === "planning";
10098 // Planning, or agents still converging what was applied.
10199 const moving =
102100 planning || plan.progress.some((item) => item.state !== "landed" && item.state !== "closed");
103− useEffect(() => {
104− if (!moving) return;
105− const timer = setInterval(() => {
106− if (document.visibilityState === "visible") revalidator.revalidate();
107− }, REFRESH_MS);
108− return () => clearInterval(timer);
109− }, [moving, revalidator]);
101+ useRefreshWhile(moving);
110102 const converging = plan.status === "applied" && plan.progress.length > 0;
111103
112104 const independent = plan.issues.filter((issue) => issue.dependsOn.length === 0).length;
+4−11
2323 User,
2424 Wrench,
2525 } from "lucide-react";
26−import { Suspense, useEffect } from "react";
27−import { Await, Form, Link, redirect, useNavigation, useRevalidator } from "react-router";
26+import { Suspense } from "react";
27+import { Await, Form, Link, redirect, useNavigation } from "react-router";
2828
2929 import {
3030 type Capability,
7676 import { actions, deployments, identity, projects, repos, work } from "../../lib/services.server";
7777 import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
7878 import { accessTo, refusal } from "../../lib/access.server";
79+import { REFRESH_MS, useRefreshWhile } from "../../lib/refresh";
7980
80−const REFRESH_MS = 4000;
8181 const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false };
8282 const TABS = ["conversation", "session", "changes"] as const;
8383 type Tab = (typeof TABS)[number];
561561 const active = pull.status === "draft" || pull.status === "open";
562562
563563 // Follow an agent at work, or checks in progress, without a manual reload.
564− const revalidator = useRevalidator();
565564 const working = pull.status === "draft";
566565 const checking = statuses.some((status) => status.state === "pending");
567566 const reviews = verdicts(comments);
601600 // Whether it merges cleanly is being worked out, so the box will change.
602601 const probing = active && mergeable === "checking";
603602 const conflicting = active && mergeable === "conflicting";
604− useEffect(() => {
605− if (!working && !checking && !reviewPending && !catchingUp && !settling && !moving && !landing && !probing) return;
606− const timer = setInterval(() => {
607− if (document.visibilityState === "visible") revalidator.revalidate();
608− }, REFRESH_MS);
609− return () => clearInterval(timer);
610− }, [working, checking, reviewPending, catchingUp, settling, moving, landing, probing, revalidator]);
603+ useRefreshWhile(working || checking || reviewPending || catchingUp || settling || moving || landing || probing);
611604 // Why the merge button cannot be pressed, if it cannot.
612605 const mergeBlocked = conflicting
613606 ? "Resolve the conflicts first."
+3−11
88 Minus,
99 X,
1010 } from "lucide-react";
11−import { useEffect } from "react";
12−import { Link, useRevalidator } from "react-router";
11+import { Link } from "react-router";
1312
1413 import type { QueueEntry, QueueState } from "@g1t/contracts";
1514
1918 import { work } from "../../lib/services.server";
2019 import { getViewer, unwrap } from "../../lib/session.server";
2120 import { accessFor, repoFor } from "../../lib/access.server";
21+import { useRefreshWhile } from "../../lib/refresh";
2222
23−const REFRESH_MS = 4000;
2423
2524 export function meta({ params, ...args }: Route.MetaArgs) {
2625 return page(args, { title: `Merge queue · ${params.owner}/${params.repo} · g1t` });
149148 export default function Queue({ loaderData, params }: Route.ComponentProps) {
150149 const { queue, defaultBranch, member } = loaderData;
151150 const base = `/${params.owner}/${params.repo}`;
152− const revalidator = useRevalidator();
153151 const moving = queue.active.length > 0;
154− useEffect(() => {
155− if (!moving) return;
156− const timer = setInterval(() => {
157− if (document.visibilityState === "visible") revalidator.revalidate();
158− }, REFRESH_MS);
159− return () => clearInterval(timer);
160− }, [moving, revalidator]);
152+ useRefreshWhile(moving);
161153
162154 const testing = queue.active.filter((entry) => entry.state !== "waiting").length;
163155 return (
+3−9
11 import { CheckCircle2, Globe, Info, RotateCw, Trash2 } from "lucide-react";
22 import { useEffect, useState } from "react";
3−import { Form, Link, useNavigation, useRevalidator } from "react-router";
3+import { Form, Link, useNavigation } from "react-router";
44
55 import type { Domain, DomainRecord, DomainStatus } from "@g1t/contracts";
66
2323 import { deployments } from "../../lib/services.server";
2424 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
2525 import { requireCapability, requireInsider } from "../../lib/access.server";
26+import { useRefreshWhile } from "../../lib/refresh";
2627
2728 export function meta({ params, ...args }: Route.MetaArgs) {
2829 return page(args, { title: `Domains · ${params.owner}/${params.repo} · g1t` });
106107 }, [added, actionData]);
107108
108109 // While a domain is on its way, the page follows it.
109− const revalidator = useRevalidator();
110110 const waiting = domains.some((d) => d.status === "pending" || d.status === "verifying");
111− useEffect(() => {
112− if (!waiting) return;
113− const timer = setInterval(() => {
114− if (revalidator.state === "idle" && document.visibilityState === "visible") revalidator.revalidate();
115− }, 10_000);
116− return () => clearInterval(timer);
117− }, [waiting, revalidator]);
111+ useRefreshWhile(waiting, 10_000);
118112
119113 // A redirecting domain is shown under the one it redirects to.
120114 const primary = domains.filter((d) => !d.redirectTo || !domains.some((p) => p.hostname === d.redirectTo));