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:
Bonerush
2026-05-08 13:17:25 +08:00
parent 73e54d4bbc
commit 8ba51edec1
18 changed files with 206 additions and 189 deletions

View File

@@ -25,24 +25,24 @@ export function useFrustrationDetection(
const [state, setState] = useState<FrustrationState>('closed')
const config = getGlobalConfig() as { transcriptShareDismissed?: boolean }
if (config.transcriptShareDismissed) {
return { state: 'closed', handleTranscriptSelect: () => {} }
}
if (!isPolicyAllowed('product_feedback' as any)) {
return { state: 'closed', handleTranscriptSelect: () => {} }
}
if (isLoading || hasActivePrompt || otherSurveyOpen) {
return { state: 'closed', handleTranscriptSelect: () => {} }
}
const policyAllowed = isPolicyAllowed('product_feedback' as any)
const shouldSkip =
config.transcriptShareDismissed ||
!policyAllowed ||
isLoading ||
hasActivePrompt ||
otherSurveyOpen
const frustrated = detectFrustration(messages)
const effectiveState =
frustrated && state === 'closed' ? 'transcript_prompt' : state
const effectiveState = shouldSkip
? 'closed'
: frustrated && state === 'closed'
? 'transcript_prompt'
: state
function handleTranscriptSelect(choice: string) {
const handleTranscriptSelect = (choice: string) => {
if (shouldSkip) return
if (choice === 'yes') {
void submitTranscriptShare(messages, 'frustration', crypto.randomUUID())
setState('submitted')

View File

@@ -204,10 +204,14 @@ function NotificationContent({
}, []);
// Voice state (VOICE_MODE builds only, runtime-gated by GrowthBook)
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
const voiceError = feature('VOICE_MODE') ? useVoiceState(s => s.voiceError) : null;
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
const voiceStateRaw = useVoiceState(s => s.voiceState);
const voiceState = feature('VOICE_MODE') ? voiceStateRaw : ('idle' as const);
const voiceEnabledRaw = useVoiceEnabled();
const voiceEnabled = feature('VOICE_MODE') ? voiceEnabledRaw : false;
const voiceErrorRaw = useVoiceState(s => s.voiceError);
const voiceError = feature('VOICE_MODE') ? voiceErrorRaw : null;
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? isBriefOnlyState : false;
// When voice is actively recording or processing, replace all
// notifications with just the voice indicator.

View File

@@ -347,8 +347,8 @@ function PromptInput({
// the input bar. viewingAgentTaskId mirrors the gate on both (Spinner.tsx,
// REPL.tsx) — teammate view falls back to SpinnerWithVerbInner which has
// its own marginTop, so the gap stays even without ours.
const briefOwnsGap =
feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) && !viewingAgentTaskId : false;
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
const briefOwnsGap = feature('KAIROS') || feature('KAIROS_BRIEF') ? isBriefOnlyState && !viewingAgentTaskId : false;
const mainLoopModel_ = useAppState(s => s.mainLoopModel);
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession);
const thinkingEnabled = useAppState(s => s.thinkingEnabled);
@@ -2111,7 +2111,8 @@ function PromptInput({
useBuddyNotification();
const companionSpeaking = feature('BUDDY') ? useAppState(s => s.companionReaction !== undefined) : false;
const companionReactionState = useAppState(s => s.companionReaction);
const companionSpeaking = feature('BUDDY') ? companionReactionState !== undefined : false;
const { columns, rows } = useTerminalSize();
const textInputColumns = columns - 3 - companionReservedColumns(columns, companionSpeaking);

View File

@@ -230,9 +230,12 @@ function ModeIndicator({
proactiveModule?.getNextTickAt ?? NULL,
NULL,
);
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
const voiceWarmingUp = feature('VOICE_MODE') ? useVoiceState(s => s.voiceWarmingUp) : false;
const voiceEnabledRaw = useVoiceEnabled();
const voiceEnabled = feature('VOICE_MODE') ? voiceEnabledRaw : false;
const voiceStateRaw = useVoiceState(s => s.voiceState);
const voiceState = feature('VOICE_MODE') ? voiceStateRaw : ('idle' as const);
const voiceWarmingUpRaw = useVoiceState(s => s.voiceWarmingUp);
const voiceWarmingUp = feature('VOICE_MODE') ? voiceWarmingUpRaw : false;
const hasSelection = useHasSelection();
const selGetState = useSelection().getState;
const hasNextTick = nextTickAt !== null;
@@ -250,16 +253,19 @@ function ModeIndicator({
const escShortcut = useShortcutDisplay('chat:cancel', 'Chat', 'esc').toLowerCase();
const todosShortcut = useShortcutDisplay('app:toggleTodos', 'Global', 'ctrl+t');
const killAgentsShortcut = useShortcutDisplay('chat:killAgents', 'Chat', 'ctrl+x ctrl+k');
const voiceKeyShortcut = feature('VOICE_MODE') ? useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space') : '';
const voiceKeyShortcutRaw = useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space');
const voiceKeyShortcut = feature('VOICE_MODE') ? voiceKeyShortcutRaw : '';
// Captured at mount so the hint doesn't flicker mid-session if another
// CC instance increments the counter. Incremented once via useEffect the
// first time voice is enabled in this session — approximates "hint was
// shown" without tracking the exact render-time condition (which depends
// on parts/hintParts computed after the early-return hooks boundary).
const [voiceHintUnderCap] = feature('VOICE_MODE')
? useState(() => (getGlobalConfig().voiceFooterHintSeenCount ?? 0) < MAX_VOICE_HINT_SHOWS)
: [false];
const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null;
const [voiceHintUnderCapRaw] = useState(
() => (getGlobalConfig().voiceFooterHintSeenCount ?? 0) < MAX_VOICE_HINT_SHOWS,
);
const voiceHintUnderCap = feature('VOICE_MODE') ? voiceHintUnderCapRaw : false;
const voiceHintIncrementedRefRaw = useRef(false);
const voiceHintIncrementedRef = feature('VOICE_MODE') ? voiceHintIncrementedRefRaw : null;
useEffect(() => {
if (feature('VOICE_MODE')) {
if (!voiceEnabled || !voiceHintUnderCap) return;

View File

@@ -80,7 +80,8 @@ function PromptInputQueuedCommandsImpl(): React.ReactNode {
// already indent themselves). Gate mirrors the brief-spinner/message
// check elsewhere — no teammate-view override needed since this
// component early-returns when viewing a teammate.
const useBriefLayout = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
const useBriefLayout = feature('KAIROS') || feature('KAIROS_BRIEF') ? isBriefOnlyState : false;
// createUserMessage mints a fresh UUID per call; without memoization, streaming
// re-renders defeat Message's areMessagePropsEqual (compares uuid) → flicker.

View File

@@ -78,10 +78,8 @@ export function SpinnerWithVerb(props: Props): React.ReactNode {
// teammate view needs the real spinner (which shows teammate status).
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId);
// Hoisted to mount-time — this component re-renders at animation framerate.
const briefEnvEnabled =
feature('KAIROS') || feature('KAIROS_BRIEF')
? useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
: false;
const briefEnvEnabledRaw = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), []);
const briefEnvEnabled = feature('KAIROS') || feature('KAIROS_BRIEF') ? briefEnvEnabledRaw : false;
// Runtime gate mirrors isBriefEnabled() but inlined — importing from
// BriefTool.ts would leak tool-name strings into external builds. Single

View File

@@ -44,14 +44,18 @@ export default function TextInput(props: Props): React.ReactNode {
const settings = useSettings();
const reducedMotion = settings.prefersReducedMotion ?? false;
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
const voiceStateRaw = useVoiceState(s => s.voiceState);
const voiceState = feature('VOICE_MODE') ? voiceStateRaw : ('idle' as const);
const isVoiceRecording = voiceState === 'recording';
const audioLevels = feature('VOICE_MODE') ? useVoiceState(s => s.voiceAudioLevels) : [];
const audioLevelsRaw = useVoiceState(s => s.voiceAudioLevels);
const audioLevels = feature('VOICE_MODE') ? audioLevelsRaw : [];
const smoothedRef = useRef<number[]>(new Array(CURSOR_WAVEFORM_WIDTH).fill(0));
const needsAnimation = isVoiceRecording && !reducedMotion;
const [animRef, animTime] = feature('VOICE_MODE') ? useAnimationFrame(needsAnimation ? 50 : null) : [() => {}, 0];
const [animRefRaw, animTimeRaw] = useAnimationFrame(needsAnimation ? 50 : null);
const animRef = feature('VOICE_MODE') ? animRefRaw : () => {};
const animTime = feature('VOICE_MODE') ? animTimeRaw : 0;
// Show hint when terminal regains focus and clipboard has an image
useClipboardImageHint(isTerminalFocused, !!props.onImagePaste);

View File

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

View File

@@ -38,27 +38,20 @@ export function UserPromptMessage({ addMargin, param: { text }, isTranscriptMode
// child renders a label-style layout, and Box backgroundColor paints
// behind children unconditionally (they can't opt out).
//
// Hooks stay INSIDE feature() ternaries so external builds don't pay
// the per-scrollback-message store subscription (useSyncExternalStore
// bypasses React.memo). Runtime-gated like isBriefEnabled() but inlined
// to avoid pulling BriefTool.ts → prompt.ts tool-name strings into
// external builds.
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
const viewingAgentTaskId =
feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.viewingAgentTaskId) : null;
// Hooks must always be called unconditionally to satisfy React rules.
// The feature gate is applied to the computed value, not the hook call.
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
const viewingAgentTaskIdState = useAppState(s => s.viewingAgentTaskId);
// Hoisted to mount-time — per-message component, re-renders on every scroll.
const briefEnvEnabled =
feature('KAIROS') || feature('KAIROS_BRIEF')
? useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
: false;
const briefEnvEnabledState = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), []);
const useBriefLayout =
feature('KAIROS') || feature('KAIROS_BRIEF')
? (getKairosActive() ||
(getUserMsgOptIn() &&
(briefEnvEnabled || getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_brief', false)))) &&
isBriefOnly &&
(briefEnvEnabledState || getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_brief', false)))) &&
isBriefOnlyState &&
!isTranscriptMode &&
!viewingAgentTaskId
!viewingAgentTaskIdState
: false;
// Truncate before the early return so the hook order is stable.

View File

@@ -43,10 +43,9 @@ export function UserToolSuccessMessage({
shouldCollapseDiffs,
}: Props): React.ReactNode {
const [theme] = useTheme();
// Hook stays inside feature() ternary so external builds don't pay a
// per-scrollback-message store subscription — same pattern as
// UserPromptMessage.tsx.
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
// Always call hook unconditionally; feature gate applied to the value.
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? isBriefOnlyState : false;
// Capture classifier approval once on mount, then delete from Map to prevent linear growth.
// useState lazy initializer ensures the value persists across re-renders.