问题描述
E2E 自动化测试中,Playwright 点击 antd Select 的 option 后,下拉框没有关闭(aria-expanded 仍为 true),但手动点击 option 可以正常关闭。
失败的测试:
Error: expect(locator).toHaveAttribute(expected) failed
Locator: locator('.ant-select').getByRole('combobox')
Expected: "false"
Received: "true"
测试代码(简化):
test('test select close', async ({ page }) => {
const select = page.locator('.ant-select');
const combobox = select.getByRole('combobox');
await select.click();
await expect(combobox).toHaveAttribute('aria-expanded', 'true');
const option = page.locator('.ant-select-item-option').filter({ hasText: '选项1' });
await option.click();
await expect(combobox).toHaveAttribute('aria-expanded', 'false'); // ← 失败
});
调查过程
第一步:阅读 POM 代码,理解调用链
select.select('选项1')
→ withDropdown(() => click option)
→ openDropdown() // 点击 Select 打开下拉
→ sleep(3000) // 等待下拉渲染
→ click option // 点击选项
→ // closeDropdown() // ← 被注释掉了!
POM 代码中 closeDropdown()(通过 Escape 键关闭)被注释掉了,代码注释写道:
rc-select 内部状态机与 Playwright click 事件的时序不兼容,
onOpenChange(false)不会被触发
初始假设:这是 rc-select 与 Playwright 的已知兼容性问题。
第二步:最小复现,验证假设
编写一个脱离产品代码的最小测试,验证纯 antd Select + Playwright 是否有同样问题。
复现项目结构:
select-repro/
├── src/App.tsx # 最小 antd Select 组件
├── select.spec.ts # Playwright 测试
├── playwright.config.ts # Playwright 配置(内置 Vite webServer)
└── vite.config.ts # Vite 配置
App.tsx — 最小复现组件:
import { useState } from 'react'
import { Select } from 'antd'
function App() {
const [value, setValue] = useState<string | undefined>(undefined)
return (
<div style={{ padding: 50 }}>
<Select
style={{ width: 200 }}
placeholder="请选择"
value={value}
onChange={(v) => setValue(v)}
options={[
{ label: '选项1', value: '1' },
{ label: '选项2', value: '2' },
{ label: '选项3', value: '3' },
]}
/>
</div>
)
}
export default App
select.spec.ts — 测试脚本:
import { test, expect } from '@playwright/test';
test('antd Select: click option should close dropdown', async ({ page }) => {
await page.goto('http://localhost:4567');
await page.waitForSelector('.ant-select');
const select = page.locator('.ant-select');
const combobox = select.getByRole('combobox');
// 打开下拉
await select.click();
await expect(combobox).toHaveAttribute('aria-expanded', 'true');
// 点击选项
const option = page.locator('.ant-select-item-option').filter({ hasText: '选项1' });
await option.click();
// 期望下拉关闭
await expect(combobox).toHaveAttribute('aria-expanded', 'false', { timeout: 3000 });
});
playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: '.',
testMatch: 'select.spec.ts',
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: 'npm run dev',
port: 4567,
reuseExistingServer: true,
},
});
vite.config.ts:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: { port: 4567 },
})
第三步:逐版本二分测试
| antd 版本 | @rc-component/select 版本 | 测试结果 |
|---|---|---|
| 6.0.1 | 1.2.7 | FAIL — 下拉框不关闭 |
| 6.1.0 | 1.3.6 | PASS — 下拉框正常关闭 |
| 6.3.2 | 最新 | PASS |
同时验证:在 antd 6.0.1 基础上单独升级 @rc-component/select 到 1.3.6 仍然失败,说明修复涉及 antd 本身对 BaseSelect 的调用方式变更。
第四步:对比源码,定位根因
保存 antd 6.0.1 和 6.1.0 的 @rc-component/select/es/hooks/useOpen.js,diff 对比。
根因分析
旧版本 useOpen.js(antd 6.0.1)— 有竞态条件
const toggleOpen = useEvent((nextOpen, config = {}) => {
const { ignoreNext = false } = config;
taskIdRef.current += 1;
const id = taskIdRef.current;
const nextOpenVal = typeof nextOpen === 'boolean' ? nextOpen : !mergedOpen;
if (nextOpenVal) {
if (!taskLockRef.current) {
triggerEvent(nextOpenVal);
if (ignoreNext) {
taskLockRef.current = ignoreNext; // ← 设置全局锁
macroTask(() => {
taskLockRef.current = false; // ← 3 个 macroTask 周期后释放
}, 3);
}
}
return;
}
// close 被延迟到 1 个 macroTask 后执行
macroTask(() => {
if (id === taskIdRef.current && !taskLockRef.current) { // ← 被锁阻止!
triggerEvent(nextOpenVal);
}
});
});
事件时序(点击 option 时)
时间线 ──────────────────────────────────────────────────────►
mousedown 事件(先触发):
└─ 冒泡到 popup wrapper div
└─ onInternalMouseDown 检测到 click 在 popup 内部
└─ 调用 triggerOpen(true, { ignoreNext: true })
└─ 设置 taskLockRef = true(锁定 3 个 macroTask 周期)
锁释放(但没有后续 close 触发)
↓
├──── macroTask 1 ────── macroTask 2 ────── macroTask 3 ──┤
click 事件(后触发):
└─ option onClick → onSelectValue(value) → toggleOpen(false)
└─ close 被延迟 1 个 macroTask
↓
├── macroTask 1 ──┤
↓
检查 taskLockRef → 仍为 true → close 被阻止!
手动点击为什么能工作: 真实用户的 mousedown 到 mouseup 之间天然存在 50-150ms 的间隔,lock(3 个 macroTask 周期 ≈ 3-4ms)在 click 事件触发前就已释放。Playwright 默认 click() 的 mousedown 和 mouseup 之间几乎无间隔(< 1ms),lock 还没释放 click 就已触发,close 被阻止。
通过 click({ delay }) 参数验证了这一判断:
| delay (ms) | 结果 |
|---|---|
| 0 | FAIL |
| 1 | FAIL |
| 2 | FAIL |
| 5 | PASS |
| 10 | PASS |
| 50 | PASS |
| 100 | PASS |
临界点在 2-5ms 之间,与 3 个 MessageChannel macroTask 周期的耗时一致。
新版本 useOpen.js(antd 6.1.0)— 已修复
const toggleOpen = useEvent((nextOpen, config = {}) => {
const { cancelFun } = config; // ← 改用 cancelFun 替代 ignoreNext
taskIdRef.current += 1;
const id = taskIdRef.current;
const nextOpenVal = typeof nextOpen === 'boolean' ? nextOpen : !mergedOpen;
function triggerUpdate() {
if (id === taskIdRef.current && !cancelFun?.()) { // ← 没有全局锁了
triggerEvent(nextOpenVal);
}
}
if (nextOpenVal) {
triggerUpdate(); // ← 直接调用
} else {
macroTask(() => {
triggerUpdate(); // ← 只检查 id 和 cancelFun,不检查锁
});
}
});
修复方式: 完全移除了 taskLockRef 全局锁机制,改用 cancelFun 回调按需取消。close 操作不再被全局锁阻止。
锁机制的设计背景
为什么需要锁?—— dropdownRender 场景
antd Select 支持 dropdownRender 属性,允许在下拉菜单中渲染自定义内容(如额外按钮、搜索框、滚动条等)。当用户点击这些自定义内容时,不应该关闭下拉框:
<Select
dropdownRender={(menu) => (
<>
{menu}
<Divider />
<Button onClick={addItem}>+ 添加选项</Button> {/* 点击这里不应关闭下拉 */}
</>
)}
/>
问题在于:点击 popup 内部的非 option 元素会触发 input 的 blur 事件(焦点从 input 转移到被点击的元素),而 blur 事件的默认行为是关闭下拉框。如果不加处理,用户点击「添加选项」按钮时下拉框会意外关闭。
相关 issue:
- ant-design#55928 — 点击
dropdownRender中的按钮导致下拉框关闭 - ant-design#56033 — Select 下拉框在交互中意外关闭
锁的工作原理
锁机制通过 onInternalMouseDown 事件处理器实现:
用户点击 popup 内部任意元素
└─ mousedown 冒泡到 popup wrapper div
└─ onInternalMouseDown 被触发
└─ 调用 triggerOpen(true, { ignoreNext: true })
└─ taskLockRef = true(阻止后续 close)
└─ 3 个 macroTask 周期后 taskLockRef = false(释放锁)
为什么是 3 个 macroTask 周期? 这是为 blur 事件的异步处理留出时间窗口。浏览器中 blur 事件的触发时机在 mousedown 之后、click 之前,且框架内部的 blur 处理逻辑是通过 macroTask 延迟执行的。3 个 macroTask 周期(约 3-4ms)足以覆盖 blur → onRootBlur → toggleOpen(false) 这条异步链路,确保 blur 触发的 close 操作被锁阻止。
锁的设计缺陷
锁的粒度太粗——它在 popup 级别的 mousedown 上设置,无法区分:
| 场景 | 期望行为 | 锁的实际效果 |
|---|---|---|
点击 dropdownRender 中的自定义按钮 | 不关闭下拉框 | ✅ 正确阻止了 close |
| 点击 option 选项 | 关闭下拉框 | ❌ 也阻止了 close |
点击 option 时,mousedown 同样冒泡到 popup wrapper,同样触发 onInternalMouseDown,同样设置锁。随后 option 的 click 事件触发 toggleOpen(false),但 close 操作被锁阻止。
手动点击之所以不受影响,是因为真实用户的 mousedown 到 click 之间天然存在 50-150ms 的间隔,锁(3-4ms)在 click 触发前就已释放。
新版本如何修复
PR #1183 完全移除了全局锁机制,改用 cancelFun 回调:
// 旧方案:全局锁(粗粒度,有竞态条件)
if (ignoreNext) {
taskLockRef.current = true;
macroTask(() => { taskLockRef.current = false; }, 3);
}
// 新方案:cancelFun 回调(细粒度,按需取消)
const toggleOpen = useEvent((nextOpen, config = {}) => {
const { cancelFun } = config;
// ...
function triggerUpdate() {
if (id === taskIdRef.current && !cancelFun?.()) {
triggerEvent(nextOpenVal);
}
}
});
cancelFun 是一个在执行时才求值的函数,调用方可以传入具体的取消条件(如检查 document.activeElement 是否仍在 popup 内),而不是依赖固定时间窗口的全局锁。这样:
- 点击自定义内容:cancelFun 检测到焦点仍在 popup 内 → 取消 close → ✅ 不关闭
- 点击 option:option 的 click 触发 close 时,cancelFun 不阻止 → ✅ 正常关闭
相关 PR / Issue
| PR / Issue | 仓库 | 说明 |
|---|---|---|
| #1166 | react-component/select | 引入 useOpen hook 和 macroTask 锁机制(初始实现) |
| #1175 | react-component/select | 修复 useOpen 的 open/close 竞态问题 |
| #1180 | react-component/select | 尝试修复锁导致的 option click 问题 |
| #1183 | react-component/select | 核心修复:移除 ignoreNext / taskLockRef 锁机制,改用 cancelFun(2025-12-05 合入,发布于 1.3.3) |
| #1184 | react-component/select | 配套修复:blur 关闭逻辑改用 cancelFun |
| #56054 | ant-design/ant-design | antd 升级 @rc-component/select(部分修复) |
| #55928 | ant-design/ant-design | Issue:点击 dropdownRender 自定义内容导致下拉框关闭 |
| #56033 | ant-design/ant-design | Issue:Select 下拉框在交互中意外关闭 |
解决方案
在无法升级 antd 版本的前提下,对多种 workaround 进行了验证:
方案对比
| 方案 | 做法 | 结果 | 评价 |
|---|---|---|---|
普通 click() | await option.click() | FAIL | 原始问题 |
| 拆分事件 + 延迟 | dispatchEvent('mousedown') → waitForTimeout(100) → dispatchEvent('click') | PASS | 手动构造事件,偏离用户真实操作 |
click({ delay }) | await option.click({ delay: 20 }) | PASS | 推荐:一行改动,Playwright 原生支持,最接近真实用户行为 |
| click + Escape | await option.click() → await input.press('Escape') | PASS | 用户不会按 Escape 关闭,偏离真实操作 |
| click + 点击空白 | await option.click() → await page.locator('body').click(...) | PASS | 用户不会点击空白关闭,偏离真实操作 |
选择原则: E2E 测试应尽可能模拟真实用户行为。除
click({ delay })外的其他方案都引入了用户不会执行的操作(手动构造事件、按 Escape、点击空白),脱离了测试的初衷。
推荐方案:click({ delay })
Playwright 的 click({ delay }) 参数控制 mousedown 和 mouseup 之间的等待时间。事件分发顺序为:
mousedown → 等待 delay 毫秒 → mouseup → click
由于 lock 在 mousedown 后 3 个 macroTask 周期(约 3-4ms)即释放,20ms 的 delay 有充足余量让 lock 在 click 触发前过期,使 toggleOpen(false) 正常执行。
代码改动(POM 层):
// 改前
await this.optionsLocator.filter({ hasText: ... }).click();
// 改后
await this.optionsLocator.filter({ hasText: ... }).click({ delay: 20 });
为什么这个方案最优
- 最接近真实用户行为:真实用户的 mousedown 和 mouseup 之间天然存在几十毫秒的间隔,
delay: 20模拟了这个间隔 - 使用 Playwright 原生 API:不依赖
dispatchEvent或额外操作,事件链完整(pointerdown → mousedown → delay → pointerup → mouseup → click) - 改动最小:只需在 click 调用处加一个参数
- 不引入副作用:不像 Escape 或点击空白处那样引入额外交互
结论
- 根因:antd 6.0.1 依赖的
@rc-component/select中useOpenhook 的全局锁机制存在竞态条件,popup 的 mousedown 设置的锁会阻止 option click 触发的 close - 影响范围:antd 6.0.x,Playwright 等自动化测试工具因 mousedown/mouseup 无间隔确定性触发此 bug;手动操作因天然 50-150ms 间隔不受影响
- 产品侧修复版本:antd >= 6.1.0(单独升级
@rc-component/select不够,需整体升级 antd) - 测试侧 workaround:使用
click({ delay: 20 })替代click(),在 mousedown 和 mouseup 之间插入延迟,让 lock 在 click 事件触发前释放