Skip to content

Commit 8ba51ed

Browse files
committed
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
1 parent 73e54d4 commit 8ba51ed

18 files changed

Lines changed: 206 additions & 189 deletions

build.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const result = await Bun.build({
2121
outdir,
2222
target: 'bun',
2323
splitting: true,
24+
sourcemap: 'linked',
2425
define: {
2526
...getMacroDefines(),
2627
// React production mode — eliminates _debugStack Error objects

scripts/dev.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const defines = {
1818
...getMacroDefines(),
1919
// React production mode — prevents 6,889+ _debugStack Error objects
2020
// (12MB) from accumulating during long-running sessions.
21-
'process.env.NODE_ENV': JSON.stringify('production'),
21+
'process.env.NODE_ENV': JSON.stringify('development'),
2222
}
2323

2424
const defineArgs = Object.entries(defines).flatMap(([k, v]) => [

src/components/FeedbackSurvey/useFrustrationDetection.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,24 +25,24 @@ export function useFrustrationDetection(
2525
const [state, setState] = useState<FrustrationState>('closed')
2626

2727
const config = getGlobalConfig() as { transcriptShareDismissed?: boolean }
28-
if (config.transcriptShareDismissed) {
29-
return { state: 'closed', handleTranscriptSelect: () => {} }
30-
}
31-
32-
if (!isPolicyAllowed('product_feedback' as any)) {
33-
return { state: 'closed', handleTranscriptSelect: () => {} }
34-
}
35-
36-
if (isLoading || hasActivePrompt || otherSurveyOpen) {
37-
return { state: 'closed', handleTranscriptSelect: () => {} }
38-
}
28+
const policyAllowed = isPolicyAllowed('product_feedback' as any)
29+
const shouldSkip =
30+
config.transcriptShareDismissed ||
31+
!policyAllowed ||
32+
isLoading ||
33+
hasActivePrompt ||
34+
otherSurveyOpen
3935

4036
const frustrated = detectFrustration(messages)
4137

42-
const effectiveState =
43-
frustrated && state === 'closed' ? 'transcript_prompt' : state
38+
const effectiveState = shouldSkip
39+
? 'closed'
40+
: frustrated && state === 'closed'
41+
? 'transcript_prompt'
42+
: state
4443

45-
function handleTranscriptSelect(choice: string) {
44+
const handleTranscriptSelect = (choice: string) => {
45+
if (shouldSkip) return
4646
if (choice === 'yes') {
4747
void submitTranscriptShare(messages, 'frustration', crypto.randomUUID())
4848
setState('submitted')

src/components/PromptInput/Notifications.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,14 @@ function NotificationContent({
204204
}, []);
205205

206206
// Voice state (VOICE_MODE builds only, runtime-gated by GrowthBook)
207-
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
208-
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
209-
const voiceError = feature('VOICE_MODE') ? useVoiceState(s => s.voiceError) : null;
210-
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
207+
const voiceStateRaw = useVoiceState(s => s.voiceState);
208+
const voiceState = feature('VOICE_MODE') ? voiceStateRaw : ('idle' as const);
209+
const voiceEnabledRaw = useVoiceEnabled();
210+
const voiceEnabled = feature('VOICE_MODE') ? voiceEnabledRaw : false;
211+
const voiceErrorRaw = useVoiceState(s => s.voiceError);
212+
const voiceError = feature('VOICE_MODE') ? voiceErrorRaw : null;
213+
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
214+
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? isBriefOnlyState : false;
211215

212216
// When voice is actively recording or processing, replace all
213217
// notifications with just the voice indicator.

src/components/PromptInput/PromptInput.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -347,8 +347,8 @@ function PromptInput({
347347
// the input bar. viewingAgentTaskId mirrors the gate on both (Spinner.tsx,
348348
// REPL.tsx) — teammate view falls back to SpinnerWithVerbInner which has
349349
// its own marginTop, so the gap stays even without ours.
350-
const briefOwnsGap =
351-
feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) && !viewingAgentTaskId : false;
350+
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
351+
const briefOwnsGap = feature('KAIROS') || feature('KAIROS_BRIEF') ? isBriefOnlyState && !viewingAgentTaskId : false;
352352
const mainLoopModel_ = useAppState(s => s.mainLoopModel);
353353
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession);
354354
const thinkingEnabled = useAppState(s => s.thinkingEnabled);
@@ -2111,7 +2111,8 @@ function PromptInput({
21112111

21122112
useBuddyNotification();
21132113

2114-
const companionSpeaking = feature('BUDDY') ? useAppState(s => s.companionReaction !== undefined) : false;
2114+
const companionReactionState = useAppState(s => s.companionReaction);
2115+
const companionSpeaking = feature('BUDDY') ? companionReactionState !== undefined : false;
21152116
const { columns, rows } = useTerminalSize();
21162117
const textInputColumns = columns - 3 - companionReservedColumns(columns, companionSpeaking);
21172118

src/components/PromptInput/PromptInputFooterLeftSide.tsx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -230,9 +230,12 @@ function ModeIndicator({
230230
proactiveModule?.getNextTickAt ?? NULL,
231231
NULL,
232232
);
233-
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
234-
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
235-
const voiceWarmingUp = feature('VOICE_MODE') ? useVoiceState(s => s.voiceWarmingUp) : false;
233+
const voiceEnabledRaw = useVoiceEnabled();
234+
const voiceEnabled = feature('VOICE_MODE') ? voiceEnabledRaw : false;
235+
const voiceStateRaw = useVoiceState(s => s.voiceState);
236+
const voiceState = feature('VOICE_MODE') ? voiceStateRaw : ('idle' as const);
237+
const voiceWarmingUpRaw = useVoiceState(s => s.voiceWarmingUp);
238+
const voiceWarmingUp = feature('VOICE_MODE') ? voiceWarmingUpRaw : false;
236239
const hasSelection = useHasSelection();
237240
const selGetState = useSelection().getState;
238241
const hasNextTick = nextTickAt !== null;
@@ -250,16 +253,19 @@ function ModeIndicator({
250253
const escShortcut = useShortcutDisplay('chat:cancel', 'Chat', 'esc').toLowerCase();
251254
const todosShortcut = useShortcutDisplay('app:toggleTodos', 'Global', 'ctrl+t');
252255
const killAgentsShortcut = useShortcutDisplay('chat:killAgents', 'Chat', 'ctrl+x ctrl+k');
253-
const voiceKeyShortcut = feature('VOICE_MODE') ? useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space') : '';
256+
const voiceKeyShortcutRaw = useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space');
257+
const voiceKeyShortcut = feature('VOICE_MODE') ? voiceKeyShortcutRaw : '';
254258
// Captured at mount so the hint doesn't flicker mid-session if another
255259
// CC instance increments the counter. Incremented once via useEffect the
256260
// first time voice is enabled in this session — approximates "hint was
257261
// shown" without tracking the exact render-time condition (which depends
258262
// on parts/hintParts computed after the early-return hooks boundary).
259-
const [voiceHintUnderCap] = feature('VOICE_MODE')
260-
? useState(() => (getGlobalConfig().voiceFooterHintSeenCount ?? 0) < MAX_VOICE_HINT_SHOWS)
261-
: [false];
262-
const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null;
263+
const [voiceHintUnderCapRaw] = useState(
264+
() => (getGlobalConfig().voiceFooterHintSeenCount ?? 0) < MAX_VOICE_HINT_SHOWS,
265+
);
266+
const voiceHintUnderCap = feature('VOICE_MODE') ? voiceHintUnderCapRaw : false;
267+
const voiceHintIncrementedRefRaw = useRef(false);
268+
const voiceHintIncrementedRef = feature('VOICE_MODE') ? voiceHintIncrementedRefRaw : null;
263269
useEffect(() => {
264270
if (feature('VOICE_MODE')) {
265271
if (!voiceEnabled || !voiceHintUnderCap) return;

src/components/PromptInput/PromptInputQueuedCommands.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ function PromptInputQueuedCommandsImpl(): React.ReactNode {
8080
// already indent themselves). Gate mirrors the brief-spinner/message
8181
// check elsewhere — no teammate-view override needed since this
8282
// component early-returns when viewing a teammate.
83-
const useBriefLayout = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
83+
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
84+
const useBriefLayout = feature('KAIROS') || feature('KAIROS_BRIEF') ? isBriefOnlyState : false;
8485

8586
// createUserMessage mints a fresh UUID per call; without memoization, streaming
8687
// re-renders defeat Message's areMessagePropsEqual (compares uuid) → flicker.

src/components/Spinner.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,8 @@ export function SpinnerWithVerb(props: Props): React.ReactNode {
7878
// teammate view needs the real spinner (which shows teammate status).
7979
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId);
8080
// Hoisted to mount-time — this component re-renders at animation framerate.
81-
const briefEnvEnabled =
82-
feature('KAIROS') || feature('KAIROS_BRIEF')
83-
? useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
84-
: false;
81+
const briefEnvEnabledRaw = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), []);
82+
const briefEnvEnabled = feature('KAIROS') || feature('KAIROS_BRIEF') ? briefEnvEnabledRaw : false;
8583

8684
// Runtime gate mirrors isBriefEnabled() but inlined — importing from
8785
// BriefTool.ts would leak tool-name strings into external builds. Single

src/components/TextInput.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,18 @@ export default function TextInput(props: Props): React.ReactNode {
4444
const settings = useSettings();
4545
const reducedMotion = settings.prefersReducedMotion ?? false;
4646

47-
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
47+
const voiceStateRaw = useVoiceState(s => s.voiceState);
48+
const voiceState = feature('VOICE_MODE') ? voiceStateRaw : ('idle' as const);
4849
const isVoiceRecording = voiceState === 'recording';
4950

50-
const audioLevels = feature('VOICE_MODE') ? useVoiceState(s => s.voiceAudioLevels) : [];
51+
const audioLevelsRaw = useVoiceState(s => s.voiceAudioLevels);
52+
const audioLevels = feature('VOICE_MODE') ? audioLevelsRaw : [];
5153
const smoothedRef = useRef<number[]>(new Array(CURSOR_WAVEFORM_WIDTH).fill(0));
5254

5355
const needsAnimation = isVoiceRecording && !reducedMotion;
54-
const [animRef, animTime] = feature('VOICE_MODE') ? useAnimationFrame(needsAnimation ? 50 : null) : [() => {}, 0];
56+
const [animRefRaw, animTimeRaw] = useAnimationFrame(needsAnimation ? 50 : null);
57+
const animRef = feature('VOICE_MODE') ? animRefRaw : () => {};
58+
const animTime = feature('VOICE_MODE') ? animTimeRaw : 0;
5559

5660
// Show hint when terminal regains focus and clipboard has an image
5761
useClipboardImageHint(isTerminalFocused, !!props.onImagePaste);

src/components/messages/AttachmentMessage.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ type Props = {
3939
export function AttachmentMessage({ attachment, addMargin, verbose, isTranscriptMode }: Props): React.ReactNode {
4040
const bg = useSelectedMessageBg();
4141
// Hoisted to mount-time — per-message component, re-renders on every scroll.
42-
const isDemoEnv = feature('EXPERIMENTAL_SKILL_SEARCH') ? useMemo(() => isEnvTruthy(process.env.IS_DEMO), []) : false;
42+
const isDemoEnvRaw = useMemo(() => isEnvTruthy(process.env.IS_DEMO), []);
43+
const isDemoEnv = feature('EXPERIMENTAL_SKILL_SEARCH') ? isDemoEnvRaw : false;
4344
// Handle teammate_mailbox BEFORE switch
4445
if (isAgentSwarmsEnabled() && attachment.type === 'teammate_mailbox') {
4546
// Filter out idle notifications BEFORE counting - they are hidden in the UI

0 commit comments

Comments
 (0)