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

18 阅读8分钟

问题描述

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.11.2.7FAIL — 下拉框不关闭
6.1.01.3.6PASS — 下拉框正常关闭
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)结果
0FAIL
1FAIL
2FAIL
5PASS
10PASS
50PASS
100PASS

临界点在 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:

锁的工作原理

锁机制通过 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仓库说明
#1166react-component/select引入 useOpen hook 和 macroTask 锁机制(初始实现)
#1175react-component/select修复 useOpen 的 open/close 竞态问题
#1180react-component/select尝试修复锁导致的 option click 问题
#1183react-component/select核心修复:移除 ignoreNext / taskLockRef 锁机制,改用 cancelFun(2025-12-05 合入,发布于 1.3.3)
#1184react-component/select配套修复:blur 关闭逻辑改用 cancelFun
#56054ant-design/ant-designantd 升级 @rc-component/select(部分修复)
#55928ant-design/ant-designIssue:点击 dropdownRender 自定义内容导致下拉框关闭
#56033ant-design/ant-designIssue: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 + Escapeawait 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/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 事件触发前释放