From 2c6ec0119e8be5c270b7b88457e7f4318a2b40dc Mon Sep 17 00:00:00 2001 From: gnanam1990 Date: Fri, 3 Apr 2026 07:41:53 +0530 Subject: [PATCH] fix: prevent keyboard freeze when MCP notification effects fire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React 19 requires `supportsMicrotasks: true` in the reconciler host config so it can flush state updates from passive effects via queueMicrotask. Without this, state updates triggered inside useMcpConnectivityStatus were silently dropped, corrupting React's internal executionContext and causing all keyboard input to freeze after the "N MCP server(s) need auth" notification appeared. Root cause (three-part fix): 1. reconciler.ts: declare supportsMicrotasks + scheduleMicrotask so React 19 schedules passive-effect flushes correctly. 2. useMcpConnectivityStatus.tsx: wrap the MCP auth notification effect in try/catch so any unexpected throw does not propagate into flushPassiveEffects and permanently corrupt executionContext. 3. notifications.tsx: wrap addNotification, removeNotification, and processQueue in try/catch for the same reason — these are called from 12+ notification hooks across passive effects. Also fixes a pre-existing test isolation bug in context.test.ts where assigning `undefined` to process.env produced the string "undefined", polluting the env for subsequent test files. Resolves: #169, #205, #77 --- src/context/notifications.tsx | 13 +++ src/hooks/notifs/useMcpConnectivityStatus.tsx | 79 ++++++++++--------- src/ink/reconciler.ts | 2 + src/utils/context.test.ts | 14 +++- 4 files changed, 68 insertions(+), 40 deletions(-) diff --git a/src/context/notifications.tsx b/src/context/notifications.tsx index 8c214db5..adf34063 100644 --- a/src/context/notifications.tsx +++ b/src/context/notifications.tsx @@ -1,6 +1,7 @@ import type * as React from 'react'; import { useCallback, useEffect } from 'react'; import { useAppStateStore, useSetAppState } from 'src/state/AppState.js'; +import { logError } from '../utils/log.js'; import type { Theme } from '../utils/theme.js'; type Priority = 'low' | 'medium' | 'high' | 'immediate'; type BaseNotification = { @@ -44,6 +45,7 @@ export function useNotifications(): { // Process queue when current notification finishes or queue changes const processQueue = useCallback(() => { + try { setAppState(prev => { const next = getNext(prev.notifications.queue); if (prev.notifications.current !== null || !next) { @@ -74,8 +76,12 @@ export function useNotifications(): { } }; }); + } catch (error) { + logError(error); + } }, [setAppState]); const addNotification = useCallback((notif: Notification) => { + try { // Handle immediate priority notifications if (notif.priority === 'immediate') { // Clear any existing timeout since we're showing a new immediate notification @@ -189,8 +195,12 @@ export function useNotifications(): { // Process queue after adding the notification processQueue(); + } catch (error) { + logError(error); + } }, [setAppState, processQueue]); const removeNotification = useCallback((key: string) => { + try { setAppState(prev => { const isCurrent = prev.notifications.current?.key === key; const inQueue = prev.notifications.queue.some(n => n.key === key); @@ -210,6 +220,9 @@ export function useNotifications(): { }; }); processQueue(); + } catch (error) { + logError(error); + } }, [setAppState, processQueue]); // Process queue on mount if there are notifications in the initial state. diff --git a/src/hooks/notifs/useMcpConnectivityStatus.tsx b/src/hooks/notifs/useMcpConnectivityStatus.tsx index cfcb9a7e..d5176e08 100644 --- a/src/hooks/notifs/useMcpConnectivityStatus.tsx +++ b/src/hooks/notifs/useMcpConnectivityStatus.tsx @@ -1,5 +1,6 @@ import { c as _c } from "react-compiler-runtime"; import * as React from 'react'; +import { logError } from '../../utils/log.js'; import { useEffect } from 'react'; import { useNotifications } from 'src/context/notifications.js'; import { getIsRemoteMode } from '../../bootstrap/state.js'; @@ -23,43 +24,47 @@ export function useMcpConnectivityStatus(t0) { let t3; if ($[0] !== addNotification || $[1] !== mcpClients) { t2 = () => { - if (getIsRemoteMode()) { - return; - } - const failedLocalClients = mcpClients.filter(_temp); - const failedClaudeAiClients = mcpClients.filter(_temp2); - const needsAuthLocalServers = mcpClients.filter(_temp3); - const needsAuthClaudeAiServers = mcpClients.filter(_temp4); - if (failedLocalClients.length === 0 && failedClaudeAiClients.length === 0 && needsAuthLocalServers.length === 0 && needsAuthClaudeAiServers.length === 0) { - return; - } - if (failedLocalClients.length > 0) { - addNotification({ - key: "mcp-failed", - jsx: <>{failedLocalClients.length} MCP{" "}{failedLocalClients.length === 1 ? "server" : "servers"} failed · /mcp, - priority: "medium" - }); - } - if (failedClaudeAiClients.length > 0) { - addNotification({ - key: "mcp-claudeai-failed", - jsx: <>{failedClaudeAiClients.length} claude.ai{" "}{failedClaudeAiClients.length === 1 ? "connector" : "connectors"}{" "}unavailable · /mcp, - priority: "medium" - }); - } - if (needsAuthLocalServers.length > 0) { - addNotification({ - key: "mcp-needs-auth", - jsx: <>{needsAuthLocalServers.length} MCP{" "}{needsAuthLocalServers.length === 1 ? "server needs" : "servers need"}{" "}auth · /mcp, - priority: "medium" - }); - } - if (needsAuthClaudeAiServers.length > 0) { - addNotification({ - key: "mcp-claudeai-needs-auth", - jsx: <>{needsAuthClaudeAiServers.length} claude.ai{" "}{needsAuthClaudeAiServers.length === 1 ? "connector needs" : "connectors need"}{" "}auth · /mcp, - priority: "medium" - }); + try { + if (getIsRemoteMode()) { + return; + } + const failedLocalClients = mcpClients.filter(_temp); + const failedClaudeAiClients = mcpClients.filter(_temp2); + const needsAuthLocalServers = mcpClients.filter(_temp3); + const needsAuthClaudeAiServers = mcpClients.filter(_temp4); + if (failedLocalClients.length === 0 && failedClaudeAiClients.length === 0 && needsAuthLocalServers.length === 0 && needsAuthClaudeAiServers.length === 0) { + return; + } + if (failedLocalClients.length > 0) { + addNotification({ + key: "mcp-failed", + jsx: <>{failedLocalClients.length} MCP{" "}{failedLocalClients.length === 1 ? "server" : "servers"} failed · /mcp, + priority: "medium" + }); + } + if (failedClaudeAiClients.length > 0) { + addNotification({ + key: "mcp-claudeai-failed", + jsx: <>{failedClaudeAiClients.length} claude.ai{" "}{failedClaudeAiClients.length === 1 ? "connector" : "connectors"}{" "}unavailable · /mcp, + priority: "medium" + }); + } + if (needsAuthLocalServers.length > 0) { + addNotification({ + key: "mcp-needs-auth", + jsx: <>{needsAuthLocalServers.length} MCP{" "}{needsAuthLocalServers.length === 1 ? "server needs" : "servers need"}{" "}auth · /mcp, + priority: "medium" + }); + } + if (needsAuthClaudeAiServers.length > 0) { + addNotification({ + key: "mcp-claudeai-needs-auth", + jsx: <>{needsAuthClaudeAiServers.length} claude.ai{" "}{needsAuthClaudeAiServers.length === 1 ? "connector needs" : "connectors need"}{" "}auth · /mcp, + priority: "medium" + }); + } + } catch (error) { + logError(error); } }; t3 = [addNotification, mcpClients]; diff --git a/src/ink/reconciler.ts b/src/ink/reconciler.ts index ba5e1395..53a292fb 100644 --- a/src/ink/reconciler.ts +++ b/src/ink/reconciler.ts @@ -433,6 +433,8 @@ const reconciler = createReconciler< scheduleTimeout: setTimeout, cancelTimeout: clearTimeout, noTimeout: -1, + supportsMicrotasks: true, + scheduleMicrotask: queueMicrotask, getCurrentUpdatePriority: () => dispatcher.currentUpdatePriority, beforeActiveInstanceBlur() {}, afterActiveInstanceBlur() {}, diff --git a/src/utils/context.test.ts b/src/utils/context.test.ts index a95b209e..f6bc80d3 100644 --- a/src/utils/context.test.ts +++ b/src/utils/context.test.ts @@ -12,9 +12,17 @@ const originalEnv = { } afterEach(() => { - process.env.CLAUDE_CODE_USE_OPENAI = originalEnv.CLAUDE_CODE_USE_OPENAI - process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = - originalEnv.CLAUDE_CODE_MAX_OUTPUT_TOKENS + if (originalEnv.CLAUDE_CODE_USE_OPENAI === undefined) { + delete process.env.CLAUDE_CODE_USE_OPENAI + } else { + process.env.CLAUDE_CODE_USE_OPENAI = originalEnv.CLAUDE_CODE_USE_OPENAI + } + if (originalEnv.CLAUDE_CODE_MAX_OUTPUT_TOKENS === undefined) { + delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS + } else { + process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = + originalEnv.CLAUDE_CODE_MAX_OUTPUT_TOKENS + } }) test('deepseek-chat uses provider-specific context and output caps', () => {