From fc6c69f03d8e185c1f97a1d086c47bfb97f94b86 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 2 Mar 2026 23:03:36 -0800 Subject: [PATCH 1/4] Changes from fix/dev-server-hang --- apps/server/src/index.ts | 17 ++++--- .../server/src/services/dev-server-service.ts | 10 +++-- .../services/dev-server-event-types.test.ts | 4 +- .../board-view/hooks/use-board-features.ts | 7 +-- .../hooks/use-dev-server-logs.ts | 44 ++++++++++++++++--- .../worktree-panel/hooks/use-dev-servers.ts | 22 ++++++++-- apps/ui/src/lib/http-api-client.ts | 20 +++++---- apps/ui/vite.config.mts | 26 ++++++++--- 8 files changed, 108 insertions(+), 42 deletions(-) diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 2b48f662f..cc0c3fbc1 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -598,24 +598,23 @@ wss.on('connection', (ws: WebSocket) => { // Subscribe to all events and forward to this client const unsubscribe = events.subscribe((type, payload) => { - logger.info('Event received:', { + // Use debug level for high-frequency events to avoid log spam + // that causes progressive memory growth and server slowdown + const isHighFrequency = + type === 'dev-server:output' || type === 'test-runner:output' || type === 'feature:progress'; + const log = isHighFrequency ? logger.debug.bind(logger) : logger.info.bind(logger); + + log('Event received:', { type, hasPayload: !!payload, - payloadKeys: payload ? Object.keys(payload) : [], wsReadyState: ws.readyState, - wsOpen: ws.readyState === WebSocket.OPEN, }); if (ws.readyState === WebSocket.OPEN) { const message = JSON.stringify({ type, payload }); - logger.info('Sending event to client:', { - type, - messageLength: message.length, - sessionId: (payload as Record)?.sessionId, - }); ws.send(message); } else { - logger.info('WARNING: Cannot send event, WebSocket not open. ReadyState:', ws.readyState); + logger.warn('Cannot send event, WebSocket not open. ReadyState:', ws.readyState); } }); diff --git a/apps/server/src/services/dev-server-service.ts b/apps/server/src/services/dev-server-service.ts index 6cef17dc8..319e9895f 100644 --- a/apps/server/src/services/dev-server-service.ts +++ b/apps/server/src/services/dev-server-service.ts @@ -88,9 +88,13 @@ const PORT_PATTERNS: Array<{ pattern: RegExp; description: string }> = [ }, ]; -// Throttle output to prevent overwhelming WebSocket under heavy load -const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback -const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency +// Throttle output to prevent overwhelming WebSocket under heavy load. +// 100ms (~10fps) is sufficient for readable log streaming while keeping +// WebSocket traffic manageable. The previous 4ms rate (~250fps) generated +// up to 250 events/sec which caused progressive browser slowdown from +// accumulated console logs, JSON serialization overhead, and React re-renders. +const OUTPUT_THROTTLE_MS = 100; // ~10fps max update rate +const OUTPUT_BATCH_SIZE = 8192; // Larger batches to compensate for lower frequency export interface DevServerInfo { worktreePath: string; diff --git a/apps/server/tests/unit/services/dev-server-event-types.test.ts b/apps/server/tests/unit/services/dev-server-event-types.test.ts index 95fdfd943..5bf4dcbfa 100644 --- a/apps/server/tests/unit/services/dev-server-event-types.test.ts +++ b/apps/server/tests/unit/services/dev-server-event-types.test.ts @@ -90,8 +90,8 @@ describe('DevServerService Event Types', () => { // 2. Output & URL Detected mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5173/\n')); - // Throttled output needs a bit of time - await new Promise((resolve) => setTimeout(resolve, 100)); + // Throttled output needs a bit of time (OUTPUT_THROTTLE_MS is 100ms) + await new Promise((resolve) => setTimeout(resolve, 250)); expect(emittedEvents['dev-server:output'].length).toBeGreaterThanOrEqual(1); expect(emittedEvents['dev-server:url-detected'].length).toBe(1); expect(emittedEvents['dev-server:url-detected'][0].url).toBe('http://localhost:5173/'); diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-features.ts b/apps/ui/src/components/views/board-view/hooks/use-board-features.ts index 9068af89a..203b30173 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-features.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-features.ts @@ -32,11 +32,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) { const isRestoring = useIsRestoring(); // Use React Query for features - const { - data: features = [], - isLoading: isQueryLoading, - refetch: loadFeatures, - } = useFeatures(currentProject?.path); + const { data: features = [], isLoading: isQueryLoading } = useFeatures(currentProject?.path); // Don't report loading while IDB cache restore is in progress — // features will appear momentarily once the restore completes. @@ -159,7 +155,6 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) { }); return unsubscribe; - // eslint-disable-next-line react-hooks/exhaustive-deps -- loadFeatures is a stable ref from React Query }, [currentProject]); // Check for interrupted features on mount diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-server-logs.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-server-logs.ts index 2d5dd8abb..cf54aa6a3 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-server-logs.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-server-logs.ts @@ -146,11 +146,18 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS hasFetchedInitialLogs.current = false; }, []); - /** - * Append content to logs, enforcing a maximum buffer size to prevent - * unbounded memory growth and progressive UI lag. - */ - const appendLogs = useCallback((content: string) => { + // Buffer for batching rapid output events into fewer setState calls. + // Content accumulates here and is flushed via requestAnimationFrame, + // ensuring at most one React re-render per animation frame (~60fps max). + const pendingOutputRef = useRef(''); + const rafIdRef = useRef(null); + + const flushPendingOutput = useCallback(() => { + rafIdRef.current = null; + const content = pendingOutputRef.current; + if (!content) return; + pendingOutputRef.current = ''; + setState((prev) => { const combined = prev.logs + content; const didTrim = combined.length > MAX_LOG_BUFFER_SIZE; @@ -170,6 +177,33 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS }); }, []); + /** + * Append content to logs, enforcing a maximum buffer size to prevent + * unbounded memory growth and progressive UI lag. + * + * Uses requestAnimationFrame to batch rapid output events into at most + * one React state update per frame, preventing excessive re-renders. + */ + const appendLogs = useCallback( + (content: string) => { + pendingOutputRef.current += content; + if (rafIdRef.current === null) { + rafIdRef.current = requestAnimationFrame(flushPendingOutput); + } + }, + [flushPendingOutput] + ); + + // Clean up pending RAF on unmount to prevent state updates after unmount + useEffect(() => { + return () => { + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = null; + } + }; + }, []); + // Fetch initial logs when worktreePath changes useEffect(() => { if (worktreePath && autoSubscribe) { diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-servers.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-servers.ts index 9499ee15e..ccf2caa9e 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-servers.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-servers.ts @@ -4,13 +4,17 @@ import { getElectronAPI } from '@/lib/electron'; import { normalizePath } from '@/lib/utils'; import { toast } from 'sonner'; import type { DevServerInfo, WorktreeInfo } from '../types'; +import { useEventRecencyStore } from '@/hooks/use-event-recency'; const logger = createLogger('DevServers'); // Timeout (ms) for port detection before showing a warning to the user const PORT_DETECTION_TIMEOUT_MS = 30_000; -// Interval (ms) for periodic state reconciliation with the backend -const STATE_RECONCILE_INTERVAL_MS = 5_000; +// Interval (ms) for periodic state reconciliation with the backend. +// 30 seconds is sufficient since WebSocket events handle real-time updates; +// reconciliation is only a fallback for missed events (PWA restart, WS gaps). +// The previous 5-second interval added unnecessary HTTP pressure. +const STATE_RECONCILE_INTERVAL_MS = 30_000; interface UseDevServersOptions { projectPath: string; @@ -322,12 +326,24 @@ export function useDevServers({ projectPath }: UseDevServersOptions) { return () => clearInterval(intervalId); }, [clearPortDetectionTimer, startPortDetectionTimer]); + // Record global events so smart polling knows WebSocket is healthy. + // Without this, dev-server events don't suppress polling intervals, + // causing all queries (features, worktrees, running-agents) to poll + // at their default rates even though the WebSocket is actively connected. + const recordGlobalEvent = useEventRecencyStore((state) => state.recordGlobalEvent); + // Subscribe to all dev server lifecycle events for reactive state updates useEffect(() => { const api = getElectronAPI(); if (!api?.worktree?.onDevServerLogEvent) return; const unsubscribe = api.worktree.onDevServerLogEvent((event) => { + // Record that WS is alive (but only for lifecycle events, not output - + // output fires too frequently and would trigger unnecessary store updates) + if (event.type !== 'dev-server:output') { + recordGlobalEvent(); + } + if (event.type === 'dev-server:starting') { const { worktreePath } = event.payload; const key = normalizePath(worktreePath); @@ -424,7 +440,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) { }); return unsubscribe; - }, [clearPortDetectionTimer, startPortDetectionTimer]); + }, [clearPortDetectionTimer, startPortDetectionTimer, recordGlobalEvent]); // Cleanup all port detection timers on unmount useEffect(() => { diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index d3dc9fb92..efaf92e3c 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -923,17 +923,19 @@ export class HttpApiClient implements ElectronAPI { this.ws.onmessage = (event) => { try { const data = JSON.parse(event.data); - logger.info( - 'WebSocket message:', - data.type, - 'hasPayload:', - !!data.payload, - 'callbacksRegistered:', - this.eventCallbacks.has(data.type) - ); + // Only log non-high-frequency events to avoid progressive memory growth + // from accumulated console entries. High-frequency events (dev-server output, + // test runner output, agent progress) fire 10+ times/sec and would generate + // thousands of console entries per minute. + const isHighFrequency = + data.type === 'dev-server:output' || + data.type === 'test-runner:output' || + data.type === 'auto_mode_progress'; + if (!isHighFrequency) { + logger.info('WebSocket message:', data.type); + } const callbacks = this.eventCallbacks.get(data.type); if (callbacks) { - logger.info('Dispatching to', callbacks.size, 'callbacks'); callbacks.forEach((cb) => cb(data.payload)); } } catch (error) { diff --git a/apps/ui/vite.config.mts b/apps/ui/vite.config.mts index 2cd413578..ab4f91346 100644 --- a/apps/ui/vite.config.mts +++ b/apps/ui/vite.config.mts @@ -238,14 +238,16 @@ export default defineConfig(({ command }) => { // Inject build hash into sw.js CACHE_NAME for automatic cache busting swCacheBuster(), ], + // Keep Vite dep-optimization cache local to apps/ui so each worktree gets + // its own pre-bundled dependencies. Shared cache state across worktrees can + // produce duplicate React instances (notably with @xyflow/react) and trigger + // "Invalid hook call" in the graph view. + cacheDir: path.resolve(__dirname, 'node_modules/.vite'), resolve: { alias: [ { find: '@', replacement: path.resolve(__dirname, './src') }, // Force ALL React imports (including from nested deps like zustand@4 inside - // @xyflow/react) to resolve to the single copy in the workspace root node_modules. - // This prevents "Cannot read properties of null (reading 'useState')" caused by - // react-dom setting the hooks dispatcher on one React instance while component - // code reads it from a different instance. + // @xyflow/react) to resolve to a single copy. { find: /^react-dom(\/|$)/, replacement: path.resolve(__dirname, '../../node_modules/react-dom') + '/', @@ -254,8 +256,18 @@ export default defineConfig(({ command }) => { find: /^react(\/|$)/, replacement: path.resolve(__dirname, '../../node_modules/react') + '/', }, + // Explicit subpath aliases avoid mixed module IDs between bare imports and + // optimized deps (e.g. react/jsx-runtime), which can manifest as duplicate React. + { + find: 'react/jsx-runtime', + replacement: path.resolve(__dirname, '../../node_modules/react/jsx-runtime.js'), + }, + { + find: 'react/jsx-dev-runtime', + replacement: path.resolve(__dirname, '../../node_modules/react/jsx-dev-runtime.js'), + }, ], - dedupe: ['react', 'react-dom'], + dedupe: ['react', 'react-dom', 'zustand', 'use-sync-external-store', '@xyflow/react'], }, server: { host: process.env.HOST || '0.0.0.0', @@ -355,8 +367,12 @@ export default defineConfig(({ command }) => { include: [ 'react', 'react-dom', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', 'use-sync-external-store', + 'use-sync-external-store/shim', 'use-sync-external-store/shim/with-selector', + 'zustand', '@xyflow/react', ], }, From 90be17fd798e1adc4166f4ed8866979c54249cb7 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 2 Mar 2026 23:17:28 -0800 Subject: [PATCH 2/4] fix: Address PR #828 review feedback - Reset RAF buffer on context changes (worktree switch, dev-server restart) to prevent stale output from flushing into new sessions - Fix high-frequency WebSocket filter to catch auto-mode:event wrapping (auto_mode_progress is wrapped in auto-mode:event) and add feature:progress - Reorder Vite aliases so explicit jsx-runtime entries aren't shadowed by the broad /^react(\/|$)/ regex (Vite uses first-match-wins) Co-Authored-By: Claude Opus 4.6 From 736e12d39700546b126ee4b4f34ac0b81c059312 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 2 Mar 2026 23:19:17 -0800 Subject: [PATCH 3/4] feat: Batch dev server logs and fix React module resolution order --- .../hooks/use-dev-server-logs.ts | 33 +++++++++++-------- apps/ui/src/lib/http-api-client.ts | 3 +- apps/ui/vite.config.mts | 12 +++---- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-server-logs.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-server-logs.ts index cf54aa6a3..6461e851a 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-server-logs.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-server-logs.ts @@ -74,6 +74,20 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS // Keep track of whether we've fetched initial logs const hasFetchedInitialLogs = useRef(false); + // Buffer for batching rapid output events into fewer setState calls. + // Content accumulates here and is flushed via requestAnimationFrame, + // ensuring at most one React re-render per animation frame (~60fps max). + const pendingOutputRef = useRef(''); + const rafIdRef = useRef(null); + + const resetPendingOutput = useCallback(() => { + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = null; + } + pendingOutputRef.current = ''; + }, []); + /** * Fetch buffered logs from the server */ @@ -130,6 +144,7 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS * Clear logs and reset state */ const clearLogs = useCallback(() => { + resetPendingOutput(); setState({ logs: '', logsVersion: 0, @@ -144,13 +159,7 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS serverError: null, }); hasFetchedInitialLogs.current = false; - }, []); - - // Buffer for batching rapid output events into fewer setState calls. - // Content accumulates here and is flushed via requestAnimationFrame, - // ensuring at most one React re-render per animation frame (~60fps max). - const pendingOutputRef = useRef(''); - const rafIdRef = useRef(null); + }, [resetPendingOutput]); const flushPendingOutput = useCallback(() => { rafIdRef.current = null; @@ -197,12 +206,9 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS // Clean up pending RAF on unmount to prevent state updates after unmount useEffect(() => { return () => { - if (rafIdRef.current !== null) { - cancelAnimationFrame(rafIdRef.current); - rafIdRef.current = null; - } + resetPendingOutput(); }; - }, []); + }, [resetPendingOutput]); // Fetch initial logs when worktreePath changes useEffect(() => { @@ -230,6 +236,7 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS switch (event.type) { case 'dev-server:started': { + resetPendingOutput(); const { payload } = event; logger.info('Dev server started:', payload); setState((prev) => ({ @@ -279,7 +286,7 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS }); return unsubscribe; - }, [worktreePath, autoSubscribe, appendLogs]); + }, [worktreePath, autoSubscribe, appendLogs, resetPendingOutput]); return { ...state, diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index efaf92e3c..e92ab311f 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -930,7 +930,8 @@ export class HttpApiClient implements ElectronAPI { const isHighFrequency = data.type === 'dev-server:output' || data.type === 'test-runner:output' || - data.type === 'auto_mode_progress'; + data.type === 'feature:progress' || + (data.type === 'auto-mode:event' && data.payload?.type === 'auto_mode_progress'); if (!isHighFrequency) { logger.info('WebSocket message:', data.type); } diff --git a/apps/ui/vite.config.mts b/apps/ui/vite.config.mts index ab4f91346..a24caefad 100644 --- a/apps/ui/vite.config.mts +++ b/apps/ui/vite.config.mts @@ -248,16 +248,12 @@ export default defineConfig(({ command }) => { { find: '@', replacement: path.resolve(__dirname, './src') }, // Force ALL React imports (including from nested deps like zustand@4 inside // @xyflow/react) to resolve to a single copy. + // Explicit subpath aliases must come BEFORE the broad regex so Vite's + // first-match-wins resolution applies the specific match first. { find: /^react-dom(\/|$)/, replacement: path.resolve(__dirname, '../../node_modules/react-dom') + '/', }, - { - find: /^react(\/|$)/, - replacement: path.resolve(__dirname, '../../node_modules/react') + '/', - }, - // Explicit subpath aliases avoid mixed module IDs between bare imports and - // optimized deps (e.g. react/jsx-runtime), which can manifest as duplicate React. { find: 'react/jsx-runtime', replacement: path.resolve(__dirname, '../../node_modules/react/jsx-runtime.js'), @@ -266,6 +262,10 @@ export default defineConfig(({ command }) => { find: 'react/jsx-dev-runtime', replacement: path.resolve(__dirname, '../../node_modules/react/jsx-dev-runtime.js'), }, + { + find: /^react(\/|$)/, + replacement: path.resolve(__dirname, '../../node_modules/react') + '/', + }, ], dedupe: ['react', 'react-dom', 'zustand', 'use-sync-external-store', '@xyflow/react'], }, From 0a6e7a61bf0ca9d3c95dda37c9ab077d7b7ae5e3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 2 Mar 2026 23:47:33 -0800 Subject: [PATCH 4/4] feat: Add fallback timer for flushing dev server logs in background tabs --- .../hooks/use-dev-server-logs.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-server-logs.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-server-logs.ts index 6461e851a..797f55c43 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-server-logs.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-server-logs.ts @@ -77,14 +77,21 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS // Buffer for batching rapid output events into fewer setState calls. // Content accumulates here and is flushed via requestAnimationFrame, // ensuring at most one React re-render per animation frame (~60fps max). + // A fallback setTimeout ensures the buffer is flushed even when RAF is + // throttled (e.g., when the tab is in the background). const pendingOutputRef = useRef(''); const rafIdRef = useRef(null); + const timerIdRef = useRef | null>(null); const resetPendingOutput = useCallback(() => { if (rafIdRef.current !== null) { cancelAnimationFrame(rafIdRef.current); rafIdRef.current = null; } + if (timerIdRef.current !== null) { + clearTimeout(timerIdRef.current); + timerIdRef.current = null; + } pendingOutputRef.current = ''; }, []); @@ -162,7 +169,12 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS }, [resetPendingOutput]); const flushPendingOutput = useCallback(() => { + // Clear both scheduling handles to prevent duplicate flushes rafIdRef.current = null; + if (timerIdRef.current !== null) { + clearTimeout(timerIdRef.current); + timerIdRef.current = null; + } const content = pendingOutputRef.current; if (!content) return; pendingOutputRef.current = ''; @@ -192,13 +204,31 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS * * Uses requestAnimationFrame to batch rapid output events into at most * one React state update per frame, preventing excessive re-renders. + * A fallback setTimeout(250ms) ensures the buffer is flushed even when + * RAF is throttled (e.g., when the tab is in the background). + * If the pending buffer reaches MAX_LOG_BUFFER_SIZE, flushes immediately + * to prevent unbounded memory growth. */ const appendLogs = useCallback( (content: string) => { pendingOutputRef.current += content; + + // Flush immediately if buffer has reached the size limit + if (pendingOutputRef.current.length >= MAX_LOG_BUFFER_SIZE) { + flushPendingOutput(); + return; + } + + // Schedule a RAF flush if not already scheduled if (rafIdRef.current === null) { rafIdRef.current = requestAnimationFrame(flushPendingOutput); } + + // Schedule a fallback timer flush if not already scheduled, + // to handle cases where RAF is throttled (background tab) + if (timerIdRef.current === null) { + timerIdRef.current = setTimeout(flushPendingOutput, 250); + } }, [flushPendingOutput] );