mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
修复在 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
60 lines
1.8 KiB
TypeScript
60 lines
1.8 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* Dev entrypoint — launches cli.tsx with MACRO.* defines injected
|
|
* via Bun's -d flag (bunfig.toml [define] doesn't propagate to
|
|
* dynamically imported modules at runtime).
|
|
*/
|
|
import { join, dirname } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { getMacroDefines, DEFAULT_BUILD_FEATURES } from './defines.ts'
|
|
|
|
// Resolve project root from this script's location
|
|
const __filename = fileURLToPath(import.meta.url)
|
|
const __dirname = dirname(__filename)
|
|
const projectRoot = join(__dirname, '..')
|
|
const cliPath = join(projectRoot, 'src/entrypoints/cli.tsx')
|
|
|
|
const defines = {
|
|
...getMacroDefines(),
|
|
// React production mode — prevents 6,889+ _debugStack Error objects
|
|
// (12MB) from accumulating during long-running sessions.
|
|
'process.env.NODE_ENV': JSON.stringify('development'),
|
|
}
|
|
|
|
const defineArgs = Object.entries(defines).flatMap(([k, v]) => [
|
|
'-d',
|
|
`${k}:${v}`,
|
|
])
|
|
|
|
// Bun --feature flags: enable feature() gates at runtime.
|
|
// Uses the shared DEFAULT_BUILD_FEATURES list from defines.ts.
|
|
|
|
// Any env var matching FEATURE_<NAME>=1 will also enable that feature.
|
|
// e.g. FEATURE_PROACTIVE=1 bun run dev
|
|
const envFeatures = Object.entries(process.env)
|
|
.filter(([k]) => k.startsWith('FEATURE_'))
|
|
.map(([k]) => k.replace('FEATURE_', ''))
|
|
|
|
const allFeatures = [...new Set([...DEFAULT_BUILD_FEATURES, ...envFeatures])]
|
|
const featureArgs = allFeatures.flatMap(name => ['--feature', name])
|
|
|
|
// If BUN_INSPECT is set, pass --inspect-wait to the child process
|
|
const inspectArgs = process.env.BUN_INSPECT
|
|
? ['--inspect-wait=' + process.env.BUN_INSPECT]
|
|
: []
|
|
|
|
const result = Bun.spawnSync(
|
|
[
|
|
'bun',
|
|
...inspectArgs,
|
|
'run',
|
|
...defineArgs,
|
|
...featureArgs,
|
|
cliPath,
|
|
...process.argv.slice(2),
|
|
],
|
|
{ stdio: ['inherit', 'inherit', 'inherit'], cwd: projectRoot },
|
|
)
|
|
|
|
process.exit(result.exitCode ?? 0)
|