Skip to content

fix(cli): yield to React after addItem to reduce input lag - #6059

Merged
wenshao merged 4 commits into
QwenLM:mainfrom
Alex-ai-future:fix/input_lag
Jul 1, 2026
Merged

fix(cli): yield to React after addItem to reduce input lag#6059
wenshao merged 4 commits into
QwenLM:mainfrom
Alex-ai-future:fix/input_lag

Conversation

@Alex-ai-future

@Alex-ai-future Alex-ai-future commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Adds a macrotask yield point (await new Promise(r => setImmediate(r))) after addItem() in the prepareQueryForGemini function. 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 via setHistory(), but subsequent synchronous operations in the same async function blocked React from rendering until the next await in the call chain (typically sendMessageStream() or processGeminiStreamEvents()).

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

  1. Run npm run dev to start the CLI in development mode
  2. Type any message in the input box and press Enter
  3. Observe that the input box clears and the message appears in the chat history nearly simultaneously (no visible lag gap)
  4. Test with @-commands (e.g., @file.ts) to confirm file reading and context injection still work correctly
  5. Test with slash commands (e.g., /help) to confirm command processing is unaffected

Evidence (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

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Risk & Scope

  • Main risk or tradeoff: Adds one extra macrotask cycle (setImmediate) to the prompt submission path. The performance impact is negligible (<1ms) and only affects the rendering timing, not the actual message processing logic.
  • Not validated / out of scope: Daemon/web-shell mode (this fix targets the TUI code path in useGeminiStream.ts)
  • Breaking changes / migration notes: None

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 能立即渲染用户消息,而后台处理继续并行进行。

验证方式

  1. 运行 npm run dev 启动 CLI 开发模式
  2. 在输入框中输入任意消息并按回车
  3. 观察输入框清空和消息出现在聊天记录中几乎同时发生(无明显 lag)
  4. 测试 @-command(如 @file.ts)确认文件读取和上下文注入正常
  5. 测试 slash command(如 /help)确认命令处理不受影响

修复前:输入框清空 → 200-500ms 空白期 → 消息突然出现

修复后:输入框清空 → 消息在 ~50-100ms 内出现(感觉是即时的)

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
doudouOUC previously approved these changes Jun 30, 2026

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 applyVisionBridgeIfNeeded already 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));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
DragonnZhang
DragonnZhang previously approved these changes Jun 30, 2026

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. The technical reasoning is sound:

  • React 19's scheduler uses MessageChannel.postMessage (a macrotask) to schedule renders. In Node.js, MessagePort messages are delivered via uv_async_send in the poll phase of the event loop.
  • setImmediate fires 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; setImmediate is 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).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

Suggested change
// 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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));
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
}
await new Promise((r) => setImmediate(r));
if (turnCancelledRef.current) {
return { queryToSend: null, shouldProceed: false };
}

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

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 await line with a well-written comment explaining why setImmediate over Promise.resolve() or setTimeout(0). The scope is exactly what the goal needs: no abstractions, no config flags, no "future-proofing." The comment itself is justified because the microtask-vs-macrotask distinction is genuinely non-obvious and would trip up future maintainers. One small question: the comment says "Only needed for non-Cron submissions since Cron skips addItem()" — this is correct (the if (submitType !== SendMessageType.Cron) guard above already ensures we only reach this code for non-Cron), but worth confirming the yield is inside that if block, which it is. ✓

Moving on to code review and testing. 🔍

中文说明

感谢贡献!

模板完整 ✓ — 所有必需部分齐全,中英双语描述完整。

方向:这解决了一个真实的 UX 问题——按下回车到看到消息出现在聊天记录中的可感知延迟确实令人不适。React 19 + Ink 7 基于宏任务的渲染调度使这成为一个合理的关注点,修复完全在 qwen-code 提供响应式 CLI 体验的核心使命范围内。CHANGELOG 中没有这个特定问题的参考,但输入响应性显然在范围内。

方案:改动非常精简——一行 await 加上一个写得很好的注释解释了为什么选择 setImmediate 而不是 Promise.resolve()setTimeout(0)。范围恰好是目标所需的:没有抽象,没有配置标志,没有"面向未来"。注释本身是合理的,因为微任务与宏任务的区别确实不直观,未来的维护者可能会踩坑。一个小问题:注释说"仅对非 Cron 提交需要,因为 Cron 跳过 addItem()"——这是正确的(上面的 if (submitType !== SendMessageType.Cron) 守卫已经确保我们只在非 Cron 时到达这段代码),但值得确认 yield 确实在该 if 块内,确认无误。✓

