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.
9 files+50−820/9 viewed
| 6 | 6 | */ | |
| 7 | 7 | import { Bot, CircleSlash, Clock, Coins, Gauge, Loader2, MessageSquare, OctagonX, Square, TriangleAlert } from "lucide-react"; | |
| 8 | 8 | 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"; | |
| 10 | 11 | ||
| 11 | 12 | import { | |
| 12 | 13 | type AgentRun, | |
| 41 | 42 | ||
| 42 | 43 | /** Revalidates the page every few seconds while `live`, as the merge queue does. */ | |
| 43 | 44 | 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); | |
| 52 | 46 | } | |
| 53 | 47 | ||
| 54 | 48 | export function formatCost(usd: number | null | undefined): string | null { |
| 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 | + | } |
| 1 | 1 | 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"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Annotation, Job, StepState } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 12 | 12 | import { actions } from "../../lib/services.server"; | |
| 13 | 13 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 14 | 14 | import { accessTo, refusal } from "../../lib/access.server"; | |
| 15 | + | import { useRefreshWhile } from "../../lib/refresh"; | |
| 15 | 16 | ||
| 16 | 17 | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { | |
| 17 | 18 | const run = loaderData?.detail.run; | |
| 170 | 171 | const base = `/${params.owner}/${params.repo}`; | |
| 171 | 172 | const [search] = useSearchParams(); | |
| 172 | 173 | const busy = useNavigation().state === "submitting"; | |
| 173 | − | const revalidator = useRevalidator(); | |
| 174 | 174 | 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); | |
| 182 | 176 | ||
| 183 | 177 | // The job asked for, else one that failed, is running, or the first. | |
| 184 | 178 | const selected = |
| 1 | 1 | 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"; | |
| 4 | 4 | ||
| 5 | 5 | import type { DispatchInput, Workflow, WorkflowRun } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 15 | 15 | import { actions } from "../../lib/services.server"; | |
| 16 | 16 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 17 | 17 | import { accessFor, refusal, repoFor } from "../../lib/access.server"; | |
| 18 | + | import { useRefreshWhile } from "../../lib/refresh"; | |
| 18 | 19 | ||
| 19 | 20 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 20 | 21 | return page(args, { title: `Workflows · ${params.owner}/${params.repo} · g1t` }); | |
| 78 | 79 | ||
| 79 | 80 | /** Re-reads the page every few seconds while something is still running. */ | |
| 80 | 81 | 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); | |
| 89 | 83 | } | |
| 90 | 84 | ||
| 91 | 85 | const EVENT_WORDS: Record<string, string> = { |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | 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"; | |
| 5 | 4 | ||
| 6 | 5 | import { type Pull, PROVIDERS, workOwner } from "@g1t/contracts"; | |
| 7 | 6 | ||
| 41 | 40 | import { identity, integrations, work } from "../../lib/services.server"; | |
| 42 | 41 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 43 | 42 | import { accessTo, refusal } from "../../lib/access.server"; | |
| 43 | + | import { useRefreshWhile } from "../../lib/refresh"; | |
| 44 | 44 | ||
| 45 | − | const REFRESH_MS = 4000; | |
| 46 | 45 | ||
| 47 | 46 | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { | |
| 48 | 47 | const issue = loaderData?.issue; | |
| 236 | 235 | const { issue, pulls, comments, viewer, labels, agentsEnabled, members, canManage, can } = loaderData; | |
| 237 | 236 | ||
| 238 | 237 | // Follow agents at work without a manual reload. | |
| 239 | − | const revalidator = useRevalidator(); | |
| 240 | 238 | const navigation = useNavigation(); | |
| 241 | 239 | const running = pulls.some( | |
| 242 | 240 | (pull) => | |
| 253 | 251 | pull.runtime === "hosted" && | |
| 254 | 252 | (pull.status === "draft" || pull.status === "open"), | |
| 255 | 253 | ); | |
| 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)); | |
| 263 | 255 | ||
| 264 | 256 | const starting = navigation.formData?.get("action") === "run-hosted"; | |
| 265 | 257 | const base = `/${params.owner}/${params.repo}`; |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | import type { G1tEvent } from "@g1t/contracts"; | |
| 3 | 3 | 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"; | |
| 6 | 5 | ||
| 7 | 6 | import type { Route } from "./+types/plan"; | |
| 8 | 7 | import { refusal, requireRepo } from "../../lib/access.server"; | |
| 21 | 20 | requireUser, | |
| 22 | 21 | unwrap, | |
| 23 | 22 | } from "../../lib/session.server"; | |
| 23 | + | import { useRefreshWhile } from "../../lib/refresh"; | |
| 24 | 24 | ||
| 25 | − | const REFRESH_MS = 4000; | |
| 26 | 25 | ||
| 27 | 26 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 28 | 27 | return page(args, { title: `Plan · ${params.owner}/${params.repo} · g1t` }); | |
| 95 | 94 | const applying = useNavigation().state === "submitting"; | |
| 96 | 95 | ||
| 97 | 96 | // The agent is still writing it. | |
| 98 | − | const revalidator = useRevalidator(); | |
| 99 | 97 | const planning = plan.status === "planning"; | |
| 100 | 98 | // Planning, or agents still converging what was applied. | |
| 101 | 99 | const moving = | |
| 102 | 100 | 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); | |
| 110 | 102 | const converging = plan.status === "applied" && plan.progress.length > 0; | |
| 111 | 103 | ||
| 112 | 104 | const independent = plan.issues.filter((issue) => issue.dependsOn.length === 0).length; |
| 23 | 23 | User, | |
| 24 | 24 | Wrench, | |
| 25 | 25 | } 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"; | |
| 28 | 28 | ||
| 29 | 29 | import { | |
| 30 | 30 | type Capability, | |
| 76 | 76 | import { actions, deployments, identity, projects, repos, work } from "../../lib/services.server"; | |
| 77 | 77 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; | |
| 78 | 78 | import { accessTo, refusal } from "../../lib/access.server"; | |
| 79 | + | import { REFRESH_MS, useRefreshWhile } from "../../lib/refresh"; | |
| 79 | 80 | ||
| 80 | − | const REFRESH_MS = 4000; | |
| 81 | 81 | const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false }; | |
| 82 | 82 | const TABS = ["conversation", "session", "changes"] as const; | |
| 83 | 83 | type Tab = (typeof TABS)[number]; | |
| 561 | 561 | const active = pull.status === "draft" || pull.status === "open"; | |
| 562 | 562 | ||
| 563 | 563 | // Follow an agent at work, or checks in progress, without a manual reload. | |
| 564 | − | const revalidator = useRevalidator(); | |
| 565 | 564 | const working = pull.status === "draft"; | |
| 566 | 565 | const checking = statuses.some((status) => status.state === "pending"); | |
| 567 | 566 | const reviews = verdicts(comments); | |
| 601 | 600 | // Whether it merges cleanly is being worked out, so the box will change. | |
| 602 | 601 | const probing = active && mergeable === "checking"; | |
| 603 | 602 | 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); | |
| 611 | 604 | // Why the merge button cannot be pressed, if it cannot. | |
| 612 | 605 | const mergeBlocked = conflicting | |
| 613 | 606 | ? "Resolve the conflicts first." |
| 8 | 8 | Minus, | |
| 9 | 9 | X, | |
| 10 | 10 | } from "lucide-react"; | |
| 11 | − | import { useEffect } from "react"; | |
| 12 | − | import { Link, useRevalidator } from "react-router"; | |
| 11 | + | import { Link } from "react-router"; | |
| 13 | 12 | ||
| 14 | 13 | import type { QueueEntry, QueueState } from "@g1t/contracts"; | |
| 15 | 14 | ||
| 19 | 18 | import { work } from "../../lib/services.server"; | |
| 20 | 19 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 21 | 20 | import { accessFor, repoFor } from "../../lib/access.server"; | |
| 21 | + | import { useRefreshWhile } from "../../lib/refresh"; | |
| 22 | 22 | ||
| 23 | − | const REFRESH_MS = 4000; | |
| 24 | 23 | ||
| 25 | 24 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 26 | 25 | return page(args, { title: `Merge queue · ${params.owner}/${params.repo} · g1t` }); | |
| 149 | 148 | export default function Queue({ loaderData, params }: Route.ComponentProps) { | |
| 150 | 149 | const { queue, defaultBranch, member } = loaderData; | |
| 151 | 150 | const base = `/${params.owner}/${params.repo}`; | |
| 152 | − | const revalidator = useRevalidator(); | |
| 153 | 151 | 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); | |
| 161 | 153 | ||
| 162 | 154 | const testing = queue.active.filter((entry) => entry.state !== "waiting").length; | |
| 163 | 155 | return ( |
| 1 | 1 | import { CheckCircle2, Globe, Info, RotateCw, Trash2 } from "lucide-react"; | |
| 2 | 2 | import { useEffect, useState } from "react"; | |
| 3 | − | import { Form, Link, useNavigation, useRevalidator } from "react-router"; | |
| 3 | + | import { Form, Link, useNavigation } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Domain, DomainRecord, DomainStatus } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 23 | 23 | import { deployments } from "../../lib/services.server"; | |
| 24 | 24 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 25 | 25 | import { requireCapability, requireInsider } from "../../lib/access.server"; | |
| 26 | + | import { useRefreshWhile } from "../../lib/refresh"; | |
| 26 | 27 | ||
| 27 | 28 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 28 | 29 | return page(args, { title: `Domains · ${params.owner}/${params.repo} · g1t` }); | |
| 106 | 107 | }, [added, actionData]); | |
| 107 | 108 | ||
| 108 | 109 | // While a domain is on its way, the page follows it. | |
| 109 | − | const revalidator = useRevalidator(); | |
| 110 | 110 | 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); | |
| 118 | 112 | ||
| 119 | 113 | // A redirecting domain is shown under the one it redirects to. | |
| 120 | 114 | const primary = domains.filter((d) => !d.redirectTo || !domains.some((p) => p.hostname === d.redirectTo)); |