Uh oh!
There was an error while loading. Please reload this page.
fix(app-shell): context selector 的 auto-select-first 移到 commit 阶段,pick 不再被后发的自动选中覆盖 (objectstack#6979) - #3933
Merged
Conversation
…phase `SelectorControl` auto-selects `options[0]` as soon as the option list resolves and nothing concrete is selected — a context selector is a mandatory scope. That repair ran in a passive effect, i.e. in a task AFTER the commit that rendered the option rows, which left a gap containing a rendered, clickable dropdown and no selection yet. A pick delivered inside the gap was applied and then undone: the queued auto-select fired second with a closure captured before the pick (`hasConcrete` still false, and the search string it wrote from still the pre-pick one), so the user's choice was silently replaced by the first row — with a URL rewrite behind it for a `persist: 'query'` selector. The effect is now a layout effect, so it lands in the same synchronous flush as the options it reacts to: no event can be delivered in between, and the control is never painted with an empty value while options exist. Same trigger, same deps, one write per medium as before — a scope dropped later by a param-less nav link is still re-established from the first option, which is what objectstack#5994 relied on when it deleted the storage-to-URL bridge. Surfaced as a CI flake. `ContextSelectors.persist.test.tsx`'s first case clicked `crm_core` immediately after the option rows appeared, and on a loaded worker its own pick lost this race — twice in a row on objectui#3932's shard, `expected 'billing' to be 'crm_core'`, on a case with no overlap with that PR's file surface. Every param-less mount in that file now settles the auto-select in the medium `persist` names before picking, which also pins a fact none of those cases pinned before: a user's pick overrides the auto-selected first option. The cases that mount a URL param, or seed storage for a `'session'` selector, hand the control a concrete value on first render — auto-select never fires there and they are unchanged, each with the reason recorded. `ContextSelectors.autoSelectRace.test.tsx` pins the load-independent half, which no amount of waiting in a test can fix for a real user: a click aimed straight into the gap (a raw `MutationObserver` stops at the options commit, where RTL's `waitFor` would drain a macrotask and let the pending auto-select through) must keep the pick, on the URL medium and the storage medium alike. Reverse-verified in the predicted direction: restoring `React.useEffect` turns both cases red immediately rather than by timeout — the clobber happens inside the click's own `act`, so the synchronous assertion right after `fireEvent.click` is the one that fires, with `expected 'billing' to be 'crm_core'`. The third case is the opposite direction on purpose: it stays green across that revert, because it guards the behaviour the fix must not change (re-selection after a nav link drops the param) against a "latch auto-select off after the first pick" style fix. Fixesobjectstack-ai/objectstack#6979 Co-authored-by: Claude <noreply@anthropic.com>
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
yinlianghui
commented
Aug 9, 2026
CollaboratorAuthor
✅ 验收通过(objectui 分片 PM,session 实物核验:base 验收要点:
三件套走起,落 main 后为 objectui#3932 解锁。 Generated by Claude Code |
yinlianghui
marked this pull request as ready for review
August 9, 2026 08:15
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixesobjectstack-ai/objectstack#6979
结论:issue 的机理判断成立,且不止是测试写法问题
issue 的诊断逐字复核成立:
ContextSelectors.persist.test.tsx首用例在optionsReady()之后立即点击,与SelectorControl的 auto-select-first 竞速;高负载下 auto-select 后发、带过期闭包(hasConcrete仍为 false、写入用的 search 仍是 pick 之前的),把用户 pick 覆盖回options[0](billing)。补充一层 issue 没写、但决定了修法的事实:这个 gap 不是测试环境的产物。auto-select 原本是 passive effect,排在「渲染出选项行的那次 commit」之后的一个 task 里执行,于是存在一个「下拉已渲染、可点,但尚无选中值」的真实窗口。选项接口慢、机器负载高、低端设备上,用户恰好在这个窗口里点选,pick 会被随后落地的 auto-select 覆盖 —— 对
persist: 'query'的选择器还会连带改写 URL。测试里再怎么等,都替真实用户等不了。所以本 PR 两侧都修。组件侧:把 auto-select-first 移到 commit 阶段
packages/app-shell/src/layout/ContextSelectors.tsx里 auto-select 那条React.useEffect改为React.useLayoutEffect。它与选项所在的 commit 落在同一个同步 flush 里,期间无法投递任何事件,窗口从源头消失;顺带也不再有「选项已就绪却渲染空值」的一帧。触发条件、依赖数组、每种介质一次写入全部不变 —— 后续 nav link 把 scope 参数抹掉时,依旧从首项重新建立(objectstack#5994 删掉 storage 到 URL 的 bridge 时依赖的就是这条),该行为已单独钉住。
评估过、未采用的另一种改法:给「用户已 pick」加一个 ref 闩,让后发的 auto-select 看到闩就放弃。它能修同一个竞态,但语义不唯一 —— 闩什么时候清很关键,清晚了(比如永久闩上)就会在 nav link 抹掉参数后把 scope 留空,正是 #5994 判定「不需要 bridge」所依赖的那条行为。相比之下换 effect 相位不引入新状态、不新增分支,风险面小得多。另外确认过「fire 时重读当前 location/params」这条 issue 提到的方向不足以修好:覆盖发生时,pick 触发的 re-render 还没 commit(React 在开始下一次渲染前先 flush pending passive effects),所以任何在 render 期更新的 ref 读到的仍是 pick 之前的值 —— 只有相位或闩能解决。
SSR 面已核:依赖
@object-ui/app-shell的只有 console SPA(Vite)与两个 console example,唯一 SSR 的@object-ui/site不依赖它;仓内useLayoutEffect已有先例(RouteFader、form.tsx的状态修复)。测试侧:逐用例排查,param-less 挂载全部 settle-then-click
ContextSelectors.persist.test.tsx全文件 11 个用例逐条判定过:'query'写 URL / 不碰 storage?package=billing落定'query'从 URL 读回'query'忽略过期 storage 条目'query'默认值(省略persist)'query',auto-select 落在 URL'session'写 storage / 不碰 URLsetValue,这里的竞态发生在 storage 里,等 storage 出现billing再点'session'从 storage 恢复'session'不被同名 query 参数遮蔽'none'两处都不写但仍发布 pick'none'没有 store 可等,发布值本身就是要等的介质'none'不恢复上次会话 / 不读 URLbilling,且没有点击'none'重新挂载后丢弃 pickbilling(后半段本来就是 settle 形状)即:
session/none的用例并非「不经 auto-select 路径」,而是 auto-select 走同一个setValue、只是落在各自介质里 —— 所以 param-less 挂载的那三条一并治了,免改的四条都是「首帧即有具体值,auto-select 从不触发」。顺手核过同目录的
ContextSelectors.scopeKey.test.tsx:6 个用例全部挂载带参数的 URL 或播种 storage,没有同形用例,不需要改。新增 ContextSelectors.autoSelectRace.test.tsx —— 钉住不依赖负载的那一半
三条用例。前两条把点击精确瞄进 gap:用裸
MutationObserver停在「渲染出选项行的那次 commit」,RTL 的waitFor在回调首次成功后会额外 drain 一个 macrotask、正好把 pending 的 auto-select 放过去(这也正是本 bug 表现为 flaky 而不是稳定红的原因)。URL 介质与 storage 介质各钉一条,防止将来只修一半。第三条方向相反,是故意的:它钉住「nav link 抹掉参数后仍从首项重选」,把上面那个 ref 闩式改法可能踩坏的行为提前挡住。
验证证据
反向验证(方向先判后跑,判断成立):把组件改回
React.useEffect,新增文件前两条立刻变红而不是超时 —— 覆盖发生在点击自己的act里,所以fireEvent.click之后那条同步断言先炸,消息与 CI 两跑一字不差:同一次反向验证里,改好写法的
persist.test.tsx12 条全绿(上面1 passed)—— settle-then-click 不依赖 effect 相位,两侧修法各自独立成立。第三条用例在这次 revert 下保持绿,与预判一致。稳定性(修后):
pnpm exec vitest run packages/app-shell/src/layout/__tests__/ --maxWorkers=2连跑 5 次:17 passed (17) / 88 passed (88)× 5--maxWorkers=4连跑 3 次:同上全绿pnpm exec vitest run packages/app-shell --maxWorkers=2:306 passed (306)/2772 passed | 1 skippedpnpm exec turbo run type-check --concurrency=2:78 successful, 78 total没能复现的部分,如实说明:修前状态在 4 核机器上加 6 个 CPU 占用进程、
--maxWorkers=4连跑 6 次,原用例始终是绿的 —— CI 那个 shard 的负载(约 1/4 全量 dom 文件 + 饱和的 Vite transform 管线)本地无法等价复制。红绿的实际开关是 Node 事件循环的相位竞争:React scheduler 用setImmediate(check 阶段)排 passive effect flush,RTL 的 drain 用setTimeout(0)(timers 阶段),两者谁先取决于当前处在循环的哪个阶段、每个阶段在负载下有多长。所以本 PR 用的是确定性复现而非概率复现:裸MutationObserver直接把测试放到「effect 仍 pending」这个 CI 命中的状态上,产出与 CI 完全同签名的失败,再证明修后同条件全绿。Generated by Claude Code