fix: prevent keyboard freeze when MCP notification effects fire
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
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import type * as React from 'react';
|
import type * as React from 'react';
|
||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useAppStateStore, useSetAppState } from 'src/state/AppState.js';
|
import { useAppStateStore, useSetAppState } from 'src/state/AppState.js';
|
||||||
|
import { logError } from '../utils/log.js';
|
||||||
import type { Theme } from '../utils/theme.js';
|
import type { Theme } from '../utils/theme.js';
|
||||||
type Priority = 'low' | 'medium' | 'high' | 'immediate';
|
type Priority = 'low' | 'medium' | 'high' | 'immediate';
|
||||||
type BaseNotification = {
|
type BaseNotification = {
|
||||||
@@ -44,6 +45,7 @@ export function useNotifications(): {
|
|||||||
|
|
||||||
// Process queue when current notification finishes or queue changes
|
// Process queue when current notification finishes or queue changes
|
||||||
const processQueue = useCallback(() => {
|
const processQueue = useCallback(() => {
|
||||||
|
try {
|
||||||
setAppState(prev => {
|
setAppState(prev => {
|
||||||
const next = getNext(prev.notifications.queue);
|
const next = getNext(prev.notifications.queue);
|
||||||
if (prev.notifications.current !== null || !next) {
|
if (prev.notifications.current !== null || !next) {
|
||||||
@@ -74,8 +76,12 @@ export function useNotifications(): {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error);
|
||||||
|
}
|
||||||
}, [setAppState]);
|
}, [setAppState]);
|
||||||
const addNotification = useCallback<AddNotificationFn>((notif: Notification) => {
|
const addNotification = useCallback<AddNotificationFn>((notif: Notification) => {
|
||||||
|
try {
|
||||||
// Handle immediate priority notifications
|
// Handle immediate priority notifications
|
||||||
if (notif.priority === 'immediate') {
|
if (notif.priority === 'immediate') {
|
||||||
// Clear any existing timeout since we're showing a new immediate notification
|
// 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
|
// Process queue after adding the notification
|
||||||
processQueue();
|
processQueue();
|
||||||
|
} catch (error) {
|
||||||
|
logError(error);
|
||||||
|
}
|
||||||
}, [setAppState, processQueue]);
|
}, [setAppState, processQueue]);
|
||||||
const removeNotification = useCallback<RemoveNotificationFn>((key: string) => {
|
const removeNotification = useCallback<RemoveNotificationFn>((key: string) => {
|
||||||
|
try {
|
||||||
setAppState(prev => {
|
setAppState(prev => {
|
||||||
const isCurrent = prev.notifications.current?.key === key;
|
const isCurrent = prev.notifications.current?.key === key;
|
||||||
const inQueue = prev.notifications.queue.some(n => n.key === key);
|
const inQueue = prev.notifications.queue.some(n => n.key === key);
|
||||||
@@ -210,6 +220,9 @@ export function useNotifications(): {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
processQueue();
|
processQueue();
|
||||||
|
} catch (error) {
|
||||||
|
logError(error);
|
||||||
|
}
|
||||||
}, [setAppState, processQueue]);
|
}, [setAppState, processQueue]);
|
||||||
|
|
||||||
// Process queue on mount if there are notifications in the initial state.
|
// Process queue on mount if there are notifications in the initial state.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { c as _c } from "react-compiler-runtime";
|
import { c as _c } from "react-compiler-runtime";
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { logError } from '../../utils/log.js';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNotifications } from 'src/context/notifications.js';
|
import { useNotifications } from 'src/context/notifications.js';
|
||||||
import { getIsRemoteMode } from '../../bootstrap/state.js';
|
import { getIsRemoteMode } from '../../bootstrap/state.js';
|
||||||
@@ -23,6 +24,7 @@ export function useMcpConnectivityStatus(t0) {
|
|||||||
let t3;
|
let t3;
|
||||||
if ($[0] !== addNotification || $[1] !== mcpClients) {
|
if ($[0] !== addNotification || $[1] !== mcpClients) {
|
||||||
t2 = () => {
|
t2 = () => {
|
||||||
|
try {
|
||||||
if (getIsRemoteMode()) {
|
if (getIsRemoteMode()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -61,6 +63,9 @@ export function useMcpConnectivityStatus(t0) {
|
|||||||
priority: "medium"
|
priority: "medium"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logError(error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
t3 = [addNotification, mcpClients];
|
t3 = [addNotification, mcpClients];
|
||||||
$[0] = addNotification;
|
$[0] = addNotification;
|
||||||
|
|||||||
@@ -433,6 +433,8 @@ const reconciler = createReconciler<
|
|||||||
scheduleTimeout: setTimeout,
|
scheduleTimeout: setTimeout,
|
||||||
cancelTimeout: clearTimeout,
|
cancelTimeout: clearTimeout,
|
||||||
noTimeout: -1,
|
noTimeout: -1,
|
||||||
|
supportsMicrotasks: true,
|
||||||
|
scheduleMicrotask: queueMicrotask,
|
||||||
getCurrentUpdatePriority: () => dispatcher.currentUpdatePriority,
|
getCurrentUpdatePriority: () => dispatcher.currentUpdatePriority,
|
||||||
beforeActiveInstanceBlur() {},
|
beforeActiveInstanceBlur() {},
|
||||||
afterActiveInstanceBlur() {},
|
afterActiveInstanceBlur() {},
|
||||||
|
|||||||
@@ -12,9 +12,17 @@ const originalEnv = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
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
|
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 =
|
process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS =
|
||||||
originalEnv.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
originalEnv.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('deepseek-chat uses provider-specific context and output caps', () => {
|
test('deepseek-chat uses provider-specific context and output caps', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user