Antd Select 下拉框在 Playwright 中点击选项后不关闭

问题描述

E2E 自动化测试中,Playwright 点击 antd Select 的 option 后,下拉框没有关闭(aria-expanded 仍为 true),但手动点击 option 可以正常关闭。

失败的测试:

css 复制代码
Error: expect(locator).toHaveAttribute(expected) failed
Locator:  locator('.ant-select').getByRole('combobox')
Expected: "false"
Received: "true"

测试代码(简化):

typescript 复制代码
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 代码,理解调用链

scss 复制代码
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 是否有同样问题。

复现项目结构:

csharp 复制代码
select-repro/
├── src/App.tsx           # 最小 antd Select 组件
├── select.spec.ts        # Playwright 测试
├── playwright.config.ts  # Playwright 配置(内置 Vite webServer)
└── vite.config.ts        # Vite 配置

App.tsx --- 最小复现组件:

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 --- 测试脚本:

typescript 复制代码
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:

typescript 复制代码
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:

typescript 复制代码
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)--- 有竞态条件

javascript 复制代码
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 时)

arduino 复制代码
时间线 ──────────────────────────────────────────────────────►

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)--- 已修复

javascript 复制代码
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 操作不再被全局锁阻止。

锁机制的设计背景

antd Select 支持 dropdownRender 属性,允许在下拉菜单中渲染自定义内容(如额外按钮、搜索框、滚动条等)。当用户点击这些自定义内容时,不应该关闭下拉框

tsx 复制代码
<Select
  dropdownRender={(menu) => (
    <>
      {menu}
      <Divider />
      <Button onClick={addItem}>+ 添加选项</Button>  {/* 点击这里不应关闭下拉 */}
    </>
  )}
/>

问题在于:点击 popup 内部的非 option 元素会触发 input 的 blur 事件(焦点从 input 转移到被点击的元素),而 blur 事件的默认行为是关闭下拉框。如果不加处理,用户点击「添加选项」按钮时下拉框会意外关闭。

相关 issue:

锁的工作原理

锁机制通过 onInternalMouseDown 事件处理器实现:

ini 复制代码
用户点击 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 回调:

javascript 复制代码
// 旧方案:全局锁(粗粒度,有竞态条件)
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 之间的等待时间。事件分发顺序为:

arduino 复制代码
mousedown → 等待 delay 毫秒 → mouseup → click

由于 lock 在 mousedown 后 3 个 macroTask 周期(约 3-4ms)即释放,20ms 的 delay 有充足余量让 lock 在 click 触发前过期,使 toggleOpen(false) 正常执行。

代码改动(POM 层):

typescript 复制代码
// 改前
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/selectuseOpen hook 的全局锁机制存在竞态条件,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 事件触发前释放
相关推荐
独断万古他化1 天前
Python+Pytest 搭建博客系统接口自动化测试框架(全用例执行+完整代码)
pytest·接口自动化·测试·allure·requests
在坚持一下我可没意见2 天前
软件测试入门复习笔记:BUG篇
笔记·bug·测试
智擎软件测评小祺2 天前
信创产品评估测试报告怎么办理?权威机构怎么选?
测试·信创·第三方检测·产品评估测试·信创产品评估测试
智擎软件测评小祺2 天前
信创产品评估测试都测什么?企业需要准备哪些材料?
测试·信创·信创产品评估·第三方软件测评·软件测评·产品评估测试
独断万古他化3 天前
Python YAML 模块使用教程:接口测试参数存储与配置
python·接口自动化·测试·配置·yaml
Apifox3 天前
测试数据终于不用到处复制了,Apifox 自动化测试新增「共用测试数据」
前端·后端·测试
Mintopia3 天前
Web性能测试流程全解析:从概念到落地的完整指南
前端·性能优化·测试
得物技术4 天前
搜索 C++ 引擎回归能力建设:从自测到工程化准出|得物技术
c++·后端·测试
阁老5 天前
pytest中集成allure打造更好的自动化测试报告
测试