Skip to content

Commit abe4790

Browse files
baozhoutaoclaude
andcommitted
fix(deps): 模态表单里 lookup 搜索框聚焦即被夺回——补丁修复 focus-scope 栈驱逐竞态 (#3183)
产线 console 里,新建/编辑模态中 lookup 快捷下拉的搜索框和 Record Picker 表格弹窗都无法输入:焦点在 focusout 阶段就被外层 Dialog 的 FocusScope 同步拽回触发按钮,列表永不过滤("新建记录时 lookup 无法搜索")。 根因是 @radix-ui/react-focus-scope@1.1.16(上游最新 rc 同样存在)的竞态: 栈管理 effect 的 cleanup 用 setTimeout(0) 延迟执行 focusScopesStack.remove(scope)。当该 effect 因 container ref 抖动在组件 未卸载时重跑,顺序变成"重跑先 add → 旧 cleanup 的延时 remove 后执行", 刚加回去的 scope 被永久踢出栈——dialog 的陷阱监听器还活着,却再也没有 弹层能 pause 它。vite dev 不触发该抖动,所以只有产线 bundle 复现。 pnpm patch:effect 重跑(scope 存活)时取消挂起的延时驱逐;真实卸载路径 (autofocus-on-unmount + 栈移除)不变。回归测试用 asChild 子元素换 key 确定性复现竞态:未打补丁必红。已在 rc.1 后端 + 修复后产线 console 真机 验证下拉与表格弹窗搜索均恢复。 Closes #3183 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5cb75b3 commit abe4790

6 files changed

Lines changed: 248 additions & 14 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@object-ui/console": patch
3+
"@object-ui/components": patch
4+
---
5+
6+
Lookup search inside a create/edit modal is typeable again (objectui#3183).
7+
8+
In every production console build, the search input of a lookup field's
9+
quick-select popover — and the nested Record Picker dialog — could not take
10+
focus while the form modal was open: every click/focus was synchronously
11+
yanked back to the field trigger, so "新建记录时 lookup 无法搜索".
12+
13+
Root cause is a race in stock `@radix-ui/react-focus-scope@1.1.16`: the
14+
focus-scopes stack effect's cleanup schedules `focusScopesStack.remove(scope)`
15+
in a `setTimeout(0)`. When the effect re-runs for a still-mounted scope (a
16+
`container` ref flicker), the re-run re-`add`s the scope and the stale timeout
17+
then evicts it — the dialog's trap listeners stay active but its scope is no
18+
longer in the stack, so an opening popover pauses nothing and the trap yanks
19+
focus out of the popover forever.
20+
21+
Fixed via `patches/@radix-ui__react-focus-scope.patch`: an effect re-run for a
22+
live scope cancels the pending eviction; a real unmount still runs the full
23+
delayed cleanup (autofocus-on-unmount + stack removal). Regression-tested in
24+
`packages/components` with a deterministic reproduction of the race.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@
115115
"shell-quote@<1.8.4": "^1.8.4",
116116
"tmp@<0.2.6": "^0.2.6",
117117
"uuid@<11.1.1": "^11.1.1"
118+
},
119+
"patchedDependencies": {
120+
"@radix-ui/react-focus-scope": "patches/@radix-ui__react-focus-scope.patch"
118121
}
119122
},
120123
"dependencies": {

packages/components/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
},
9292
"devDependencies": {
9393
"@objectstack/spec": "^17.0.0-rc.0",
94+
"@radix-ui/react-focus-scope": "^1.1.16",
9495
"@tailwindcss/postcss": "^4.3.3",
9596
"@types/react": "19.2.17",
9697
"@types/react-dom": "19.2.3",
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
* A lookup popover's search input inside a modal Dialog must be focusable.
9+
*
10+
* Radix's FocusScope keeps a module-level stack: when the popover's scope
11+
* mounts it pauses the dialog's trap, so focus may travel into the portalled
12+
* popover (which lives at document.body, OUTSIDE the dialog's DOM). Stock
13+
* @radix-ui/react-focus-scope@1.1.16 has a race that silently breaks this
14+
* pact: the stack-add effect's cleanup schedules
15+
* `focusScopesStack.remove(scope)` in a `setTimeout(0)`. When the effect
16+
* re-runs for a still-mounted scope (any `container`/dep flicker), the re-run
17+
* `add`s the scope back and THEN the stale timeout evicts it. The dialog's
18+
* trap listeners stay alive but its scope is gone from the stack, so a later
19+
* popover pauses nothing and every focus into the popover's search input is
20+
* yanked straight back into the dialog — "lookup 无法搜索" in the production
21+
* console (objectui#3183).
22+
*
23+
* `patches/@radix-ui__react-focus-scope.patch` fixes the race by cancelling
24+
* the pending eviction when the effect re-runs for a live scope. The first
25+
* test drives the race deterministically (child key swap → new DOM node →
26+
* container ref reattach → effect re-run) and fails without the patch.
27+
*/
28+
29+
import { describe, it, expect, afterEach } from 'vitest';
30+
import * as React from 'react';
31+
import { render, cleanup, act, screen } from '@testing-library/react';
32+
import { FocusScope } from '@radix-ui/react-focus-scope';
33+
import { Dialog, DialogContent, DialogTitle } from '../ui/dialog';
34+
import { Popover, PopoverTrigger, PopoverContent } from '../ui/popover';
35+
36+
afterEach(cleanup);
37+
38+
/** Flush the focus-scope cleanup timers (the `setTimeout(0)` eviction path). */
39+
const flushEvictionTimers = () => act(() => new Promise<void>((r) => setTimeout(r, 25)));
40+
41+
/**
42+
* Minimal stand-in for "modal dialog with a portalled lookup popover":
43+
* a trapped outer scope (the dialog) and an optional untrapped inner scope
44+
* (the popover) that must pause it via the shared focus-scopes stack.
45+
* `contentKey` swaps the trapped scope's child element identity, forcing the
46+
* container ref to detach/reattach — the deterministic version of the
47+
* production dep flicker that re-runs the stack effect.
48+
*/
49+
function Harness({ contentKey, popoverOpen }: { contentKey: string; popoverOpen: boolean }) {
50+
return (
51+
<>
52+
<FocusScope trapped loop asChild>
53+
<div key={contentKey}>
54+
<button type="button">inside dialog</button>
55+
</div>
56+
</FocusScope>
57+
{popoverOpen && (
58+
<FocusScope asChild trapped={false}>
59+
<div>
60+
<input placeholder="search records" />
61+
</div>
62+
</FocusScope>
63+
)}
64+
</>
65+
);
66+
}
67+
68+
describe('focus-scope stack race (patched @radix-ui/react-focus-scope)', () => {
69+
it('a re-run of the trapped scope effect must not evict it — the popover still pauses the dialog trap', async () => {
70+
const { rerender } = render(<Harness contentKey="a" popoverOpen={false} />);
71+
await flushEvictionTimers();
72+
73+
// Swap the child key: the trapped scope's container node is replaced, its
74+
// ref detaches/reattaches, and the stack effect cleans up + re-runs.
75+
// Un-patched, the stale cleanup timeout then evicts the re-added scope
76+
// while its trap listeners stay active and `paused` is still false.
77+
rerender(<Harness contentKey="b" popoverOpen={false} />);
78+
await flushEvictionTimers();
79+
80+
// Open the "popover". Its scope pauses the stack top — which, un-patched,
81+
// no longer contains the dialog scope, so the trap keeps yanking.
82+
rerender(<Harness contentKey="b" popoverOpen />);
83+
await flushEvictionTimers();
84+
85+
const inside = screen.getByRole('button', { name: 'inside dialog' });
86+
const input = screen.getByPlaceholderText('search records');
87+
act(() => {
88+
inside.focus();
89+
});
90+
act(() => {
91+
input.focus();
92+
});
93+
await flushEvictionTimers();
94+
95+
expect(document.activeElement).toBe(input);
96+
});
97+
98+
it('sanity: without a popover the trapped scope still yanks outside focus back', async () => {
99+
render(<Harness contentKey="a" popoverOpen={false} />);
100+
await flushEvictionTimers();
101+
102+
const inside = screen.getByRole('button', { name: 'inside dialog' });
103+
act(() => {
104+
inside.focus();
105+
});
106+
107+
const outside = document.createElement('button');
108+
outside.textContent = 'outside';
109+
document.body.appendChild(outside);
110+
act(() => {
111+
outside.focus();
112+
});
113+
await flushEvictionTimers();
114+
115+
expect(document.activeElement).not.toBe(outside);
116+
outside.remove();
117+
});
118+
});
119+
120+
describe('lookup popover search inside a modal dialog (integration)', () => {
121+
it('the popover search input inside a Dialog receives and keeps focus', async () => {
122+
render(
123+
<Dialog open>
124+
<DialogContent>
125+
<DialogTitle>host dialog</DialogTitle>
126+
<Popover open>
127+
<PopoverTrigger asChild>
128+
<button type="button">pick</button>
129+
</PopoverTrigger>
130+
<PopoverContent>
131+
<input placeholder="search records" />
132+
</PopoverContent>
133+
</Popover>
134+
</DialogContent>
135+
</Dialog>,
136+
);
137+
await flushEvictionTimers();
138+
139+
// Focus starts on an element INSIDE the dialog (the field trigger), then
140+
// moves into the portalled popover input — the real interaction shape.
141+
const trigger = screen.getByRole('button', { name: 'pick' });
142+
const input = screen.getByPlaceholderText('search records');
143+
act(() => {
144+
trigger.focus();
145+
});
146+
act(() => {
147+
input.focus();
148+
});
149+
await flushEvictionTimers();
150+
151+
expect(document.activeElement).toBe(input);
152+
});
153+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
diff --git a/dist/index.js b/dist/index.js
2+
index e8906db3b1e87bfc568b75d72b7ed3d8d1d3c09a..7a05e0e4cffba466301ba1fb67067c0861af0a4d 100644
3+
--- a/dist/index.js
4+
+++ b/dist/index.js
5+
@@ -110,6 +110,10 @@ var FocusScope = /* @__PURE__ */ React.forwardRef(
6+
}, [trapped, container, focusScope.paused]);
7+
React.useEffect(() => {
8+
if (container) {
9+
+ if (focusScope.__pendingStackRemove !== void 0) {
10+
+ clearTimeout(focusScope.__pendingStackRemove);
11+
+ focusScope.__pendingStackRemove = void 0;
12+
+ }
13+
focusScopesStack.add(focusScope);
14+
const previouslyFocusedElement = document.activeElement;
15+
const hasFocusedCandidate = container.contains(previouslyFocusedElement);
16+
@@ -126,7 +130,8 @@ var FocusScope = /* @__PURE__ */ React.forwardRef(
17+
}
18+
return () => {
19+
container.removeEventListener(AUTOFOCUS_ON_MOUNT, onMountAutoFocus);
20+
- setTimeout(() => {
21+
+ focusScope.__pendingStackRemove = setTimeout(() => {
22+
+ focusScope.__pendingStackRemove = void 0;
23+
const unmountEvent = new CustomEvent(AUTOFOCUS_ON_UNMOUNT, EVENT_OPTIONS);
24+
container.addEventListener(AUTOFOCUS_ON_UNMOUNT, onUnmountAutoFocus);
25+
container.dispatchEvent(unmountEvent);
26+
diff --git a/dist/index.mjs b/dist/index.mjs
27+
index 030ff1a4d2383c9a0a1ba850aa4591890f028784..3dd39a90db9886bc495014a6e4136773e3548448 100644
28+
--- a/dist/index.mjs
29+
+++ b/dist/index.mjs
30+
@@ -75,6 +75,10 @@ var FocusScope = /* @__PURE__ */ React.forwardRef(
31+
}, [trapped, container, focusScope.paused]);
32+
React.useEffect(() => {
33+
if (container) {
34+
+ if (focusScope.__pendingStackRemove !== void 0) {
35+
+ clearTimeout(focusScope.__pendingStackRemove);
36+
+ focusScope.__pendingStackRemove = void 0;
37+
+ }
38+
focusScopesStack.add(focusScope);
39+
const previouslyFocusedElement = document.activeElement;
40+
const hasFocusedCandidate = container.contains(previouslyFocusedElement);
41+
@@ -91,7 +95,8 @@ var FocusScope = /* @__PURE__ */ React.forwardRef(
42+
}
43+
return () => {
44+
container.removeEventListener(AUTOFOCUS_ON_MOUNT, onMountAutoFocus);
45+
- setTimeout(() => {
46+
+ focusScope.__pendingStackRemove = setTimeout(() => {
47+
+ focusScope.__pendingStackRemove = void 0;
48+
const unmountEvent = new CustomEvent(AUTOFOCUS_ON_UNMOUNT, EVENT_OPTIONS);
49+
container.addEventListener(AUTOFOCUS_ON_UNMOUNT, onUnmountAutoFocus);
50+
container.dispatchEvent(unmountEvent);

pnpm-lock.yaml

Lines changed: 17 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)