From 8b04147ca42b29382d8038f43a62d605d9b0cacc Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:42:04 -0500 Subject: [PATCH] perf(ui): stop polling the event-sourced company live-runs list (#9701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Its web UI keeps live views fresh with a live-events websocket plus React Query, and #9627 began replacing polling with event-sourcing (pushing data into the cache) > - After the churn fixes (#9624) and #9627 shipped, a long-lived tab's memory footprint was still climbing, so I re-profiled with the Chrome DevTools MCP > - The dominant remaining churn is React Query re-arming a polled query's `refetchInterval` timer on **every** observer notification — and our live-event handlers `setQueryData(liveRuns(companyId))` on nearly every event, so each pushed update re-arms every `liveRuns` observer's timer (the sidebar is always mounted) > - #9627 event-sourced the live-runs data but left the now-redundant `refetchInterval` in place, so we did half the fix — the poll is pure waste and the thing re-arming timers > - This pull request removes `refetchInterval` from the event-sourced company live-runs queries so the frequent cache writes have no timer to re-arm > - The benefit is that the steady-state timer churn on the most-observed resource collapses, so the off-heap footprint stops climbing ## Linked Issues or Issue Description No public GitHub issue exists; describing inline per CONTRIBUTING.md → "Link Issues or Describe Them In-PR", following the bug report template. Continues #9569 / #9624 / #9627. **What happened?** With the earlier fixes deployed, a browser tab left open on the app kept growing its memory footprint. MCP profiling showed ~100+ `setInterval` create/clear cycles per 5s on an aged tab (vs ~16 fresh), all from React Query's refetch-interval timers being re-armed on every `setQueryData` to the frequently-written `liveRuns(companyId)` query. **Expected behavior** A resource whose data is pushed (event-sourced) should not also poll; cache writes should not repeatedly re-arm interval timers. Idle tabs should hold a bounded footprint. **Steps to reproduce** Open a tab with agents streaming, leave it open, and instrument `setInterval`/`clearInterval`: the churn rate climbs and traces to `QueryObserver.updateTimers` (`refetchInterval`) for `liveRuns`, re-armed by every live-event cache write. **Paperclip version or commit** Branch `perf/drop-live-runs-refetch-interval`, off `master` (after #9627). **Deployment mode** Local dev (`pnpm dev`), web UI. Core UI live-updates plumbing; not adapter-specific. ## What Changed - Set `refetchInterval: false` on every site that polls the **plain** `queryKeys.liveRuns(companyId)` query (event-sourced by #9627): `Sidebar`, `SidebarAgents`, `Issues`, `Inbox`, `IssueDetail` (companyLiveRuns), `ProjectDetail` (×2), `Routines`, `ExecutionWorkspaceDetail`, `bridge-init`. - Removed the now-unused `useVisibilityRefetchInterval` interval vars/imports in `Issues`, `Inbox`, `IssueDetail`. - **Left variant-key sites polling on purpose** — `Agents` page (`[...liveRuns, "agents-page"]`) and `ActiveAgentsPanel` (`[...liveRuns, scope, …]`) are NOT event-sourced by #9627 (different exact cache key), so dropping their poll would make them stale. Those are a later phase. - Freshness for the converted queries now comes from event-sourcing (#9627) + its reconnect reconcile; the initial mount fetch and cross-tab publish (`usePublishSharedQueryData`) still happen. ## Verification - MCP profiling identified the churn: the single churning callback is React Query's `refetchInterval` timer, re-armed by `setQueryData(liveRuns)` on live events. - `vitest`: all affected suites pass (`Sidebar`, `SidebarAgents`, `Issues`, `Inbox`, `IssueDetail`, `ProjectDetail`, `Routines`, `ExecutionWorkspaceDetail`, `LiveUpdatesProvider`) — 153 tests. - Updated two `SidebarAgents` linger-window tests: they advanced fake timers to the *exact* linger-expiry boundary and had relied on poll-induced re-renders to flush. The linger self-schedules its own `setTimeout`, so the tests now cross the boundary with a small margin + an explicit flush (no product change). - `tsc -b` clean. - End-to-end footprint reduction should be re-measured against a rebuilt bundle with the same instrumentation. ## Risks Low, client-only. - `liveRuns(companyId)` freshness now depends entirely on event-sourcing + reconnect reconcile (both from #9627). If an event path is missed, the reconnect handler refetches once; durable replay is a planned later phase. - Variant-key run lists (Agents page, ActiveAgentsPanel) are unchanged and still poll, so they don't regress. - Issue-scoped run queries (`issues.liveRuns/activeRun/runs`) are **not** touched here — they aren't event-sourced yet and are a separate phase. ## Model Used - **Provider:** Anthropic, via the Claude Code CLI. - **Model:** Claude Opus 4.8 (`claude-opus-4-8`). - **Reasoning mode:** Extended thinking enabled. - **Capabilities used:** tool use (shell, file editing), and the Chrome DevTools MCP to re-profile the live instance and pinpoint the `refetchInterval` timer churn. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work (a perf/plumbing change) - [x] I have searched GitHub for duplicate or related PRs and linked them above (continues #9627; no duplicates) - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have considered and documented any risks above - [ ] I have updated relevant documentation to reflect my changes (N/A — no user-facing docs; rationale documented inline) - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- ui/src/components/Sidebar.tsx | 5 ++++- ui/src/components/SidebarAgents.test.tsx | 9 ++++++--- ui/src/components/SidebarAgents.tsx | 3 ++- ui/src/pages/ExecutionWorkspaceDetail.tsx | 3 ++- ui/src/pages/Inbox.tsx | 5 ++--- ui/src/pages/IssueDetail.tsx | 5 ++--- ui/src/pages/Issues.tsx | 5 ++--- ui/src/pages/ProjectDetail.tsx | 6 ++++-- ui/src/pages/Routines.tsx | 3 ++- ui/src/plugins/bridge-init.ts | 3 ++- 10 files changed, 28 insertions(+), 19 deletions(-) diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 613baa6278..429ca9c42b 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -68,7 +68,10 @@ export function Sidebar() { resourceKey: "live-runs", queryKey: liveRunsQueryKey, enabled: !!selectedCompanyId, - refetchInterval: 10_000, + // Event-sourced via LiveUpdatesProvider (#9627) + reconnect reconcile — no + // interval poll needed. Polling here also re-armed React Query's timer on + // every live-event cache write, a major source of steady-state churn. + refetchInterval: false, leaderOnly: true, }); const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({ diff --git a/ui/src/components/SidebarAgents.test.tsx b/ui/src/components/SidebarAgents.test.tsx index 1cdfdd49a8..3ee1aeea3b 100644 --- a/ui/src/components/SidebarAgents.test.tsx +++ b/ui/src/components/SidebarAgents.test.tsx @@ -702,7 +702,8 @@ describe("SidebarAgents", () => { expect(agentLinkLabels(container)).toEqual(["Alpha"]); await act(async () => { - await vi.advanceTimersByTimeAsync(1); + await vi.advanceTimersByTimeAsync(5); + await vi.advanceTimersByTimeAsync(0); }); expect(agentLinkLabels(container)).toEqual(["Alpha", "Bravo", "Charlie"]); }); @@ -758,12 +759,14 @@ describe("SidebarAgents", () => { expect(agentLinkLabels(container)).toEqual(["Alpha", "Bravo"]); await act(async () => { - await vi.advanceTimersByTimeAsync(60_001); + await vi.advanceTimersByTimeAsync(60_005); + await vi.advanceTimersByTimeAsync(0); }); expect(agentLinkLabels(container)).toEqual(["Bravo"]); await act(async () => { - await vi.advanceTimersByTimeAsync(60_000); + await vi.advanceTimersByTimeAsync(60_005); + await vi.advanceTimersByTimeAsync(0); }); expect(agentLinkLabels(container)).toEqual(["Alpha", "Bravo", "Charlie"]); }); diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index da1f9dde4f..b344364d2f 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -339,7 +339,8 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } resourceKey: "live-runs", queryKey: liveRunsQueryKey, enabled: !!selectedCompanyId, - refetchInterval: 10_000, + // Event-sourced via LiveUpdatesProvider (#9627); no interval poll needed. + refetchInterval: false, leaderOnly: true, }); const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({ diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index e130cf1b21..c2cac0b629 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -445,7 +445,8 @@ function ExecutionWorkspaceIssuesList({ resourceKey: "live-runs", queryKey: liveRunsQueryKey, enabled: !!companyId, - refetchInterval: 5000, + // Event-sourced via LiveUpdatesProvider (#9627); no interval poll needed. + refetchInterval: false, leaderOnly: true, }); const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({ diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index f3ff80fe12..16355d1f5e 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -2,7 +2,6 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } fro import { Link, useLocation, useNavigate } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { deriveOriginatingActor, INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared"; -import { useVisibilityRefetchInterval } from "@/lib/polling"; import { usePublishSharedQueryData, useSharedPollingQuery } from "@/hooks/useSharedPolling"; import { approvalsApi } from "../api/approvals"; import { accessApi } from "../api/access"; @@ -917,14 +916,14 @@ export function Inbox() { refetchOnWindowFocus: false, staleTime: INBOX_HOT_PATH_STALE_MS, }); - const liveRunsRefetchInterval = useVisibilityRefetchInterval({ visibleMs: 5000 }); const liveRunsQueryKey = queryKeys.liveRuns(selectedCompanyId!); const sharedLiveRuns = useSharedPollingQuery({ companyId: selectedCompanyId, resourceKey: "live-runs", queryKey: liveRunsQueryKey, enabled: !!selectedCompanyId, - refetchInterval: liveRunsRefetchInterval, + // Event-sourced via LiveUpdatesProvider (#9627); no interval poll needed. + refetchInterval: false, leaderOnly: true, }); const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({ diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 694fd42a70..acfc5ea85a 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -2,7 +2,6 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEve import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { Link, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router"; import { useInfiniteQuery, useQuery, useMutation, useQueryClient, type InfiniteData, type QueryClient } from "@tanstack/react-query"; -import { useVisibilityRefetchInterval } from "@/lib/polling"; import { usePublishSharedQueryData, useSharedPollingQuery } from "@/hooks/useSharedPolling"; import { ApiError } from "../api/client"; import { issuesApi } from "../api/issues"; @@ -1684,14 +1683,14 @@ export function IssueDetail() { queryFn: () => issuesApi.list(resolvedCompanyId!, { parentId: issue!.parentId!, includeBlockedBy: true }), enabled: !!resolvedCompanyId && !!issue?.parentId, }); - const companyLiveRunsRefetchInterval = useVisibilityRefetchInterval({ visibleMs: 5000 }); const companyLiveRunsQueryKey = resolvedCompanyId ? queryKeys.liveRuns(resolvedCompanyId) : ["live-runs", "pending"] as const; const sharedCompanyLiveRuns = useSharedPollingQuery({ companyId: resolvedCompanyId, resourceKey: "live-runs", queryKey: companyLiveRunsQueryKey, enabled: !!resolvedCompanyId, - refetchInterval: companyLiveRunsRefetchInterval, + // Event-sourced via LiveUpdatesProvider (#9627); no interval poll needed. + refetchInterval: false, leaderOnly: true, }); const { data: companyLiveRuns, dataUpdatedAt: companyLiveRunsUpdatedAt } = useQuery({ diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index c523e6edfa..97e367c0e7 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -8,7 +8,6 @@ import { heartbeatsApi } from "../api/heartbeats"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { collectLiveIssueIds } from "../lib/liveIssueIds"; -import { useVisibilityRefetchInterval } from "@/lib/polling"; import { usePublishSharedQueryData, useSharedPollingQuery } from "@/hooks/useSharedPolling"; import { queryKeys } from "../lib/queryKeys"; import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; @@ -98,14 +97,14 @@ export function Issues() { enabled: !!selectedCompanyId, }); - const liveRunsRefetchInterval = useVisibilityRefetchInterval({ visibleMs: 5000 }); const liveRunsQueryKey = queryKeys.liveRuns(selectedCompanyId!); const sharedLiveRuns = useSharedPollingQuery({ companyId: selectedCompanyId, resourceKey: "live-runs", queryKey: liveRunsQueryKey, enabled: !!selectedCompanyId, - refetchInterval: liveRunsRefetchInterval, + // Event-sourced via LiveUpdatesProvider (#9627); no interval poll needed. + refetchInterval: false, leaderOnly: true, }); const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({ diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index 1c9b881bfd..b5bbcd03c2 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -241,7 +241,8 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan resourceKey: "live-runs", queryKey: liveRunsQueryKey, enabled: !!companyId, - refetchInterval: 5000, + // Event-sourced via LiveUpdatesProvider (#9627); no interval poll needed. + refetchInterval: false, leaderOnly: true, }); const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({ @@ -317,7 +318,8 @@ function ProjectPluginOperationsList({ resourceKey: "live-runs", queryKey: liveRunsQueryKey, enabled: !!companyId, - refetchInterval: 5000, + // Event-sourced via LiveUpdatesProvider (#9627); no interval poll needed. + refetchInterval: false, leaderOnly: true, }); const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({ diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx index 0b7e33d68e..58c02dce38 100644 --- a/ui/src/pages/Routines.tsx +++ b/ui/src/pages/Routines.tsx @@ -342,7 +342,8 @@ export function Routines() { resourceKey: "live-runs", queryKey: liveRunsQueryKey, enabled: !!selectedCompanyId && activeTab === "runs", - refetchInterval: 5000, + // Event-sourced via LiveUpdatesProvider (#9627); no interval poll needed. + refetchInterval: false, leaderOnly: true, }); const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({ diff --git a/ui/src/plugins/bridge-init.ts b/ui/src/plugins/bridge-init.ts index a279c5e0bd..f6370417bd 100644 --- a/ui/src/plugins/bridge-init.ts +++ b/ui/src/plugins/bridge-init.ts @@ -277,7 +277,8 @@ function PluginSdkIssuesList({ resourceKey: "live-runs", queryKey: liveRunsQueryKey, enabled: !!companyId, - refetchInterval: 5000, + // Event-sourced via LiveUpdatesProvider (#9627); no interval poll needed. + refetchInterval: false, leaderOnly: true, }); const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({