Fix dev server hang by reducing log spam and event frequency (#828)

* Changes from fix/dev-server-hang

* 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 <noreply@anthropic.com>

* feat: Batch dev server logs and fix React module resolution order

* feat: Add fallback timer for flushing dev server logs in background tabs

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-03-02 23:52:44 -08:00
committed by GitHub
parent b2915f4de1
commit ae48065820
8 changed files with 148 additions and 44 deletions

View File

@@ -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

View File

@@ -74,6 +74,27 @@ 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).
// 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<number | null>(null);
const timerIdRef = useRef<ReturnType<typeof setTimeout> | 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 = '';
}, []);
/**
* Fetch buffered logs from the server
*/
@@ -130,6 +151,7 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
* Clear logs and reset state
*/
const clearLogs = useCallback(() => {
resetPendingOutput();
setState({
logs: '',
logsVersion: 0,
@@ -144,13 +166,19 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
serverError: null,
});
hasFetchedInitialLogs.current = false;
}, []);
}, [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 = '';
/**
* Append content to logs, enforcing a maximum buffer size to prevent
* unbounded memory growth and progressive UI lag.
*/
const appendLogs = useCallback((content: string) => {
setState((prev) => {
const combined = prev.logs + content;
const didTrim = combined.length > MAX_LOG_BUFFER_SIZE;
@@ -170,6 +198,48 @@ 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.
* 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]
);
// Clean up pending RAF on unmount to prevent state updates after unmount
useEffect(() => {
return () => {
resetPendingOutput();
};
}, [resetPendingOutput]);
// Fetch initial logs when worktreePath changes
useEffect(() => {
if (worktreePath && autoSubscribe) {
@@ -196,6 +266,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) => ({
@@ -245,7 +316,7 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
});
return unsubscribe;
}, [worktreePath, autoSubscribe, appendLogs]);
}, [worktreePath, autoSubscribe, appendLogs, resetPendingOutput]);
return {
...state,

View File

@@ -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(() => {

View File

@@ -923,17 +923,20 @@ 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 === 'feature:progress' ||
(data.type === 'auto-mode:event' && data.payload?.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) {