From e86f0cfd8fb3e96794b144944b6375ba24c0e38b Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Fri, 13 Feb 2026 01:24:43 +0800 Subject: [PATCH] perf: optimize polling with caching and event-driven approach (P1-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLI binary detection cached with 60s TTL - Settings poll replaced with visibility/focus events - Health check uses adaptive backoff (30s → 60s when stable) Co-Authored-By: Claude Opus 4.6 --- src/components/layout/AppShell.tsx | 15 ++++-- src/components/layout/ConnectionStatus.tsx | 53 +++++++++++++++++++--- src/lib/platform.ts | 19 +++++++- 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 81537d3..b1663b0 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -150,11 +150,20 @@ export function AppShell({ children }: { children: React.ReactNode }) { } }, []); - // Poll periodically so the indicator stays in sync when settings change + // Re-fetch when window gains focus / becomes visible instead of polling every 5s useEffect(() => { fetchSkipPermissions(); - const id = setInterval(fetchSkipPermissions, 5000); - return () => clearInterval(id); + const handleVisibility = () => { + if (document.visibilityState === "visible") { + fetchSkipPermissions(); + } + }; + document.addEventListener("visibilitychange", handleVisibility); + window.addEventListener("focus", fetchSkipPermissions); + return () => { + document.removeEventListener("visibilitychange", handleVisibility); + window.removeEventListener("focus", fetchSkipPermissions); + }; }, [fetchSkipPermissions]); // --- Update check state --- diff --git a/src/components/layout/ConnectionStatus.tsx b/src/components/layout/ConnectionStatus.tsx index 0c5980a..9e69041 100644 --- a/src/components/layout/ConnectionStatus.tsx +++ b/src/components/layout/ConnectionStatus.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useRef } from "react"; import { Dialog, DialogContent, @@ -17,26 +17,67 @@ interface ClaudeStatus { version: string | null; } +const BASE_INTERVAL = 30_000; // 30s +const BACKED_OFF_INTERVAL = 60_000; // 60s after 3 consecutive stable results +const STABLE_THRESHOLD = 3; + export function ConnectionStatus() { const [status, setStatus] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); + const stableCountRef = useRef(0); + const lastConnectedRef = useRef(null); + const timerRef = useRef | null>(null); + + // Use a ref-based approach to avoid circular deps between check and schedule + const checkRef = useRef<() => void>(() => {}); + + const schedule = useCallback(() => { + if (timerRef.current) clearTimeout(timerRef.current); + const interval = stableCountRef.current >= STABLE_THRESHOLD + ? BACKED_OFF_INTERVAL + : BASE_INTERVAL; + timerRef.current = setTimeout(() => checkRef.current(), interval); + }, []); const checkStatus = useCallback(async () => { try { const res = await fetch("/api/claude-status"); if (res.ok) { const data: ClaudeStatus = await res.json(); + if (lastConnectedRef.current === data.connected) { + stableCountRef.current++; + } else { + stableCountRef.current = 0; + } + lastConnectedRef.current = data.connected; setStatus(data); } } catch { + if (lastConnectedRef.current === false) { + stableCountRef.current++; + } else { + stableCountRef.current = 0; + } + lastConnectedRef.current = false; setStatus({ connected: false, version: null }); } - }, []); + schedule(); + }, [schedule]); useEffect(() => { + checkRef.current = checkStatus; + }, [checkStatus]); + + useEffect(() => { + checkStatus(); // eslint-disable-line react-hooks/set-state-in-effect -- setState is called asynchronously after fetch + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [checkStatus]); + + const handleManualRefresh = useCallback(() => { + stableCountRef.current = 0; checkStatus(); - const interval = setInterval(checkStatus, 30000); - return () => clearInterval(interval); }, [checkStatus]); const connected = status?.connected ?? false; @@ -127,9 +168,7 @@ export function ConnectionStatus() { diff --git a/src/lib/platform.ts b/src/lib/platform.ts index fa4d0ea..44796a0 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -95,11 +95,28 @@ export function getExpandedPath(): string { return parts.join(path.delimiter); } +// TTL cache for findClaudeBinary to avoid repeated filesystem probes +let _cachedBinaryPath: string | undefined | null = null; // null = not cached +let _cachedBinaryTimestamp = 0; +const BINARY_CACHE_TTL = 60_000; // 60 seconds + /** * Find and validate the Claude CLI binary. - * Tests each candidate with --version before returning. + * Results are cached for 60s to avoid redundant filesystem probes on every poll. */ export function findClaudeBinary(): string | undefined { + const now = Date.now(); + if (_cachedBinaryPath !== null && now - _cachedBinaryTimestamp < BINARY_CACHE_TTL) { + return _cachedBinaryPath; + } + + const found = _findClaudeBinaryUncached(); + _cachedBinaryPath = found; + _cachedBinaryTimestamp = now; + return found; +} + +function _findClaudeBinaryUncached(): string | undefined { // Try known candidate paths first for (const p of getClaudeCandidatePaths()) { try {