现象
生产构建的 console(如 @objectstack/console@17.0.0-rc.1)中,新建/编辑记录的模态表单里:
- lookup 字段的快捷下拉:点搜索框无法输入——输入框拿不到焦点,焦点被同步拽回「选择...」触发按钮,列表始终不过滤;
- 点「浏览全部」弹出的记录选择表格(Record Picker)同样无法搜索。
「新建用户权限集」表单的 用户(sys_user)lookup 上真机复现;任意对象的 lookup 均受影响。仅产线 bundle 复现,vite dev(HMR)正常,因此长期未被本仓测试发现。
排查(EHR rc.1 后端 + 逐层插桩坐实)
- 服务端无辜:
GET /api/v1/data/sys_user?search=zzz 正确返回 0 条(rc.1 与 main 均正常); - 前端链路(
useRecordQuery → $search → rawFind ?search=)代码正确,但请求根本没发出——因为搜索框根本打不进字; - 插桩显示:
input.focus() 时,焦点在 focusout 阶段就被外层 Dialog 的 Radix FocusScope.handleFocusOut 同步拽回触发按钮; - 弹层(Popover)的 FocusScope 挂载副作用确认执行了
focusScopesStack.add,但外层 dialog scope 的 pause()从未被调用——它已经不在栈里了; - 根因是
@radix-ui/react-focus-scope@1.1.16(上游最新 rc 同样存在)的竞态:栈管理 effect 的 cleanup 用 setTimeout(0) 延迟执行 focusScopesStack.remove(scope)。当该 effect 因依赖抖动(container ref 重挂)在组件未卸载的情况下重跑时,顺序变成「重跑先 add → 旧 cleanup 的延时 remove 后执行」,把刚加回去的 scope 永久踢出栈。此后 dialog 的陷阱监听器仍然活着,但任何弹层都无法暂停它 → 弹层内输入框永远无法聚焦。
方案
pnpm patch @radix-ui/react-focus-scope(patches/@radix-ui__react-focus-scope.patch):
- effect 重跑(scope 仍存活)时,取消上一次 cleanup 挂起的延时驱逐(
clearTimeout); - 真正卸载时行为不变(autofocus-on-unmount + 栈移除照旧)。
回归测试 packages/components/src/__tests__/dialog-popover-focus-scope.test.tsx:用「asChild 子元素换 key → container ref 重挂 → effect 必然重跑」确定性复现竞态,未打补丁必红、打补丁转绿;另附 Dialog+Popover 集成用例。
已在 os-tianshun-ehr rc.1 后端 + 修复后产线 console 实测:下拉搜索 zzz→「未找到选项」、Dev→正确过滤;Record Picker 表格弹窗搜索同样恢复。
上游(radix-ui/primitives)最新版本仍未修复该竞态,补丁需保留至上游修复为止。
现象
生产构建的 console(如
@objectstack/console@17.0.0-rc.1)中,新建/编辑记录的模态表单里:「新建用户权限集」表单的 用户(sys_user)lookup 上真机复现;任意对象的 lookup 均受影响。仅产线 bundle 复现,
vite dev(HMR)正常,因此长期未被本仓测试发现。排查(EHR rc.1 后端 + 逐层插桩坐实)
GET /api/v1/data/sys_user?search=zzz正确返回 0 条(rc.1 与 main 均正常);useRecordQuery → $search → rawFind ?search=)代码正确,但请求根本没发出——因为搜索框根本打不进字;input.focus()时,焦点在 focusout 阶段就被外层 Dialog 的 RadixFocusScope.handleFocusOut同步拽回触发按钮;focusScopesStack.add,但外层 dialog scope 的pause()从未被调用——它已经不在栈里了;@radix-ui/react-focus-scope@1.1.16(上游最新 rc 同样存在)的竞态:栈管理 effect 的 cleanup 用setTimeout(0)延迟执行focusScopesStack.remove(scope)。当该 effect 因依赖抖动(container ref 重挂)在组件未卸载的情况下重跑时,顺序变成「重跑先 add → 旧 cleanup 的延时 remove 后执行」,把刚加回去的 scope 永久踢出栈。此后 dialog 的陷阱监听器仍然活着,但任何弹层都无法暂停它 → 弹层内输入框永远无法聚焦。方案
pnpm patch @radix-ui/react-focus-scope(patches/@radix-ui__react-focus-scope.patch):clearTimeout);回归测试
packages/components/src/__tests__/dialog-popover-focus-scope.test.tsx:用「asChild 子元素换 key → container ref 重挂 → effect 必然重跑」确定性复现竞态,未打补丁必红、打补丁转绿;另附 Dialog+Popover 集成用例。已在 os-tianshun-ehr rc.1 后端 + 修复后产线 console 实测:下拉搜索
zzz→「未找到选项」、Dev→正确过滤;Record Picker 表格弹窗搜索同样恢复。上游(radix-ui/primitives)最新版本仍未修复该竞态,补丁需保留至上游修复为止。