进入代码审查和测试阶段。🔍

Qwen Code · qwen3.7-max

@wenshao

wenshao commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

CI failure root cause: the new setImmediate yield deadlocks 9 existing fake-timer tests

The red check is Test (ubuntu-latest, Node 22.x) → step "Run tests and generate reports". This is a deterministic test regression, not runner flakiness: the same 9 tests failed on two independent re-runs (attempt 1 + attempt 2) of head 787465d, while every lint / prettier / schema / bundle step passes.

What fails

packages/cli/src/ui/hooks/useGeminiStream.test.tsx — 9 tests, all in the Cancellation (6) and Thought Reset (3) suites:

  • expected "spy" to be called 1 times, but got 0 timesmockSendMessageStream is never invoked
  • expected [] to deeply equal [ … ]pendingHistoryItems never populates
  • expected undefined to be trueturnProducedMeaningfulContent never set

Why

The single line this PR adds is the entire cause:

// packages/cli/src/ui/hooks/useGeminiStream.ts:1050
await new Promise((r) => setImmediate(r));

All 9 failing tests run under vi.useFakeTimers(). Vitest's fake clock replaces setImmediate, so it only fires when the test advances timers (vi.advanceTimersByTime* / runAllTimers*). But these tests drive the hook by draining microtasks only:

act(() => { void result.current.submitQuery('test query'); });
await act(async () => {
  await Promise.resolve();
  await Promise.resolve();
});
expect(mockSendMessageStream).toHaveBeenCalledTimes(1); // ← now 0

Because the new yield is a macrotask held by the fake clock, execution stalls at the yield — the continuation (@-command handling → sendMessageStream() → stream processing) never runs.

Ironically, the PR description states the exact reason it breaks: it deliberately uses a macrotask because "the event loop drains all microtasks before any macrotask fires." The tests advance the hook with precisely that microtask drain (await Promise.resolve()), so they can never step past the new macrotask boundary.

Runtime behavior is fine — real setImmediate fires normally on the next check phase. Only the fake-timer tests break.

Evidence — local A/B on the PR head

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-delay setImmediate without 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 blocking await that gates sendMessageStream, 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 timesmockSendMessageStream 从未被调用
  • expected [] to deeply equal [ … ]pendingHistoryItems 从未填充
  • expected undefined to be trueturnProducedMeaningfulContent 从未被置位

为什么

本 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 wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@DragonnZhang

Copy link
Copy Markdown
Collaborator

🧪 E2E Tmux Test Report — PR #6059

Test Environment

  • CLI: npm run dev (tsx mode, running directly from PR branch fix/input_lag)
  • Branch: fix/input_lag (commits: d5d9a787d, 787465d75, 6389b393f)
  • Mode: interactive (tmux session pr6059)
  • Sandbox: disabled (QWEN_SANDBOX=false)
  • Model: qwen3.7-max (ModelStudio Standard)
  • OS: Ubuntu (Linux x86_64), Node.js v22.22.2

Test Results

1. CLI Startup — ✅ PASS

  • CLI started normally and rendered the TUI prompt
  • Header displayed: >_ Qwen Code (vdev) with model qwen3.7-max
  • Input prompt rendered: Type your message or @path/to/file
  • YOLO mode active, context at 100%
  • One non-critical dev warning: "React DevTools server is not running" (expected in dev mode)

2. First Query: what is 2+2? reply with just the number — ✅ PASS

  • User message appeared in chat history immediately: > what is 2+2? reply with just the number
  • Model thought for 1s, responded with: 4 (correct)
  • No errors, no lag artifacts visible in TUI

3. Second Query: what is 3+3? reply with just the number — ✅ PASS

  • User message appeared in chat history
  • Model thought for 0s, responded with: 6 (correct)
  • Session continued smoothly, no crashes or rendering issues

4. Third Query: what is 4+4? reply with just the number — ✅ PASS

  • User message appeared in chat history
  • Model thought for 0s, responded with: 8 (correct)
  • Feedback prompt appeared normally after response
  • No errors across 3 consecutive turns

