React 批处理陷阱:合成事件 dispatch 后同步读 DOM 只拿到旧值
写自动化验证脚本时,我习惯在一个 evaluate_script 调用里把"触发"和"断言"写在一起——触发一个键盘事件,紧接着读 aria-valuenow,一气呵成。结果脚本跑完,值没变,当场断定"这个键盘交互根本没跑",开始翻组件代码找问题。折腾半小时后才发现:组件没 bug,是我的验证方式骗了我。
现象
在 Playwright / chrome-devtools-mcp 的 evaluate_script 里,同步 dispatch 一个合成事件,然后立刻读 DOM 属性:
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
return el.getAttribute('aria-valuenow'); // ← 返回旧值,像是什么都没发生连读几次结果都不变,看上去 React 的 onKeyDown handler 根本没有被触发。
根因
React 18/19 引入了自动批处理(Automatic Batching):setState 不再同步刷新 DOM,而是把所有更新收集起来,在 handler 返回之后的微任务(microtask)里统一 flush。
dispatchEvent 是同步的——它调用 handler、handler 里调 setState、把更新入队,然后立刻返回。此时 DOM 还没有重渲染。紧随其后的 getAttribute 读到的是更新前的快照。
handler 其实跑了,state 其实变了,只是 DOM 的更新还在队列里等着。这个异步间隔极短(微任务级),但足以让同一个 evaluate_script 里的同步读取全部失效。
React 18/19 批处理将 DOM flush 推迟到微任务。dispatchEvent 同步返回时重渲染尚未发生,同步读 getAttribute / style / textContent 全部返回旧值。这不是组件 bug,是验证方式的时序错误。
正解
方案一:把"读"放到独立的第二次调用
两次工具调用之间,React 已经完成 flush:
// 第一次 evaluate_script — 只 dispatch,不读
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
// 第二次 evaluate_script(新的工具调用)— 此时 DOM 已更新
return el.getAttribute('aria-valuenow'); // ✅ 拿到新值方案二:用 defaultPrevented 在同步内证明 handler 跑了
如果组件 handler 里调了 e.preventDefault(),可以在同步内验证 handler 是否执行——defaultPrevented 不需要等 DOM flush:
const ev = new KeyboardEvent('keydown', {
key: 'ArrowRight',
bubbles: true,
cancelable: true,
});
el.dispatchEvent(ev);
return ev.defaultPrevented; // true = React onKeyDown 确实执行了方案三:信 a11y 快照,别信 DOM eval
take_snapshot / browser_snapshot 是独立调用,稍后取到时 React 早已 flush,a11y 树反映的是更新后的真实状态。与其同步读 DOM,不如事后取快照做断言。
关于真实 CDP 输入
用 press_key / click 等真实输入指令(而非 evaluate_script 内手动 dispatch),天然就是跨调用的——触发和读取本来就在两个步骤里,自动避开这个坑。只有在 evaluate_script 内把 dispatch + 读 DOM 合并到一次调用时才会中招。
把事件 dispatch 放第一次 evaluate_script,把断言读取放第二次独立调用。或者直接用 take_snapshot 取 a11y 树——两种方式都能拿到 React flush 后的真实状态。
一句话外卖
React 18/19 的批处理让"dispatch + 同步读"在任何单次 evaluate 里都只是幻觉;验证受控交互,必须把触发和断言拆成两次独立调用。