fix: 修复条件式 hook 调用导致的 "Rendered fewer hooks than expected" 错误#435
Conversation
修复在 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 claude-code-best#434
📝 WalkthroughWalkthroughThis PR systematically refactors React hook invocation patterns across the codebase to avoid conditional hook calls. Instead of calling hooks only when feature flags are enabled, hooks are now invoked unconditionally and feature gates are applied to their return values. The changes span build configuration, voice mode, brief mode, and state management while preserving all existing runtime behavior. ChangesHook Pattern Refactoring
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
build.ts (1)
65-73:⚠️ Potential issue | 🟠 Major | ⚡ Quick winReset regex state before each
.test()call inside the file loop.The global regex
/gwith.test()maintains state vialastIndex. The current code resets it after the loop completes, but this happens too late—files processed later in the loop may start searching from an incorrect position, causing matches to be skipped. Move theBUN_DESTRUCTURE.lastIndex = 0reset inside the loop before the.test()call to ensure each file is checked from the beginning.Proposed fix
for (const file of files) { if (!file.endsWith('.js')) continue const filePath = join(outdir, file) const content = await readFile(filePath, 'utf-8') + BUN_DESTRUCTURE.lastIndex = 0 if (BUN_DESTRUCTURE.test(content)) { await writeFile( filePath, content.replace(BUN_DESTRUCTURE, BUN_DESTRUCTURE_SAFE), ) bunPatched++ } } -BUN_DESTRUCTURE.lastIndex = 0🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@build.ts` around lines 65 - 73, The BUN_DESTRUCTURE regex is global and its .test() call inside the for loop can skip matches due to lastIndex state; inside the loop (before calling BUN_DESTRUCTURE.test(content)) reset BUN_DESTRUCTURE.lastIndex = 0 so each file is tested from the start, then continue using BUN_DESTRUCTURE_SAFE and writeFile as before (affects the code around the for (const file of files) loop, the BUN_DESTRUCTURE/BUN_DESTRUCTURE_SAFE variables, and the readFile/writeFile logic).
🧹 Nitpick comments (3)
src/components/FeedbackSurvey/useFrustrationDetection.ts (3)
15-15: ⚡ Quick win
as anycast violates the no-anyguideline — use a type guard instead.
(m as any).isApiErrorMessagebypasses type safety. The proper fix is a type guard or an interface extension.♻️ Proposed fix
+function isApiError(m: Message): m is Message & { isApiErrorMessage: true } { + return (m as unknown as { isApiErrorMessage?: boolean }).isApiErrorMessage === true +} + function detectFrustration(messages: Message[]): boolean { - const apiErrors = messages.filter(m => (m as any).isApiErrorMessage) + const apiErrors = messages.filter(isApiError) return apiErrors.length >= 2 }As per coding guidelines: "Prohibit
as anytype assertions in production code; useas unknown as SpecificTypedouble assertion or interface supplementation when type mismatches occur."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/FeedbackSurvey/useFrustrationDetection.ts` at line 15, Replace the unsafe cast in useFrustrationDetection.ts: instead of (m as any).isApiErrorMessage, implement a proper type guard function (e.g., function isApiErrorMessage(msg: unknown): msg is ApiErrorMessage) and use it in the filter that produces apiErrors (const apiErrors = messages.filter(isApiErrorMessage)). Define or extend the ApiErrorMessage interface to include the isApiErrorMessage property so the type guard can narrow types safely and remove the as any assertion.
44-56: ⚡ Quick win
handleTranscriptSelectshould be wrapped inuseCallbackto stabilise its reference.The function is recreated on every render. Any consumer that passes it as a prop or dependency will re-render unnecessarily. The dependencies are
shouldSkip,messages, andsetState.♻️ Proposed fix
- const handleTranscriptSelect = (choice: string) => { + const handleTranscriptSelect = useCallback((choice: string) => { if (shouldSkip) return if (choice === 'yes') { void submitTranscriptShare(messages, 'frustration', crypto.randomUUID()) setState('submitted') } else { - saveGlobalConfig((current: any) => ({ + saveGlobalConfig((current: Record<string, unknown>) => ({ ...current, transcriptShareDismissed: true, })) setState('closed') } - } + }, [shouldSkip, messages])Also add
useCallbackto the import at line 1:-import { useState } from 'react' +import { useState, useCallback } from 'react'🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/FeedbackSurvey/useFrustrationDetection.ts` around lines 44 - 56, handleTranscriptSelect is recreated on every render; wrap it with React's useCallback and add useCallback to the component imports so its reference is stable. Change the declaration of handleTranscriptSelect to useCallback(() => { ... }, [shouldSkip, messages, setState]) and keep the internal logic (calls to submitTranscriptShare, saveGlobalConfig and crypto.randomUUID()) unchanged; ensure the dependency array includes shouldSkip, messages and setState so the callback updates correctly.
50-50: ⚡ Quick win
current: anyin thesaveGlobalConfigcallback violates the no-anyguideline.♻️ Proposed fix
- saveGlobalConfig((current: any) => ({ + saveGlobalConfig((current: Record<string, unknown>) => ({ ...current, transcriptShareDismissed: true, }))As per coding guidelines: "Use
Record<string, unknown>instead ofanyfor objects with unknown structure."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/FeedbackSurvey/useFrustrationDetection.ts` at line 50, The callback passed to saveGlobalConfig uses the parameter typed as any (current: any); replace that with a safer type per guidelines—use current: Record<string, unknown> (or a more specific interface if available) in the saveGlobalConfig callback inside useFrustrationDetection.ts so the callback signature avoids any while preserving the existing logic and return shape for saveGlobalConfig.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@scripts/dev.ts`:
- Around line 19-21: Update the stale comment above the process.env.NODE_ENV
definition in scripts/dev.ts to reflect that NODE_ENV is set to "development"
(not production); locate the comment immediately preceding the
'process.env.NODE_ENV': JSON.stringify('development') entry and change the text
so it accurately describes the development-mode behavior (e.g., mention React
development mode and that this prevents accumulation of _debugStack Error
objects during long-running sessions).
In `@src/components/FeedbackSurvey/useFrustrationDetection.ts`:
- Line 28: The code uses a banned "as any" cast when calling isPolicyAllowed;
replace it by either adding 'product_feedback' to the PolicyKey union type if
it's a valid policy key, or perform a safe double-cast like `'product_feedback'
as unknown as PolicyKey` when calling isPolicyAllowed (update the call site
where policyAllowed is assigned and ensure isPolicyAllowed's parameter type is
PolicyKey); remove the "as any" cast and run type checks to confirm no remaining
mismatches.
In `@src/hooks/useUpdateNotification.ts`:
- Around line 22-23: Validate updatedVersion with semver.valid(...) using the
same loose option before calling getSemverPart to avoid exceptions; in the
useUpdateNotification flow replace the direct call const updatedSemver =
updatedVersion ? getSemverPart(updatedVersion) : null with a validation step
(e.g., const isValid = semver.valid(updatedVersion, { loose: true })) and only
call getSemverPart(updatedVersion) when isValid is true, otherwise set
updatedSemver to null so the subsequent if (!updatedSemver) branch is safe.
---
Outside diff comments:
In `@build.ts`:
- Around line 65-73: The BUN_DESTRUCTURE regex is global and its .test() call
inside the for loop can skip matches due to lastIndex state; inside the loop
(before calling BUN_DESTRUCTURE.test(content)) reset BUN_DESTRUCTURE.lastIndex =
0 so each file is tested from the start, then continue using
BUN_DESTRUCTURE_SAFE and writeFile as before (affects the code around the for
(const file of files) loop, the BUN_DESTRUCTURE/BUN_DESTRUCTURE_SAFE variables,
and the readFile/writeFile logic).
---
Nitpick comments:
In `@src/components/FeedbackSurvey/useFrustrationDetection.ts`:
- Line 15: Replace the unsafe cast in useFrustrationDetection.ts: instead of (m
as any).isApiErrorMessage, implement a proper type guard function (e.g.,
function isApiErrorMessage(msg: unknown): msg is ApiErrorMessage) and use it in
the filter that produces apiErrors (const apiErrors =
messages.filter(isApiErrorMessage)). Define or extend the ApiErrorMessage
interface to include the isApiErrorMessage property so the type guard can narrow
types safely and remove the as any assertion.
- Around line 44-56: handleTranscriptSelect is recreated on every render; wrap
it with React's useCallback and add useCallback to the component imports so its
reference is stable. Change the declaration of handleTranscriptSelect to
useCallback(() => { ... }, [shouldSkip, messages, setState]) and keep the
internal logic (calls to submitTranscriptShare, saveGlobalConfig and
crypto.randomUUID()) unchanged; ensure the dependency array includes shouldSkip,
messages and setState so the callback updates correctly.
- Line 50: The callback passed to saveGlobalConfig uses the parameter typed as
any (current: any); replace that with a safer type per guidelines—use current:
Record<string, unknown> (or a more specific interface if available) in the
saveGlobalConfig callback inside useFrustrationDetection.ts so the callback
signature avoids any while preserving the existing logic and return shape for
saveGlobalConfig.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 51e4a7c7-13ff-4bf5-a8bd-3a4384ef5147
📒 Files selected for processing (18)
build.tsscripts/dev.tssrc/components/FeedbackSurvey/useFrustrationDetection.tssrc/components/PromptInput/Notifications.tsxsrc/components/PromptInput/PromptInput.tsxsrc/components/PromptInput/PromptInputFooterLeftSide.tsxsrc/components/PromptInput/PromptInputQueuedCommands.tsxsrc/components/Spinner.tsxsrc/components/TextInput.tsxsrc/components/messages/AttachmentMessage.tsxsrc/components/messages/UserPromptMessage.tsxsrc/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsxsrc/hooks/useGlobalKeybindings.tsxsrc/hooks/useIssueFlagBanner.tssrc/hooks/useReplBridge.tsxsrc/hooks/useUpdateNotification.tssrc/hooks/useVoiceIntegration.tsxsrc/screens/REPL.tsx
| // React production mode — prevents 6,889+ _debugStack Error objects | ||
| // (12MB) from accumulating during long-running sessions. | ||
| 'process.env.NODE_ENV': JSON.stringify('production'), | ||
| 'process.env.NODE_ENV': JSON.stringify('development'), |
There was a problem hiding this comment.
Update stale comment to match NODE_ENV="development" behavior.
Line 19 still says “React production mode”, but Line 21 now sets development. Please align the comment to avoid misleading future changes.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/dev.ts` around lines 19 - 21, Update the stale comment above the
process.env.NODE_ENV definition in scripts/dev.ts to reflect that NODE_ENV is
set to "development" (not production); locate the comment immediately preceding
the 'process.env.NODE_ENV': JSON.stringify('development') entry and change the
text so it accurately describes the development-mode behavior (e.g., mention
React development mode and that this prevents accumulation of _debugStack Error
objects during long-running sessions).
| if (isLoading || hasActivePrompt || otherSurveyOpen) { | ||
| return { state: 'closed', handleTranscriptSelect: () => {} } | ||
| } | ||
| const policyAllowed = isPolicyAllowed('product_feedback' as any) |
There was a problem hiding this comment.
as any cast on isPolicyAllowed argument indicates a type mismatch that should be resolved properly.
'product_feedback' as any silences a compile error rather than fixing it. Either the accepted policy key union type should include 'product_feedback', or a as unknown as PolicyKey double-cast should be used per guidelines.
♻️ Proposed fix (if `PolicyKey` is the union type)
- const policyAllowed = isPolicyAllowed('product_feedback' as any)
+ const policyAllowed = isPolicyAllowed('product_feedback' as unknown as PolicyKey)Alternatively, add 'product_feedback' to the PolicyKey union type if it is a legitimate key.
As per coding guidelines: "Prohibit as any type assertions in production code; use as unknown as SpecificType double assertion."
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const policyAllowed = isPolicyAllowed('product_feedback' as any) | |
| const policyAllowed = isPolicyAllowed('product_feedback' as unknown as PolicyKey) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/FeedbackSurvey/useFrustrationDetection.ts` at line 28, The
code uses a banned "as any" cast when calling isPolicyAllowed; replace it by
either adding 'product_feedback' to the PolicyKey union type if it's a valid
policy key, or perform a safe double-cast like `'product_feedback' as unknown as
PolicyKey` when calling isPolicyAllowed (update the call site where
policyAllowed is assigned and ensure isPolicyAllowed's parameter type is
PolicyKey); remove the "as any" cast and run type checks to confirm no remaining
mismatches.
| const updatedSemver = updatedVersion ? getSemverPart(updatedVersion) : null | ||
| if (!updatedSemver) { |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In semver v7, what do major()/minor()/patch() do when passed an invalid version string? Do they throw or return null?
💡 Result:
In semver v7 (npm/node-semver package version 7.x), the major, minor, and patch functions throw a TypeError with message "Invalid Version: {version}" when passed an invalid version string. They do not return null.[1][2][3]
Citations:
- 1: Invalid Version error running Renovate 24.52.0 against Poetry project renovatebot/renovate#8740
- 2: https://www.npmjs.com/package/semver?activeTab=code
- 3: fix: throw on bad version with correct error message npm/node-semver#552
🏁 Script executed:
cat -n src/hooks/useUpdateNotification.tsRepository: claude-code-best/claude-code
Length of output: 1270
🌐 Web query:
semver v7 loose option behavior major minor patch invalid version
💡 Result:
In npm's node-semver library version 7.x (latest 7.7.4 as of 2026), the 'loose' option makes parsing more forgiving for not-quite-valid semver strings, primarily by allowing prerelease tags without the required '-' separator (e.g., semver.valid('1.2.3beta', {loose: true}) returns '1.2.3-beta' [1][2][3]). Output is always strict-compliant semver. For major, minor, patch extraction: Functions like major(v), minor(v), patch(v) first parse/validate v using loose rules if specified, then extract the respective component from the parsed SemVer object [4][2]. If invalid even loosely, they return undefined/null. Invalid versions: semver.valid('a.b.c') returns null even with loose [4]. Loose does not accept non-numeric components in major/minor/patch or malformed structures like missing parts ('2.0' likely null in loose [5]). It may misparse extras like '1.2.34.5' as '1.2.3-4.5' by treating extra as prerelease [5]. No major changes to loose behavior in v7; recent fixes ensure CLI passes loose to valid [1][6]. Use coerce for aggressive non-semver to semver conversion (e.g., '42.6.7.9.3-alpha' -> '42.6.7'), independent of loose [4][7].
Citations:
- 1: fix(cli): pass options to semver.valid() for loose version validation npm/node-semver#835
- 2: https://www.npmjs.com/package/semver
- 3: https://github.com/npm/node-semver/blob/main/README.md
- 4: https://github.com/npm/node-semver/
- 5: [QUESTION] Incorrect loose parsing of version? npm/node-semver#164
- 6: https://github.com/npm/node-semver/blob/main/CHANGELOG.md
- 7:
coercedrops build and prerelease information npm/node-semver#592
🌐 Web query:
semver.major() with loose:true throws or returns null invalid version
💡 Result:
In the npm/node-semver library, semver.major(v) returns a major number (it’s not documented to return null for invalid versions), and loose:true is only relevant when parsing/validating options are actually used by the underlying logic. The public semver docs state that valid(v) returns null if it’s not valid, while major(v) “Return the major version number” (no null/loose behavior described for major specifically) [1]. So the behavior “semver.major with { loose:true } throws or returns null for an invalid version” is not a specific/known “bug” in the major API itself. What can happen is: 1) If the input string is not valid semver, the code path may throw a TypeError “Invalid Version: …” (common across semver operations when invalid inputs are passed to logic that expects a valid SemVer object) [2][3]. 2) If you use the CLI or other helper code that calls semver.valid(v, options), loose:true can change validation results (for example, the npm CLI had a loose-flag validation issue because it called semver.valid(v) without passing the loose option, and it was fixed to call semver.valid(v, options)) [4]. Practical takeaway: don’t rely on semver.major(v, { loose:true }) returning null for invalid input. If you need “null instead of throw,” call semver.valid(v, { loose: true }) first; docs explicitly say valid(v) “Return the parsed version, or null if it’s not valid” [1]. Minimal example pattern (node-semver): - if (!semver.valid(input, { loose: true })) return null; - else return semver.major(input); Citations: semver.valid returns parsed version or null [1]; semver.major is described as returning the major version number [1]; npm CLI loose validation bug/fix indicates passing loose into semver.valid is what changes loose behavior [4]; invalid version commonly throws TypeError in semver operations [2][3].
Citations:
- 1: https://www.npmjs.com/package/semver?activeTab=code
- 2: [BUG] Invalid semver in package history causes crash when installing a package npm/cli#5017
- 3: fix: throw on bad version with correct error message npm/node-semver#552
- 4: fix(cli): pass options to semver.valid() for loose version validation npm/node-semver#835
Guard semver parsing before calling getSemverPart to avoid render-time crashes.
A truthy but invalid updatedVersion will throw via major()/minor()/patch() at line 22 during render, even with { loose: true }. The loose option only affects validation; it does not prevent exceptions when parsing malformed versions. Validate first using semver.valid() with the same loose option, then parse only if valid.
Proposed fix
-import { major, minor, patch } from 'semver'
+import { major, minor, patch, valid } from 'semver'
@@
- const updatedSemver = updatedVersion ? getSemverPart(updatedVersion) : null
+ const normalizedUpdatedVersion =
+ updatedVersion && valid(updatedVersion, { loose: true })
+ ? updatedVersion
+ : null
+ const updatedSemver = normalizedUpdatedVersion ? getSemverPart(normalizedUpdatedVersion) : null📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const updatedSemver = updatedVersion ? getSemverPart(updatedVersion) : null | |
| if (!updatedSemver) { | |
| const normalizedUpdatedVersion = | |
| updatedVersion && valid(updatedVersion, { loose: true }) | |
| ? updatedVersion | |
| : null | |
| const updatedSemver = normalizedUpdatedVersion ? getSemverPart(normalizedUpdatedVersion) : null | |
| if (!updatedSemver) { |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/hooks/useUpdateNotification.ts` around lines 22 - 23, Validate
updatedVersion with semver.valid(...) using the same loose option before calling
getSemverPart to avoid exceptions; in the useUpdateNotification flow replace the
direct call const updatedSemver = updatedVersion ? getSemverPart(updatedVersion)
: null with a validation step (e.g., const isValid =
semver.valid(updatedVersion, { loose: true })) and only call
getSemverPart(updatedVersion) when isValid is true, otherwise set updatedSemver
to null so the subsequent if (!updatedSemver) branch is safe.
修复在 dev 模式下按下 Ctrl+O 切换 transcript 视图时 React 抛出
"Rendered fewer hooks than expected" 崩溃的问题。
Fixes #434
根因分析
项目中有大量 hook(useState / useMemo / useRef / useSyncExternalStore 等) 被包裹在
feature()三元表达式中条件调用,例如:在 build 模式下
feature()是编译时常量,死代码消除会移除未使用的分支,hooks 数量在编译后是确定的。但在 dev 模式下(scripts/dev.ts 注入
--feature 启用全部 31 个 feature),
feature()是运行时调用,但始终返回 true,因此所有 hooks 都会被调用,原本不会出问题。
真正的触发器是 REPL.tsx 第 5381 行的提前返回:
当用户按下 Ctrl+O 进入 transcript 模式时,该提前返回之后的所有 hooks
(如 displayedAgentMessages 的 useMemo)都不会被调用,导致 React 在 下一次渲染时检测到 hooks 数量与上次不一致而崩溃。
此外,其他文件中也存在相同的条件式 hook 模式——虽然 dev 模式下
feature() 返回 true,所以这些路径实际上不会被触发,但它们是
潜在的隐患:若将来有人通过环境变量关闭某个 feature,
同样的崩溃会立即出现。
修复策略
采用统一模式:始终无条件调用 hook,将 feature() gate 应用到值上。
修改清单
核心修复(REPL.tsx)
displayedAgentMessagesuseMemo 及依赖变量(viewedTask / viewedTeammateTask / viewedAgentTask / usesSyncMessages / rawAgentMessages / displayedMessages)从 transcript 提前返回 之后移至之前,确保两模式下 hooks 调用顺序一致disableMessageActions/useAssistantHistory/voiceIntegration的条件式 hook 调用条件式 hook 修复(11 个文件)
其他修复
构建/开发配置
sourcemap: 'linked'Need help on this PR? Tag
@codesmithwith what you need.Summary by CodeRabbit
Chores
Refactor