fix(cli): yield to React after addItem to reduce input lag - #6059
Conversation
User messages were appearing in history with a noticeable delay (200-500ms) after the input box was cleared. This happened because addItem() triggered a React state update, but the subsequent sync operations (@-command processing, vision bridge) blocked React from rendering until the next await in the call chain. Adding await Promise.resolve() after addItem() yields control to the event loop, giving React a microtask window to render the user message before continuing with background processing. This makes the input-box clear and message appearance feel nearly instantaneous. Fixes the visual gap between input submission and history display. Signed-off-by: Alex <alex.tech.lab@outlook.com>
doudouOUC
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅
— qwen3.7-max via Qwen Code /review
| // Yield to let React render the user message before continuing | ||
| // with @-command processing and API call. Reduces the visual lag | ||
| // between input-box clear and the message appearing in history. | ||
| await Promise.resolve(); |
There was a problem hiding this comment.
[Review Bot] await Promise.resolve() only yields a microtask — it does not give React a chance to render, so this line is effectively a no-op.
React 19.2.4 (Ink 7.0.3) schedules renders via MessageChannel.postMessage, which is a macrotask. The JS event loop drains all microtasks before any macrotask fires, so the continuation after this await runs before React's scheduler has a chance to flush. The comment above claims this yields to React, but that's not what happens.
The pre-patch code already reaches a natural await almost immediately after addItem — either await handleAtCommand(...) (which internally does await resolveAtCommandQuery() on its first line) or await applyVisionBridgeIfNeeded(...) (which early-returns a resolved Promise for non-image messages). Those real awaits already yield to the event loop at the same point a microtask yield would, so adding one extra microtask here changes nothing about when React can render.
The perceived lag reduction in the PR description (200–500ms → 50–100ms) is more likely explained by those subsequent I/O-bound awaits, not this line.
Suggestions:
- To actually flush a render before continuing, use a macrotask yield:
await new Promise(r => setTimeout(r, 0))(costs ~4ms). - Or remove this line entirely and keep the existing behavior — the subsequent
await handleAtCommand/await applyVisionBridgeIfNeededalready provide natural render windows. - If keeping the line, at least update the comment to accurately describe what it does (one microtask yield, no render flush), so future readers aren't misled.
| // the continuation runs first. A setTimeout(0) macrotask yield (~4ms) | ||
| // lets the render flush before resuming background work, reducing the | ||
| // visual lag between input-box clear and the message appearing. | ||
| await new Promise((r) => setTimeout(r, 0)); |
There was a problem hiding this comment.
Nit (performance): setTimeout(r, 0) introduces a minimum ~1ms delay in Node.js (the comment says ~4ms, but that is the browser nested-setTimeout clamp; Ink runs in Node.js). setImmediate would be slightly faster with no minimum delay and is already used elsewhere in this codebase (e.g. KeypressContext.tsx:1223, MainContent.tsx:219).
In the Node.js event loop, setImmediate fires in the check phase, which is after I/O events — and MessageChannel.postMessage (React's render scheduler) is delivered as an I/O event. So setImmediate still guarantees React gets to render first, same as setTimeout(0), just without the timer overhead.
Suggestion:
await new Promise((r) => setImmediate(r));This is a minor optimization, not a correctness issue — the current fix is sound.
The previous attempt used 'await Promise.resolve()' which only yields a microtask. React 19.2.4 (Ink 7.0.3) schedules renders via MessageChannel.postMessage, a macrotask. Since the event loop drains all microtasks before any macrotask fires, the microtask yield did not give React a chance to render — the continuation after the await ran first. Replace with 'await new Promise(r => setTimeout(r, 0))', a macrotask yield that lets React flush the user message render (~4ms delay) before continuing with @-command processing and API calls. This reduces the visual lag between input-box clear and the message appearing in history. Signed-off-by: Alex <alex.tech.lab@outlook.com>
e287f97 to
6389b39
Compare
DragonnZhang
left a comment
There was a problem hiding this comment.
LGTM. The technical reasoning is sound:
- React 19's scheduler uses
MessageChannel.postMessage(a macrotask) to schedule renders. In Node.js,MessagePortmessages are delivered viauv_async_sendin the poll phase of the event loop. setImmediatefires in the check phase, which runs after the poll phase — so React's render is guaranteed to have been delivered and executed before the continuation resumes.- A microtask yield (
await Promise.resolve()) would indeed NOT give React a chance to render, since microtasks run between event loop phases, before the poll phase delivers the MessageChannel message. setTimeout(fn, 0)would also work but adds ~1ms of timer overhead;setImmediateis the tighter choice.
The setImmediate pattern is already well-established in this codebase (18+ uses in packages/cli/src alone). The comment explaining the rationale is thorough and will help future maintainers.
One minor observation (non-blocking): the setImmediate yield sits outside the if (submitType !== SendMessageType.Cron) block, so Cron messages also incur the yield even though addItem was skipped and there is no pending user-message render to flush. This is harmless (sub-ms) but slightly wasteful. Consider moving the yield inside the if block if you want to be precise, or leave it — the impact is negligible.
| // the continuation runs first. setImmediate fires in the check phase | ||
| // after I/O events (where MessageChannel delivers its postMessage), | ||
| // guaranteeing React renders first without the ~1ms timer overhead | ||
| // of setTimeout(0). |
There was a problem hiding this comment.
[Suggestion] The setImmediate yield is placed outside the if (submitType !== SendMessageType.Cron) guard, but Cron submissions skip addItem() entirely — there's nothing for React to flush. This adds an unnecessary macrotask roundtrip on the Cron path.
Move the yield inside the guard, immediately after lastTurnUserItemRef.current:
| // of setTimeout(0). | |
| // Yield via macrotask to let Ink/React flush the user message render | |
| // before continuing with @-command processing and API call. | |
| // React 19.2.4 (Ink 7.0.3) schedules renders via | |
| // MessageChannel.postMessage (a macrotask), so a microtask yield | |
| // (await Promise.resolve()) does NOT give React a chance to render — | |
| // the continuation runs first. setImmediate fires in the check phase | |
| // after I/O events (where MessageChannel delivers its postMessage), | |
| // guaranteeing React renders first without the ~1ms timer overhead | |
| // of setTimeout(0). | |
| await new Promise((r) => setImmediate(r)); |
(with the yield moved inside the if block, right after the lastTurnUserItemRef.current assignment.)
— qwen3.7-max via Qwen Code /review
| }; | ||
| } | ||
|
|
||
| // Yield via macrotask to let Ink/React flush the user message render |
There was a problem hiding this comment.
[Suggestion] This PR introduces a behavioral invariant (user message renders before API call proceeds) with no automated test to guard against regression. A future contributor removing the yield as "unnecessary" would silently reintroduce the original lag.
Consider adding a minimal test verifying that prepareQueryForGemini yields at least once between addItem and its return for the non-Cron path. The project already uses flushAsync / setImmediate drain patterns in useDeleteCommand.test.ts and MainContent.test.tsx.
Also: the PR description still says "microtask yield" / await Promise.resolve() but the code uses setImmediate. The description should be updated before merge.
— qwen3.7-max via Qwen Code /review
Reviewer noted that Cron submissions skip addItem() entirely, so the macrotask yield was adding an unnecessary roundtrip on the Cron path. Move the yield inside the 'if (submitType !== SendMessageType.Cron)' block, immediately after lastTurnUserItemRef.current assignment, so it only runs for submissions that actually add a user message to history. Signed-off-by: Alex <alex.tech.lab@outlook.com>
| // without the ~1ms timer overhead of setTimeout(0). | ||
| // Only needed for non-Cron submissions since Cron skips addItem(). | ||
| await new Promise((r) => setImmediate(r)); | ||
| } |
There was a problem hiding this comment.
[Suggestion] The setImmediate yield creates a sub-millisecond window where the user could cancel (press Escape). After the yield, execution falls through to handleAtCommand, applyVisionBridgeIfNeeded, and eventually sendMessageStream without re-checking turnCancelledRef. While the abort signal IS propagated to all downstream calls (so the API call fails cleanly rather than succeeding with stale data), adding an early re-check is consistent with the entry guard at line 922 and avoids unnecessary downstream work.
| } | |
| await new Promise((r) => setImmediate(r)); | |
| if (turnCancelledRef.current) { | |
| return { queryToSend: null, shouldProceed: false }; | |
| } |
— qwen3.7-max via Qwen Code /review
|
@qwen-code /triage |
|
Thanks for the PR! Template looks good ✓ — all required sections present, bilingual description complete. On direction: this solves a real UX problem — the perceptible lag between pressing Enter and seeing your message appear in the chat history is genuinely jarring. React 19 + Ink 7's macrotask-based render scheduling makes this a legitimate concern, and the fix is squarely within qwen-code's core mission of providing a responsive CLI experience. No CHANGELOG reference for this specific issue, but input responsiveness is clearly in scope. On approach: the change is refreshingly minimal — one Moving on to code review and testing. 🔍 中文说明感谢贡献! 模板完整 ✓ — 所有必需部分齐全,中英双语描述完整。 方向:这解决了一个真实的 UX 问题——按下回车到看到消息出现在聊天记录中的可感知延迟确实令人不适。React 19 + Ink 7 基于宏任务的渲染调度使这成为一个合理的关注点,修复完全在 qwen-code 提供响应式 CLI 体验的核心使命范围内。CHANGELOG 中没有这个特定问题的参考,但输入响应性显然在范围内。 方案:改动非常精简——一行 进入代码审查和测试阶段。🔍 — Qwen Code · qwen3.7-max |
CI failure root cause: the new
|
useGeminiStream.test.tsx |
Result |
|---|---|
| PR head as-is (yield present) | 9 failed / 139 passed (matches CI) |
| remove only line 1050 | 148 passed / 0 failed |
git diff is otherwise clean — that one line is the sole differentiator.
Fix options (author's call)
- A — adjust the 9 tests: flush the pending immediate before asserting, e.g. add
await vi.advanceTimersByTimeAsync(0);right after the microtask drain. It fires the 0-delaysetImmediatewithout advancing the throttle clock, so the subsequent throttle-dependent assertions still hold. Verified: this makes a failing test pass with the production line untouched. - B — reconsider the yield: an unconditional macrotask yield on every non-Cron submission inserts a full event-loop turn ahead of
@-command processing and API dispatch. If the render flush can be achieved without a blockingawaitthat gatessendMessageStream, both the test breakage and the hot-path latency both go away.
🇨🇳 中文说明
CI 失败根因:新增的 setImmediate 让权把 9 个既有的假定时器(fake-timer)测试卡死
红色的检查项是 Test (ubuntu-latest, Node 22.x) → 步骤 "Run tests and generate reports"。这是确定性的测试回归,不是 runner 抖动:head 提交 787465d 上两次独立重跑(attempt 1 + attempt 2)都是同样这 9 个测试失败,而 lint / prettier / schema / bundle 各步骤全绿。
失败的是什么
packages/cli/src/ui/hooks/useGeminiStream.test.tsx — 9 个测试,全部集中在 Cancellation(6 个)与 Thought Reset(3 个)两个套件:
expected "spy" to be called 1 times, but got 0 times—mockSendMessageStream从未被调用expected [] to deeply equal [ … ]—pendingHistoryItems从未填充expected undefined to be true—turnProducedMeaningfulContent从未被置位
为什么
本 PR 新增的这一行就是全部原因:
// packages/cli/src/ui/hooks/useGeminiStream.ts:1050
await new Promise((r) => setImmediate(r));9 个失败测试全部运行在 vi.useFakeTimers() 下。Vitest 的假时钟会替换 setImmediate,因此它只有在测试推进定时器(vi.advanceTimersByTime* / runAllTimers*)时才会触发。但这些测试驱动 hook 的方式只排空微任务(microtask):
act(() => { void result.current.submitQuery('test query'); });
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(mockSendMessageStream).toHaveBeenCalledTimes(1); // ← 现在是 0由于新增的让权是一个被假时钟扣住的宏任务(macrotask),执行就卡在这个 yield 处——后续的延续逻辑(@-command 处理 → sendMessageStream() → 流式处理)永远不会运行。
讽刺的是,PR 描述本身恰恰说明了它为何会坏:它故意用宏任务,正是因为*"事件循环会先排空所有微任务,才执行任何宏任务"*。而这些测试恰恰就是用这种微任务排空(await Promise.resolve())来推进 hook 的,所以它们永远迈不过这个新的宏任务边界。
运行时行为是正常的 —— 真实的
setImmediate会在下一个 check 阶段正常触发。只有假定时器测试会坏。
证据 —— 在 PR head 上的本地 A/B
useGeminiStream.test.tsx |
结果 |
|---|---|
| PR head 原样(保留 yield) | 9 failed / 139 passed(与 CI 一致) |
| 仅删除第 1050 行 | 148 passed / 0 failed |
其余 git diff 干净——这一行是唯一的差异来源。
修复方向(由作者定夺)
- 方案 A —— 改这 9 个测试: 在断言前把挂起的 immediate 冲掉,例如在微任务排空之后加一句
await vi.advanceTimersByTimeAsync(0);。它只触发 0 延迟的setImmediate,不会推进节流(throttle)时钟,因此后续依赖节流的断言仍然成立。已验证: 保留生产代码那一行不动,这样改能让一个原本失败的测试通过。 - 方案 B —— 重新考虑这个 yield: 在每次非 Cron 提交路径上无条件插入一个宏任务让权,会在
@-command 处理和 API 派发之前多插入整整一个事件循环周期。如果能在不用阻塞式await(且不卡住sendMessageStream)的前提下完成这次渲染 flush,那么测试破坏和热路径延迟这两个问题就都能规避。
9 tests under vi.useFakeTimers() stalled because the new setImmediate yield after addItem() is held by the fake clock. These tests only drained microtasks (await Promise.resolve()), so execution never reached sendMessageStream(). Add 'await vi.advanceTimersByTimeAsync(0)' after the microtask drain in each affected test to fire the pending setImmediate without advancing the throttle clock. Fixes: - Cancellation: buffers streamed content until the throttle interval elapses - Cancellation: buffers streamed thoughts until the throttle interval elapses - Cancellation: splits oversized streamed thoughts so the pending item stays bounded - Cancellation: flushes buffered content before cancellation - Cancellation: flags turnProducedMeaningfulContent=true when a content event landed even before cancel - Cancellation: flushes buffered stream events before snapshotting pendingItem so cancelling mid-throttle does not lose content - Thought Reset: should commit thought to history on non-continuation Retry - Thought Reset: should show a retry countdown and update pending history over time - Thought Reset: should clear retry errors after auto-retry succeeds once the countdown has elapsed Signed-off-by: Alex <alex.tech.lab@outlook.com>
wenshao
left a comment
There was a problem hiding this comment.
No review findings. The technical reasoning is sound — setImmediate correctly yields to React's MessageChannel-based scheduler in the check phase, and the test updates properly flush the new macrotask under fake timers. LGTM! ✅
— qwen3.7-max via Qwen Code /review
🧪 E2E Tmux Test Report — PR #6059Test Environment
Test Results1. CLI Startup — ✅ PASS
2. First Query:
|
|
@qwen-code /triage |
Code ReviewThe production change is a single The test changes are mechanical and correct: 9 instances of No correctness issues, no security concerns, no AGENTS.md violations. The diff is focused — every line serves the stated goal, no drive-by refactors or scope creep. TestingUnit TestsAll 148 tests in CIAll checks green — 4 successful, 15 skipped, 0 failing. Tmux Interactive Test (this rerun)Test environment: Ubuntu Linux x86_64, Node.js v22.22.2, Query 1: Query 2: Both queries rendered correctly — user message appeared in history, model responded accurately, no crashes or rendering artifacts. Multi-turn session stable across consecutive turns. 中文说明代码审查生产代码改动是 测试改动是机械性的且正确的:在既有的微任务排空之后插入 9 处 无正确性问题,无安全隐患,无 AGENTS.md 违规。diff 专注——每一行都服务于既定目标,无顺手重构或范围蔓延。 测试单元测试
CI全部检查绿色——4 成功,15 跳过,0 失败。 Tmux 交互式测试(本次重跑)测试环境: Ubuntu Linux x86_64,Node.js v22.22.2, CLI 正常启动并渲染 TUI。两条查询均正确渲染——用户消息出现在历史记录中,模型准确回复,无崩溃或渲染异常。多轮会话在连续对话中保持稳定。 — Qwen Code · qwen3.7-max |
VerdictThis is a clean, well-understood fix. The author clearly knows the event loop — the PR description reads like a textbook explanation of why Looking at it from the maintainer's chair: one line of production code, a comment that earns its keep, and a mechanical test fix that's the minimum viable response to the CI breakage the previous triage round flagged. The author shipped the fix within hours. The diff is focused — no refactoring, no "while I'm here" changes, no abstractions for future use. My independent proposal before reading the diff: The sub-100ms input lag improvement can't be meaningfully measured through tmux Approving. ✅ 中文说明结论这是一个干净、理解透彻的修复。作者显然了解事件循环——PR 描述读起来像是关于为什么 从维护者的角度看:一行生产代码,一段物有所值的注释,以及一个机械性的测试修复——这是对上一轮 triage 标记的 CI 故障的最小可行响应。作者在数小时内就推送了修复。diff 专注——没有重构,没有"顺便改一下"的变更,没有面向未来的抽象。 我在阅读 diff 之前的独立方案: 低于 100ms 的输入延迟改善无法通过 tmux 批准。✅ — Qwen Code · qwen3.7-max |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
| await act(async () => { | ||
| await Promise.resolve(); | ||
| await Promise.resolve(); | ||
| // Flush the macrotask yield (setImmediate) added after addItem() |
There was a problem hiding this comment.
[Suggestion] No regression test asserts that the yield is a macrotask (not a microtask). All 9 updated tests flush via vi.advanceTimersByTimeAsync(0) and then assert sendMessageStream.toHaveBeenCalledTimes(1) — but if someone replaces setImmediate with Promise.resolve() (or removes the yield entirely), the two await Promise.resolve() lines already present would flush the microtask, and all assertions still pass. The input lag silently returns with no test failure.
Consider adding a test that asserts the ordering invariant:
it('yields via setImmediate so React can render before API call', async () => {
vi.useFakeTimers();
// ... setup ...
act(() => { void result.current.submitQuery('hello'); });
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
// setImmediate hasn't fired yet — sendMessageStream must NOT be called
expect(mockSendMessageStream).not.toHaveBeenCalled();
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
});This guards against the yield being weakened to a microtask in the future.
— qwen3.7-max via Qwen Code /review
🧪 Local real-binary verification (maintainer)I built the real CLI at the PR head ( TL;DR
1. No regression — all green
2. Input-lag benefit not reproducible (real A/B, n=6 each)Real The two distributions are statistically indistinguishable and fully overlapping (baseline is if anything marginally faster on its best run). The model request leaves the process at ~200 ms in both, i.e. the message is already on screen ~150 ms before the request — with or without the fix. So the pipeline already yields early enough on its own; the added 3. Why — the comment's mechanism doesn't match this runtimeThe comment states "React 19.2.4 (Ink 7.0.3) schedules renders via
A faithful real-
RecommendationThis is a low-risk, harmless one-liner (one extra event-loop tick per non-Cron submit; tests green; no regression). But as written it appears to be a no-op for its stated purpose in the Ink 7.0.3 / React 19.2.4 runtime, and the explanatory comment is technically inaccurate (it's Before merging, consider either:
Verification harness (fake OpenAI server, in-process stdout/fetch/connect probe, tmux A/B driver, real-Ink ordering tests) available on request. 🀄 中文版(完整对应)🧪 本地真实二进制验证(维护者)我在 PR head( 结论速览
1. 无回归 —— 全绿
2. 输入延迟收益无法复现(真实 A/B,各 6 次)真实 两组分布统计上无法区分、完全重叠(baseline 在它最好的一次里甚至略快)。模型请求在两个构建里都是 ~200ms 才离开进程,也就是说消息在请求发出前 ~150ms 就已经在屏幕上了 —— 加不加 fix 都一样。说明流水线本身就已经足够早地让出事件循环;对普通一轮对话来说,这个 3. 原因 —— 注释的机制与当前运行时不符注释说:"React 19.2.4 (Ink 7.0.3) 通过
忠实的真实
建议这是个低风险、无害的单行改动(每次非 Cron 提交多一个事件循环 tick;测试全绿;无回归)。但就目前写法而言,在 Ink 7.0.3 / React 19.2.4 运行时下它对其宣称的目的基本是空操作,而且解释性注释在技术上不准确(实际是 合并前建议:
验证脚手架(伪 OpenAI 服务器、进程内 stdout/fetch/connect 探针、tmux A/B 驱动、real-Ink 排序测试)可按需提供。 |
Local verification report — PR #6059Built the PR head ( Environment: macOS, Node What the PR does
Results
Mutation M1 — is the test change necessary? Keep the source Mutation M2 — coverage note. Remove the source Behavioral claim — confirmed 3 independent ways (real runtime, no
So the user message is painted before the continuation only with the Non-blocking: code-comment accuracyThe comment claims "React 19.2.4 (Ink 7.0.3) schedules renders via Reverse audit — no functional issues
Recommendation✅ Approve / merge. Optionally tweak the code comment to say 中文版(点击展开)PR #6059 本地验证报告在隔离 worktree 里构建了 PR head( 环境: macOS,Node 这个 PR 做了什么
结果
变异 M1 — 测试改动是否必要? 保留源码 变异 M2 — 覆盖率说明。 去掉源码 行为主张 — 3 种独立方式确认(真实运行时,无
所以只有在 不阻塞:代码注释准确性注释称 "React 19.2.4 (Ink 7.0.3) schedules renders via 反向审计 — 无功能问题
建议✅ 批准 / 合并。 可选:把代码注释里的 |
What this PR does
Adds a macrotask yield point (
await new Promise(r => setImmediate(r))) afteraddItem()in theprepareQueryForGeminifunction. This gives React a rendering window immediately after the user message state update, before continuing with background operations like @-command processing and API call.Why it's needed
User messages appeared in the chat history with a noticeable delay (200-500ms) after the input box was cleared. The root cause was that
addItem()triggered a React state update viasetHistory(), but subsequent synchronous operations in the same async function blocked React from rendering until the nextawaitin the call chain (typicallysendMessageStream()orprocessGeminiStreamEvents()).React 19.2.4 (Ink 7.0.3) schedules renders via
MessageChannel.postMessage, which is a macrotask. A microtask yield (await Promise.resolve()) would NOT work because the event loop drains all microtasks before any macrotask fires. By inserting a macrotask yield (setImmediate), we yield control to the event loop's check phase (after I/O events where MessageChannel delivers), allowing React to render the user message immediately while background processing continues in parallel.Reviewer Test Plan
How to verify
npm run devto start the CLI in development mode@file.ts) to confirm file reading and context injection still work correctly/help) to confirm command processing is unaffectedEvidence (Before & After)
Before: Input box clears → 200-500ms blank period → message suddenly appears in history
After: Input box clears → message appears in history within ~50-100ms (feels instantaneous)
Tested on
Risk & Scope
setImmediate) to the prompt submission path. The performance impact is negligible (<1ms) and only affects the rendering timing, not the actual message processing logic.useGeminiStream.ts)Linked Issues
No existing issue filed for this.
中文说明
这个 PR 做了什么
在
prepareQueryForGemini函数的addItem()调用后添加了一个宏任务让权点(await new Promise(r => setImmediate(r)))。这使得 React 在用户消息状态更新后立即获得渲染窗口,然后再继续后台操作(如 @-command 处理和 vision bridge 转换)。为什么需要
用户消息在输入框清空后,要延迟 200-500ms 才出现在聊天记录中。根本原因是
addItem()通过setHistory()触发了 React state 更新,但同一 async 函数中的后续同步操作阻塞了 React 的渲染,直到调用链中的下一个await(通常是sendMessageStream()或processGeminiStreamEvents())。React 19.2.4 (Ink 7.0.3) 使用
MessageChannel.postMessage调度渲染,这是一个宏任务。微任务让权(await Promise.resolve())无效,因为事件循环会先清空所有微任务才执行宏任务。通过插入宏任务让权(setImmediate),我们让出执行权给事件循环的 check 阶段(在 I/O 事件之后,MessageChannel 在此投递),使 React 能立即渲染用户消息,而后台处理继续并行进行。验证方式
npm run dev启动 CLI 开发模式@file.ts)确认文件读取和上下文注入正常/help)确认命令处理不受影响修复前:输入框清空 → 200-500ms 空白期 → 消息突然出现
修复后:输入框清空 → 消息在 ~50-100ms 内出现(感觉是即时的)