mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
fix: 修复条件式 hook 调用导致的 "Rendered fewer hooks than expected" 错误
修复在 dev 模式下按下 Ctrl+O 切换 transcript 视图时 React 抛出
"Rendered fewer hooks than expected" 崩溃的问题。
## 根因分析
项目中有大量 hook(useState / useMemo / useRef / useSyncExternalStore 等)
被包裹在 `feature()` 三元表达式中条件调用,例如:
const value = feature('X') ? useHook() : defaultValue;
在 build 模式下 `feature()` 是编译时常量,死代码消除会移除未使用的分支,
hooks 数量在编译后是确定的。但在 dev 模式下(scripts/dev.ts 注入
--feature 启用全部 31 个 feature),`feature()` 是运行时调用,
但始终返回 true,因此所有 hooks 都会被调用,原本不会出问题。
真正的触发器是 REPL.tsx 第 5381 行的提前返回:
if (screen === 'transcript') { return transcriptReturn; }
当用户按下 Ctrl+O 进入 transcript 模式时,该提前返回之后的所有 hooks
(如 displayedAgentMessages 的 useMemo)都不会被调用,导致 React 在
下一次渲染时检测到 hooks 数量与上次不一致而崩溃。
此外,其他文件中也存在相同的条件式 hook 模式——虽然 dev 模式下
feature() 返回 true,所以这些路径实际上不会被触发,但它们是
潜在的隐患:若将来有人通过环境变量关闭某个 feature,
同样的崩溃会立即出现。
## 修复策略
采用统一模式:**始终无条件调用 hook,将 feature() gate 应用到值上**。
// Before (unsafe — hook count varies by feature flag)
const value = feature('X') ? useHook() : defaultValue;
// After (safe — hook always called, gate on the value)
const rawValue = useHook();
const value = feature('X') ? rawValue : defaultValue;
## 修改清单
### 核心修复(REPL.tsx)
- 将 `displayedAgentMessages` useMemo 及依赖变量(viewedTask /
viewedTeammateTask / viewedAgentTask / usesSyncMessages /
rawAgentMessages / displayedMessages)从 transcript 提前返回
之后移至之前,确保两模式下 hooks 调用顺序一致
- 修复 `disableMessageActions` / `useAssistantHistory` /
`voiceIntegration` 的条件式 hook 调用
### 条件式 hook 修复(11 个文件)
- src/hooks/useGlobalKeybindings.tsx — isBriefOnly / toggleBrief
keybinding 改为 isActive 门控
- src/hooks/useReplBridge.tsx — 5 个 BRIDGE_MODE 选值改为无条件调用
- src/hooks/useVoiceIntegration.tsx — 4 个 VOICE_MODE 选值修复
- src/components/PromptInput/Notifications.tsx — 4 个 feature 选值修复
- src/components/PromptInput/PromptInput.tsx — briefOwnsGap /
companionSpeaking 修复
- src/components/PromptInput/PromptInputFooterLeftSide.tsx — 4 个
VOICE_MODE 选值修复
- src/components/PromptInput/PromptInputQueuedCommands.tsx — isBriefOnly
- src/components/Spinner.tsx — briefEnvEnabled 修复
- src/components/TextInput.tsx — voiceState / audioLevels /
animationFrame 修复
- src/components/messages/AttachmentMessage.tsx — isDemoEnv 修复
- src/components/messages/UserPromptMessage.tsx — isBriefOnly /
viewingAgentTaskId / briefEnvEnabled 修复
- src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx
— isBriefOnly 修复
### 其他修复
- src/components/FeedbackSurvey/useFrustrationDetection.ts — 将 3 个
提前返回合并为 shouldSkip 变量,handleTranscriptSelect 提前 return
- src/hooks/useIssueFlagBanner.ts — useRef 移到 USER_TYPE 检查之前
- src/hooks/useUpdateNotification.ts — useState 改为 useRef,
避免版本号变化触发不必要重渲染
### 构建/开发配置
- build.ts — 添加 `sourcemap: 'linked'`
- scripts/dev.ts — NODE_ENV 从 'production' 改为 'development'
Closes #434
This commit is contained in:
@@ -860,9 +860,8 @@ export function REPL({
|
||||
[],
|
||||
);
|
||||
const disableVirtualScroll = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), []);
|
||||
const disableMessageActions = feature('MESSAGE_ACTIONS')
|
||||
? useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS), [])
|
||||
: false;
|
||||
const disableMessageActionsRaw = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS), []);
|
||||
const disableMessageActions = feature('MESSAGE_ACTIONS') ? disableMessageActionsRaw : false;
|
||||
|
||||
// Log REPL mount/unmount lifecycle
|
||||
useEffect(() => {
|
||||
@@ -1552,14 +1551,13 @@ export function REPL({
|
||||
// KAIROS build + config.viewerOnly. feature() is build-time constant so
|
||||
// the branch is dead-code-eliminated in non-KAIROS builds (same pattern
|
||||
// as useUnseenDivider above).
|
||||
const { maybeLoadOlder } = feature('KAIROS')
|
||||
? useAssistantHistory({
|
||||
config: remoteSessionConfig,
|
||||
setMessages,
|
||||
scrollRef,
|
||||
onPrepend: shiftDivider,
|
||||
})
|
||||
: HISTORY_STUB;
|
||||
const assistantHistoryResult = useAssistantHistory({
|
||||
config: remoteSessionConfig,
|
||||
setMessages,
|
||||
scrollRef,
|
||||
onPrepend: shiftDivider,
|
||||
});
|
||||
const { maybeLoadOlder } = feature('KAIROS') ? assistantHistoryResult : HISTORY_STUB;
|
||||
// Compose useUnseenDivider's callbacks with the lazy-load trigger.
|
||||
const composedOnScroll = useCallback(
|
||||
(sticky: boolean, handle: ScrollBoxHandle) => {
|
||||
@@ -4941,8 +4939,9 @@ export function REPL({
|
||||
const { relayPipeMessage, pipeReturnHadErrorRef } = usePipeRelay();
|
||||
|
||||
// Voice input integration (VOICE_MODE builds only)
|
||||
const voiceIntegrationResult = useVoiceIntegration({ setInputValueRaw, inputValueRef, insertTextRef });
|
||||
const voice = feature('VOICE_MODE')
|
||||
? useVoiceIntegration({ setInputValueRaw, inputValueRef, insertTextRef })
|
||||
? voiceIntegrationResult
|
||||
: {
|
||||
stripTrailing: () => 0,
|
||||
handleKeyEvent: () => {},
|
||||
@@ -5379,6 +5378,93 @@ export function REPL({
|
||||
// Auto-exit viewing mode when teammate completes or errors
|
||||
useTeammateViewAutoExit();
|
||||
|
||||
// Get viewed agent task (inlined from selectors for explicit data flow).
|
||||
// viewedAgentTask: teammate OR local_agent — drives the boolean checks
|
||||
// below. viewedTeammateTask: teammate-only narrowed, for teammate-specific
|
||||
// field access (inProgressToolUseIDs).
|
||||
const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined;
|
||||
const viewedTeammateTask = viewedTask && isInProcessTeammateTask(viewedTask) ? viewedTask : undefined;
|
||||
const viewedAgentTask = viewedTeammateTask ?? (viewedTask && isLocalAgentTask(viewedTask) ? viewedTask : undefined);
|
||||
|
||||
// Bypass useDeferredValue when streaming text is showing so Messages renders
|
||||
// the final message in the same frame streaming text clears. Also bypass when
|
||||
// not loading — deferredMessages only matters during streaming (keeps input
|
||||
// responsive); after the turn ends, showing messages immediately prevents a
|
||||
// jitter gap where the spinner is gone but the answer hasn't appeared yet.
|
||||
// Only reducedMotion users keep the deferred path during loading.
|
||||
const usesSyncMessages = showStreamingText || !isLoading;
|
||||
// When viewing an agent, never fall through to leader — empty until
|
||||
// bootstrap/stream fills. Closes the see-leader-type-agent footgun.
|
||||
const rawAgentMessages = viewedAgentTask?.messages;
|
||||
// Fork sidechain encodes the user prompt inside a mixed user message alongside
|
||||
// tool_result blocks; surface the prompt as a standalone bubble and strip the
|
||||
// boilerplate text from its original carrier while preserving tool_results.
|
||||
const displayedAgentMessages = useMemo(() => {
|
||||
if (!viewedAgentTask) return undefined;
|
||||
const agentMessages = rawAgentMessages ?? [];
|
||||
if (
|
||||
!isLocalAgentTask(viewedAgentTask) ||
|
||||
viewedAgentTask.agentType !== FORK_SUBAGENT_TYPE ||
|
||||
!viewedAgentTask.prompt
|
||||
) {
|
||||
return agentMessages;
|
||||
}
|
||||
// Single pass: locate boilerplate carrier, check whether the prompt text is
|
||||
// already present elsewhere, and find the fallback insertion point (after
|
||||
// the last parent assistant tool_use).
|
||||
const trimmedPrompt = viewedAgentTask.prompt.trim();
|
||||
let boilerplateIndex = -1;
|
||||
let lastAssistantToolUseIndex = -1;
|
||||
let promptAlreadyRendered = false;
|
||||
for (let i = 0; i < agentMessages.length; i++) {
|
||||
const m = agentMessages[i]!;
|
||||
if (m.type === 'user' && Array.isArray(m.message?.content)) {
|
||||
const hasBoilerplate = m.message.content.some(isForkBoilerplateTextBlock);
|
||||
if (hasBoilerplate) {
|
||||
boilerplateIndex = i;
|
||||
} else if (!promptAlreadyRendered) {
|
||||
const firstText = m.message.content.find(b => b.type === 'text' && typeof b.text === 'string') as
|
||||
| { type: 'text'; text: string }
|
||||
| undefined;
|
||||
if (firstText && firstText.text.trim() === trimmedPrompt) promptAlreadyRendered = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (m.type === 'assistant' && Array.isArray(m.message?.content)) {
|
||||
if (m.message.content.some(b => b.type === 'tool_use')) lastAssistantToolUseIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
const stripped =
|
||||
boilerplateIndex === -1
|
||||
? agentMessages
|
||||
: agentMessages.map((m, i) => {
|
||||
if (i !== boilerplateIndex) return m;
|
||||
if (!Array.isArray(m.message?.content)) return m;
|
||||
return {
|
||||
...m,
|
||||
message: {
|
||||
...m.message,
|
||||
content: m.message.content.filter(b => !isForkBoilerplateTextBlock(b)),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (promptAlreadyRendered) return stripped;
|
||||
|
||||
const insertAt = boilerplateIndex !== -1 ? boilerplateIndex + 1 : lastAssistantToolUseIndex + 1;
|
||||
const synthetic = createUserMessage({
|
||||
content: viewedAgentTask.prompt,
|
||||
timestamp: new Date(viewedAgentTask.startTime).toISOString(),
|
||||
});
|
||||
return [...stripped.slice(0, insertAt), synthetic, ...stripped.slice(insertAt)];
|
||||
}, [viewedAgentTask, rawAgentMessages]);
|
||||
const displayedMessages = viewedAgentTask
|
||||
? (displayedAgentMessages ?? [])
|
||||
: usesSyncMessages
|
||||
? messages
|
||||
: deferredMessages;
|
||||
|
||||
if (screen === 'transcript') {
|
||||
// Virtual scroll replaces the 30-message cap: everything is scrollable
|
||||
// and memory is bounded by the viewport. Without it, wrapping transcript
|
||||
@@ -5554,92 +5640,6 @@ export function REPL({
|
||||
return transcriptReturn;
|
||||
}
|
||||
|
||||
// Get viewed agent task (inlined from selectors for explicit data flow).
|
||||
// viewedAgentTask: teammate OR local_agent — drives the boolean checks
|
||||
// below. viewedTeammateTask: teammate-only narrowed, for teammate-specific
|
||||
// field access (inProgressToolUseIDs).
|
||||
const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined;
|
||||
const viewedTeammateTask = viewedTask && isInProcessTeammateTask(viewedTask) ? viewedTask : undefined;
|
||||
const viewedAgentTask = viewedTeammateTask ?? (viewedTask && isLocalAgentTask(viewedTask) ? viewedTask : undefined);
|
||||
|
||||
// Bypass useDeferredValue when streaming text is showing so Messages renders
|
||||
// the final message in the same frame streaming text clears. Also bypass when
|
||||
// not loading — deferredMessages only matters during streaming (keeps input
|
||||
// responsive); after the turn ends, showing messages immediately prevents a
|
||||
// jitter gap where the spinner is gone but the answer hasn't appeared yet.
|
||||
// Only reducedMotion users keep the deferred path during loading.
|
||||
const usesSyncMessages = showStreamingText || !isLoading;
|
||||
// When viewing an agent, never fall through to leader — empty until
|
||||
// bootstrap/stream fills. Closes the see-leader-type-agent footgun.
|
||||
const rawAgentMessages = viewedAgentTask?.messages;
|
||||
// Fork sidechain encodes the user prompt inside a mixed user message alongside
|
||||
// tool_result blocks; surface the prompt as a standalone bubble and strip the
|
||||
// boilerplate text from its original carrier while preserving tool_results.
|
||||
const displayedAgentMessages = useMemo(() => {
|
||||
if (!viewedAgentTask) return undefined;
|
||||
const agentMessages = rawAgentMessages ?? [];
|
||||
if (
|
||||
!isLocalAgentTask(viewedAgentTask) ||
|
||||
viewedAgentTask.agentType !== FORK_SUBAGENT_TYPE ||
|
||||
!viewedAgentTask.prompt
|
||||
) {
|
||||
return agentMessages;
|
||||
}
|
||||
// Single pass: locate boilerplate carrier, check whether the prompt text is
|
||||
// already present elsewhere, and find the fallback insertion point (after
|
||||
// the last parent assistant tool_use).
|
||||
const trimmedPrompt = viewedAgentTask.prompt.trim();
|
||||
let boilerplateIndex = -1;
|
||||
let lastAssistantToolUseIndex = -1;
|
||||
let promptAlreadyRendered = false;
|
||||
for (let i = 0; i < agentMessages.length; i++) {
|
||||
const m = agentMessages[i]!;
|
||||
if (m.type === 'user' && Array.isArray(m.message?.content)) {
|
||||
const hasBoilerplate = m.message.content.some(isForkBoilerplateTextBlock);
|
||||
if (hasBoilerplate) {
|
||||
boilerplateIndex = i;
|
||||
} else if (!promptAlreadyRendered) {
|
||||
const firstText = m.message.content.find(b => b.type === 'text' && typeof b.text === 'string') as
|
||||
| { type: 'text'; text: string }
|
||||
| undefined;
|
||||
if (firstText && firstText.text.trim() === trimmedPrompt) promptAlreadyRendered = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (m.type === 'assistant' && Array.isArray(m.message?.content)) {
|
||||
if (m.message.content.some(b => b.type === 'tool_use')) lastAssistantToolUseIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
const stripped =
|
||||
boilerplateIndex === -1
|
||||
? agentMessages
|
||||
: agentMessages.map((m, i) => {
|
||||
if (i !== boilerplateIndex) return m;
|
||||
if (!Array.isArray(m.message?.content)) return m;
|
||||
return {
|
||||
...m,
|
||||
message: {
|
||||
...m.message,
|
||||
content: m.message.content.filter(b => !isForkBoilerplateTextBlock(b)),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (promptAlreadyRendered) return stripped;
|
||||
|
||||
const insertAt = boilerplateIndex !== -1 ? boilerplateIndex + 1 : lastAssistantToolUseIndex + 1;
|
||||
const synthetic = createUserMessage({
|
||||
content: viewedAgentTask.prompt,
|
||||
timestamp: new Date(viewedAgentTask.startTime).toISOString(),
|
||||
});
|
||||
return [...stripped.slice(0, insertAt), synthetic, ...stripped.slice(insertAt)];
|
||||
}, [viewedAgentTask, rawAgentMessages]);
|
||||
const displayedMessages = viewedAgentTask
|
||||
? (displayedAgentMessages ?? [])
|
||||
: usesSyncMessages
|
||||
? messages
|
||||
: deferredMessages;
|
||||
// Show the placeholder until the real user message appears in
|
||||
// displayedMessages. userInputOnProcessing stays set for the whole turn
|
||||
// (cleared in resetLoadingState); this length check hides it once
|
||||
|
||||
Reference in New Issue
Block a user