5. CLI Exit — ✅ PASS

  • /exit command processed correctly
  • Process exited cleanly, returned to shell prompt
  • No orphan processes or error output

Verdict

All 5 tests passed. The CLI is responsive and stable with the macrotask yield (setImmediate) after addItem(). The PR introduces no regressions:

  • User messages render correctly in chat history
  • Model responses are accurate and timely
  • Multi-turn conversation works without issues
  • Exit handling is clean

The await new Promise((r) => setImmediate(r)) yield point does not cause any observable side effects in the interactive TUI session.

中文翻译 / Chinese Translation

🧪 E2E Tmux 测试报告 — PR #6059

测试环境

  • CLI: npm run dev(tsx 模式,直接从 PR 分支 fix/input_lag 运行)
  • 分支: fix/input_lag(提交: d5d9a787d, 787465d75, 6389b393f
  • 模式: 交互式(tmux 会话 pr6059
  • 沙箱: 已禁用(QWEN_SANDBOX=false
  • 模型: qwen3.7-max(ModelStudio Standard)
  • 系统: Ubuntu (Linux x86_64),Node.js v22.22.2

测试结果

1. CLI 启动 — ✅ 通过

  • CLI 正常启动并渲染 TUI 提示
  • 显示头部: >_ Qwen Code (vdev),模型 qwen3.7-max
  • 输入提示渲染: Type your message or @path/to/file
  • YOLO 模式激活,上下文 100%

2. 第一个查询: what is 2+2? — ✅ 通过

  • 用户消息立即出现在聊天记录中
  • 模型思考 1 秒,回答: 4(正确)

3. 第二个查询: what is 3+3? — ✅ 通过

  • 用户消息出现在聊天记录中
  • 模型思考 0 秒,回答: 6(正确)

4. 第三个查询: what is 4+4? — ✅ 通过

  • 用户消息出现在聊天记录中
  • 模型思考 0 秒,回答: 8(正确)
  • 反馈提示正常出现

5. CLI 退出 — ✅ 通过

  • /exit 命令正常处理
  • 进程干净退出,无残留进程

结论

全部 5 项测试通过。 CLI 在 addItem() 后添加宏任务让权(setImmediate)的情况下表现稳定且响应迅速。PR 未引入任何回归问题。await new Promise((r) => setImmediate(r)) 让权点在交互式 TUI 会话中未产生任何可观察的副作用。

@wenshao

wenshao commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

The production change is a single await new Promise((r) => setImmediate(r)) after addItem(), wrapped in a 12-line comment explaining why setImmediate over Promise.resolve() or setTimeout(0). The comment is justified — the microtask-vs-macrotask distinction is genuinely non-obvious and would trip up a future maintainer who might be tempted to "simplify" it.

The test changes are mechanical and correct: 9 instances of await vi.advanceTimersByTimeAsync(0) inserted right after the existing microtask drain. This is the right fix — it fires the 0-delay setImmediate without advancing the throttle clock, so all throttle-dependent assertions remain valid.

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.

Testing

Unit Tests

All 148 tests in useGeminiStream.test.tsx pass — 0 failures.

 ✓ src/ui/hooks/useGeminiStream.test.tsx (148 tests) 1348ms
 Test Files  1 passed (1)
      Tests  148 passed (148)

CI

All checks green — 4 successful, 15 skipped, 0 failing.

Tmux Interactive Test (this rerun)

Test environment: Ubuntu Linux x86_64, Node.js v22.22.2, npm run dev (tsx mode), model qwen3.7-max, sandbox disabled.

  ┌──────────────────────────────────────────────────────────────────────────┐
  │ >_ Qwen Code (vdev)                                                      │
  │                                                                          │
  │ API Key | qwen3.7-max (/model to change)                                 │
  │ ~/actions-runner-2/_work/qwen-code/qwen-code                             │
  └──────────────────────────────────────────────────────────────────────────┘
  Tips: You can run any shell commands from Qwen Code using ! (e.g. !ls).

Query 1: what is 5+5? reply with just the number

  > what is 5+5? reply with just the number

  ∴ Thought for 1s (alt+t to expand)

  ✦ 10

Query 2: what is 7+7? reply with just the number

  > what is 7+7? reply with just the number

  ∴ Thought for 0s (alt+t to expand)

  ✦ 14

Both queries rendered correctly — user message appeared in history, model responded accurately, no crashes or rendering artifacts. Multi-turn session stable across consecutive turns.

中文说明

代码审查

生产代码改动是 addItem() 之后的一行 await new Promise((r) => setImmediate(r)),附带 12 行注释解释为什么选择 setImmediate 而不是 Promise.resolve()setTimeout(0)。注释是合理的——微任务与宏任务的区别确实不直观,未来的维护者可能会试图"简化"它而踩坑。

测试改动是机械性的且正确的:在既有的微任务排空之后插入 9 处 await vi.advanceTimersByTimeAsync(0)。这是正确的修复——它触发 0 延迟的 setImmediate 而不推进节流时钟,因此所有依赖节流的断言仍然有效。

无正确性问题,无安全隐患,无 AGENTS.md 违规。diff 专注——每一行都服务于既定目标,无顺手重构或范围蔓延。

测试

单元测试

useGeminiStream.test.tsx 中全部 148 个测试通过——0 失败。

CI

全部检查绿色——4 成功,15 跳过,0 失败。

Tmux 交互式测试(本次重跑)

测试环境: Ubuntu Linux x86_64,Node.js v22.22.2,npm run dev(tsx 模式),模型 qwen3.7-max,沙箱已禁用。

CLI 正常启动并渲染 TUI。两条查询均正确渲染——用户消息出现在历史记录中,模型准确回复,无崩溃或渲染异常。多轮会话在连续对话中保持稳定。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Verdict

This is a clean, well-understood fix. The author clearly knows the event loop — the PR description reads like a textbook explanation of why setImmediate is the right primitive here, and the code matches the prose exactly.

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: setImmediate yield after addItem() + vi.advanceTimersByTimeAsync(0) in tests. The PR matches this exactly.

The sub-100ms input lag improvement can't be meaningfully measured through tmux capture-pane, but the interactive test confirms no regressions: messages render, model responds, multi-turn works, exit is clean. Unit tests all green (148/148), CI all green.

Approving. ✅

中文说明

结论

这是一个干净、理解透彻的修复。作者显然了解事件循环——PR 描述读起来像是关于为什么 setImmediate 是正确原语的教科书解释,代码与描述完全一致。

从维护者的角度看:一行生产代码,一段物有所值的注释,以及一个机械性的测试修复——这是对上一轮 triage 标记的 CI 故障的最小可行响应。作者在数小时内就推送了修复。diff 专注——没有重构,没有"顺便改一下"的变更,没有面向未来的抽象。

我在阅读 diff 之前的独立方案:addItem() 之后加 setImmediate 让权 + 测试中加 vi.advanceTimersByTimeAsync(0)。PR 与之完全一致。

低于 100ms 的输入延迟改善无法通过 tmux capture-pane 有意义地测量,但交互式测试确认无回归:消息正常渲染,模型正常回复,多轮对话正常,退出干净。单元测试全绿(148/148),CI 全绿。

批准。✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

await act(async () => {
await Promise.resolve();
await Promise.resolve();
// Flush the macrotask yield (setImmediate) added after addItem()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

@wenshao

wenshao commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

🧪 Local real-binary verification (maintainer)

I built the real CLI at the PR head (d5d9a787d) and drove it in tmux against a fake OpenAI server, plus ran the unit suites, mutation checks, and faithful react/ink event-loop reproductions. Environment: macOS (darwin), Node v22.22.2, ink@7.0.3, react@19.2.4, scheduler@0.27.0 — matching the versions cited in the code comment.

TL;DR

  • No regressions. All touched/related tests pass; the user message and model reply render correctly in the real binary; the Cron path is untouched. Safe to merge in the "does no harm" sense.
  • ⚠️ I could not reproduce the input-lag improvement. In a real A/B (6 runs each, fix vs origin/main), the user message appears at the same time with and without the change.
  • ⚠️ The code comment's mechanism is inaccurate for this runtime, and a faithful Ink/React reproduction shows the single setImmediate yield behaves identically to baseline (it does not render-before-continuation).

1. No regression — all green

Check Result
useGeminiStream.test.tsx @ PR head 148/148 pass
AppContainer.test.tsx (95) + BackgroundTasksDialog.test.tsx (36) 131/131 pass (no other suite broke)
Mutation: PR source + pre-PR tests exactly 9 fail → the 9 test flushes are load-bearing & minimal
Mutation: no-fix source + PR tests 148 pass → the added advanceTimersByTimeAsync(0) are backward-compatible no-ops
Real binary (tmux + fake OpenAI, 12 runs) message renders, reply renders, no crash/hang in both builds
Cron path yield is inside if (submitType !== SendMessageType.Cron) → Cron adds no latency ✔

2. Input-lag benefit not reproducible (real A/B, n=6 each)

Real qwen binary in tmux; probe stamps every stdout frame and the MARK_ENTER instant; the fake server stamps each request arrival (shared epoch clock). Measured Enter → user-message on screen:

A = WITH fix (setImmediate):   31.5, 32.3, 58.3, 57.8, 55.0, 56.3 ms  → mean 48.5, median 56.3
B = baseline (origin/main):    25.1, 50.0, 49.9, 56.2, 50.9, 57.8 ms  → mean 48.3, median 50.9

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 setImmediate changes nothing observable for a normal turn.

3. Why — the comment's mechanism doesn't match this runtime

The comment states "React 19.2.4 (Ink 7.0.3) schedules renders via MessageChannel.postMessage (a macrotask), so a microtask yield does NOT give React a chance to render." Verified against the shipped bundle (dist/chunks/…) and Ink source:

  • Ink configures the reconciler with supportsMicrotasks: true / scheduleMicrotask: queueMicrotask → React schedules its render work via a microtask (queueMicrotask), not MessageChannel.
  • React's scheduler macrotask fallback in Node is localSetImmediate(performWorkUntilDeadline) (setImmediate) — the MessageChannel branch is a browser-only else if that never runs here.

A faithful real-ink+react reproduction (state update triggered from a macrotask, matching the stdin-keypress→submitQuery flow), deterministic across 3 runs:

yield after addItem() user message renders before the continuation?
none (origin/main baseline)
microtask (await Promise.resolve())
setImmediate ×1 — THE PR CHANGE ❌ (same as baseline)
setImmediate ×2
setTimeout(0)
microtask + setImmediate

addItem's setState schedules React's render via queueMicrotask → setImmediate, which lands one hop after the PR's single setImmediate (enqueued during the original macrotask). So the continuation still runs first. This mechanistically explains the null A/B result in §2.


Recommendation

This 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 queueMicrotask / setImmediate, not MessageChannel).

Before merging, consider either:

  1. sharing a concrete before/after repro (scenario + measurement) that demonstrates the lag it removes — happy to re-verify against it; and/or
  2. correcting the comment; and if a real "render-first" guarantee is intended, a 2-hop yield (setImmediate×2, setTimeout(0), or microtask-then-setImmediate) is what actually achieves it here.

Verification harness (fake OpenAI server, in-process stdout/fetch/connect probe, tmux A/B driver, real-Ink ordering tests) available on request.

🀄 中文版(完整对应)

🧪 本地真实二进制验证(维护者)

我在 PR head(d5d9a787d)上构建了真实 CLI,用 tmux 驱动真实 TUI + 伪 OpenAI 服务器,并跑了单元测试、变异测试,以及忠实复现 react/ink 事件循环行为的实验。环境:macOS(darwin),Node v22.22.2ink@7.0.3react@19.2.4scheduler@0.27.0 —— 与代码注释里引用的版本一致。

结论速览

  • 无回归。所有相关测试通过;真实二进制里用户消息与模型回复都能正确渲染;Cron 路径不受影响。"不造成破坏"意义上可以合并。
  • ⚠️ 没能复现输入延迟的改善。真实 A/B 对比(各 6 次,fix vs origin/main)显示:加不加这行改动,用户消息出现在屏幕上的时刻一样
  • ⚠️ 注释里的机制描述对当前运行时不准确,且忠实的 Ink/React 复现表明这个单次 setImmediate yield 的行为与 baseline 完全相同(并没有做到"先渲染再继续")。

1. 无回归 —— 全绿

检查 结果
useGeminiStream.test.tsx(PR head) 148/148 通过
AppContainer.test.tsx(95) + BackgroundTasksDialog.test.tsx(36) 131/131 通过(没有其它套件被打断)
变异:PR 源码 + 改前测试 恰好 9 个失败 → 那 9 处测试 flush 是承重且最小的
变异:去掉 fix 的源码 + PR 测试 148 通过 → 新增的 advanceTimersByTimeAsync(0) 是向后兼容的空操作
真实二进制(tmux + 伪 OpenAI,共 12 次) 两个构建下消息都渲染、回复都渲染、无崩溃/卡死
Cron 路径 yield 在 if (submitType !== SendMessageType.Cron) 内 → Cron 不增加延迟 ✔

2. 输入延迟收益无法复现(真实 A/B,各 6 次)

真实 qwen 二进制跑在 tmux 里;探针给每个 stdout 帧和 MARK_ENTER(按下回车的瞬间)打时间戳,伪服务器给每次请求到达打时间戳(共享 epoch 时钟)。测量 回车 → 用户消息出现在屏幕上

A = 带 fix (setImmediate):   31.5, 32.3, 58.3, 57.8, 55.0, 56.3 ms  → 均值 48.5,中位 56.3
B = baseline (origin/main): 25.1, 50.0, 49.9, 56.2, 50.9, 57.8 ms  → 均值 48.3,中位 50.9

两组分布统计上无法区分、完全重叠(baseline 在它最好的一次里甚至略快)。模型请求在两个构建里都是 ~200ms 才离开进程,也就是说消息在请求发出前 ~150ms 就已经在屏幕上了 —— 加不加 fix 都一样。说明流水线本身就已经足够早地让出事件循环;对普通一轮对话来说,这个 setImmediate 没有任何可观测的改变。

3. 原因 —— 注释的机制与当前运行时不符

注释说:"React 19.2.4 (Ink 7.0.3) 通过 MessageChannel.postMessage(宏任务)调度渲染,所以微任务 yield 不会让 React 有机会渲染。" 我对照发布产物 bundledist/chunks/…)和 Ink 源码核验:

  • Ink 给 reconciler 配置了 supportsMicrotasks: true / scheduleMicrotask: queueMicrotask → React 通过**微任务(queueMicrotask)**调度渲染,而不是 MessageChannel。
  • React scheduler 在 Node 下的宏任务回退是 localSetImmediate(performWorkUntilDeadline)setImmediate —— MessageChannel 分支是只在浏览器走的 else if,这里根本不会执行。

忠实的真实 ink+react 复现(状态更新从宏任务里触发,对应 stdin 按键→submitQuery 的真实流程),3 次运行结果确定一致:

addItem() 之后的 yield 用户消息是否先于后续逻辑渲染?
无(origin/main baseline)
微任务(await Promise.resolve()
setImmediate ×1 —— 本 PR 的改动 ❌(与 baseline 相同)
setImmediate ×2
setTimeout(0)
微任务 + setImmediate

addItemsetState 通过 queueMicrotask → setImmediate 调度 React 渲染,它比本 PR 的单次 setImmediate(在原宏任务里入队)晚一跳。所以后续逻辑仍然先跑。这正好从机制上解释了 §2 里 A/B 无差异的结果。


建议

这是个低风险、无害的单行改动(每次非 Cron 提交多一个事件循环 tick;测试全绿;无回归)。但就目前写法而言,在 Ink 7.0.3 / React 19.2.4 运行时下它对其宣称的目的基本是空操作,而且解释性注释在技术上不准确(实际是 queueMicrotask / setImmediate,不是 MessageChannel)。

合并前建议:

  1. 提供一个具体的前后对比复现(场景 + 测量)来证明它消除的延迟 —— 我很乐意据此再验证;和/或
  2. 修正注释;如果确实想要"先渲染"的保证,这里真正有效的是两跳 yield(setImmediate×2、setTimeout(0),或微任务后接 setImmediate)。

验证脚手架(伪 OpenAI 服务器、进程内 stdout/fetch/connect 探针、tmux A/B 驱动、real-Ink 排序测试)可按需提供。

@wenshao

wenshao commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Local verification report — PR #6059

Built the PR head (d5d9a787d) in an isolated worktree and verified it with the real test suite plus real-runtime React/Ink probes. Verdict: the fix is correct and load-bearing; safe to merge. One non-blocking accuracy issue in the code comment is noted below.

Environment: macOS, Node v22.22.2. The PR's lockfile pins react@19.2.4 / ink@7.0.3 (matching the comment). Probes ran against the repo's installed react@19.2.4 (+ react@19.2.7 for the hand-built legacy reconciler, the version its react-reconciler@0.33.0 pairs with) and scheduler@0.27.0 — patch-level differences that don't change the scheduler's primitive selection.

What the PR does

  • Source (useGeminiStream.ts): after addItem() inserts the user message, await new Promise((r) => setImmediate(r)) yields one macrotask so Ink/React paints the > line before @-command processing and the API call run — reducing perceived input lag. Correctly scoped to non-Cron submissions (Cron skips addItem).
  • Test (useGeminiStream.test.tsx): adds await vi.advanceTimersByTimeAsync(0) in 9 fake-timer tests to flush that new setImmediate.

Results

Check Result
Full useGeminiStream.test.tsx at head 148/148 pass
tsc --noEmit (cli, against the PR's own freshly-built core) 0 errors
prettier --check on both changed files ✅ clean

Mutation M1 — is the test change necessary? Keep the source setImmediate, revert the 9 test flushes → exactly those 9 tests fail (sendMessageStream is never reached because fake timers never advance the new macrotask). ✅ The test change is load-bearing, not cosmetic.

Mutation M2 — coverage note. Remove the source setImmediate, keep the 9 flushes → 148/148 still pass. So the added flushes are a compatibility harness that keeps the fake-timer tests working; they do not assert the render-before-API ordering. That behavior is inherently untestable under act() + fake timers (see below), so this is expected, not a defect — just don't read the green suite as a guard on the UX fix itself.

Behavioral claim — confirmed 3 independent ways (real runtime, no act). The comment's stated mechanism is partly inaccurate, but the outcome is real and robust:

  1. Raw scheduler package (the one React/Ink use): instrumentation shows React schedules its flush via setImmediate (1 call), not MessageChannel (0 posts). After scheduling a flush: a microtask yield (await Promise.resolve()) leaves it un-flushed; a setImmediate yield leaves it flushed.
  2. Real setState, Concurrent root (react-dom createRoot): microtask → {reRenderedBeforeContinuation: false, painted: false} vs setImmediate → {true, true}.
  3. Real setState, Legacy root (Ink's actual mode + react@19.2.7, hand-built reconciler): identical — microtask → {false, false} vs setImmediate → {true, true}.

So the user message is painted before the continuation only with the setImmediate yield — exactly the fix's intent — under both React root modes.

Non-blocking: code-comment accuracy

The comment claims "React 19.2.4 (Ink 7.0.3) schedules renders via MessageChannel.postMessage". In Node that is false: scheduler prefers setImmediate whenever it is defined (scheduler.production.js: localSetImmediate = typeof setImmediate !== 'undefined' ? setImmediate : null, chosen ahead of MessageChannel). The fix still works — and is robust either way, since in Node MessageChannel delivery also precedes setImmediate's check phase — but the reason in the comment is not what actually happens on this runtime. Consider rewording to reference setImmediate/the scheduler's macrotask rather than MessageChannel. (The version numbers in the comment — react@19.2.4, ink@7.0.3 — match the lockfile and are correct.)

Reverse audit — no functional issues

  • setImmediate always fires → the await can't hang.
  • abortSignal is still honored downstream (handleAtCommand, applyVisionBridgeIfNeeded, sendMessageStream) if the user cancels during the 1-tick yield.
  • Node-only hook (interactive TUI) → setImmediate always available; non-interactive/daemon paths don't use this hook.
  • Cost is one macrotask hop (sub-ms) per interactive submit; negligible vs the network-bound API call. No leak (the Immediate self-clears on fire).

Recommendation

Approve / merge. Optionally tweak the code comment to say setImmediate/scheduler-macrotask instead of MessageChannel for accuracy.

中文版(点击展开)

PR #6059 本地验证报告

在隔离 worktree 里构建了 PR head(d5d9a787d),用真实测试套件 + 真实运行时的 React/Ink 探针做了验证。结论:修复正确且真实承重,可以合并。 代码注释里有一处不影响功能的描述不准确,见下。

环境: macOS,Node v22.22.2。PR 的 lockfile 锁定 react@19.2.4 / ink@7.0.3(与注释一致)。探针跑在仓库已安装的 react@19.2.4(外加 react@19.2.7 用于手写 legacy reconciler,因为它的 react-reconciler@0.33.0 配这个版本)和 scheduler@0.27.0 上——这些是补丁级差异,不影响调度器对原语的选择。

这个 PR 做了什么

  • 源码useGeminiStream.ts):在 addItem() 插入用户消息后,用 await new Promise((r) => setImmediate(r)) 让出一个宏任务,使 Ink/React 先把 > 行渲染出来,去做 @ 命令处理和 API 调用,从而降低输入卡顿感。作用域正确地限定在非 Cron 提交(Cron 不走 addItem)。
  • 测试useGeminiStream.test.tsx):在 9 个假定时器测试里加 await vi.advanceTimersByTimeAsync(0) 来冲刷这个新的 setImmediate

结果

检查 结果
head 上完整跑 useGeminiStream.test.tsx 148/148 通过
tsc --noEmit(cli,针对 PR 自己新构建的 core) 0 错误
两个改动文件 prettier --check ✅ 干净

变异 M1 — 测试改动是否必要? 保留源码 setImmediate、还原那 9 处 flush → 恰好这 9 个测试失败(假定时器不推进新宏任务,sendMessageStream 永远到不了)。✅ 测试改动是承重的,不是可有可无。

变异 M2 — 覆盖率说明。 去掉源码 setImmediate、保留 9 处 flush → 148/148 仍然通过。也就是说这些 flush 是一个"兼容性外壳",让假定时器测试在加了 yield 后还能跑;它们并不断言"先渲染再调用 API"这个顺序。这个行为在 act() + 假定时器下本来就测不出(见下),所以这是符合预期、不是缺陷——只是别把绿色套件当成对 UX 修复本身的保护。

行为主张 — 3 种独立方式确认(真实运行时,无 act)。 注释里给的机制部分不准确,但结果是真实且稳健的:

  1. 原始 scheduler(React/Ink 实际用的那个):插桩显示 React 通过 setImmediate(1 次),而不是 MessageChannel(0 次) 调度渲染。调度一次 flush 后:微任务让出(await Promise.resolve())时它还没冲刷setImmediate 让出时它已冲刷
  2. 真实 setState,Concurrent rootreact-dom createRoot):微任务 → {先于后续渲染: false, 已绘制: false} vs setImmediate → {true, true}
  3. 真实 setState,Legacy root(Ink 的实际模式 + react@19.2.7,手写 reconciler):完全一致——微任务 → {false, false} vs setImmediate → {true, true}

所以只有在 setImmediate 让出时,用户消息才会在后续逻辑之前被绘制——正是本修复的意图——两种 React root 模式下都成立。

不阻塞:代码注释准确性

注释称 "React 19.2.4 (Ink 7.0.3) schedules renders via MessageChannel.postMessage"。在 Node 下这是错的:只要 setImmediate 存在,scheduler 就优先用它(scheduler.production.jslocalSetImmediate = typeof setImmediate !== 'undefined' ? setImmediate : null,排在 MessageChannel 之前)。修复仍然有效——而且两种原语下都稳健,因为在 Node 里 MessageChannel 投递也先于 setImmediate 的 check 阶段——但注释给的原因并不是这个运行时上真实发生的。建议把注释改成引用 setImmediate/调度器宏任务,而不是 MessageChannel。(注释里的版本号——react@19.2.4ink@7.0.3——与 lockfile 一致,是对的。)

反向审计 — 无功能问题

  • setImmediate 一定会触发 → await 不可能挂起。
  • 用户在这 1 个 tick 的 yield 期间取消时,abortSignal 在下游(handleAtCommandapplyVisionBridgeIfNeededsendMessageStream)仍被尊重。
  • 仅 Node 的交互式 TUI hook → setImmediate 一定可用;非交互/daemon 路径不走这个 hook。
  • 每次交互提交多一个宏任务跳转(亚毫秒级),相对网络级 API 调用可忽略。无泄漏(Immediate 触发即自清)。

建议

批准 / 合并。 可选:把代码注释里的 MessageChannel 改成 setImmediate/调度器宏任务,以求准确。

@wenshao
wenshao added this pull request to the merge queue Jul 1, 2026
Merged via the queue into QwenLM:main with commit e0e90cc Jul 1, 2026
37 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants