Skip to content

fix(app-shell): context selector 的 auto-select-first 移到 commit 阶段,pick 不再被后发的自动选中覆盖 (objectstack#6979) - #3933

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-os6979-persist-test-race
Aug 9, 2026
Merged

fix(app-shell): context selector 的 auto-select-first 移到 commit 阶段,pick 不再被后发的自动选中覆盖 (objectstack#6979)#3933
yinlianghui merged 1 commit into
mainfrom
claude/issue-os6979-persist-test-race

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

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 已有先例(RouteFaderform.tsx 的状态修复)。

测试侧:逐用例排查,param-less 挂载全部 settle-then-click

ContextSelectors.persist.test.tsx 全文件 11 个用例逐条判定过:

用例判定
'query' 写 URL / 不碰 storage:点击前先等 ?package=billing 落定
'query' 从 URL 读回免改:挂载即带参数,首帧就有具体值,auto-select 不触发
'query' 忽略过期 storage 条目免改:本来就是 settle-then-assert 的正确写法(issue 引用的对照样本)
'query' 默认值(省略 persist):同首用例,默认解析为 'query',auto-select 落在 URL
'session' 写 storage / 不碰 URL:auto-select 与 pick 共用同一个 setValue,这里的竞态发生在 storage 里,等 storage 出现 billing 再点
'session' 从 storage 恢复免改:storage 已播种,首帧就有具体值
'session' 不被同名 query 参数遮蔽免改:同上
'none' 两处都不写但仍发布 pick:'none' 没有 store 可等,发布值本身就是要等的介质
'none' 不恢复上次会话 / 不读 URL免改:本来就在等 billing,且没有点击
'none' 重新挂载后丢弃 pick:点击前补等 billing(后半段本来就是 settle 形状)
冲突告警跟随 URL 介质免改:告警由挂载时的冲突 effect 按声明的 id 发出,任何值写入都不影响它(已在用例里写明理由)

即: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 两跑一字不差:

× keeps a `query` pick that lands before the auto-select is done reacting 58ms
× keeps a `session` pick the same way — the gap is not URL-specific 11ms
AssertionError: expected 'billing' to be 'crm_core'
Test Files 1 failed | 1 passed (2)
Tests 2 failed | 12 passed (14)

同一次反向验证里,改好写法的 persist.test.tsx 12 条全绿(上面 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 skipped
  • pnpm exec turbo run type-check --concurrency=2:78 successful, 78 total
  • changeset 三道闸:presence / no-major / fixed 全 0 退出

没能复现的部分,如实说明:修前状态在 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

…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>
@vercel

vercelBot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 9, 2026 8:02am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.1 KB350 KB
Entry fileindex-dI-zu_rY.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.66KB3.13KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)482.53KB106.37KB
core (index.js)3.00KB1.14KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)139.61KB35.99KB
fields (index.js)231.13KB56.80KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)9.48KB3.27KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)4.52KB1.96KB
layout (index.js)38.53KB10.71KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.98KB12.37KB
plugin-charts (index.js)61.04KB17.31KB
plugin-chatbot (index.js)180.33KB42.79KB
plugin-dashboard (index.js)117.21KB30.27KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)236.63KB59.02KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)112.10KB27.10KB
plugin-gantt (index.js)162.55KB39.57KB
plugin-grid (index.js)187.63KB49.66KB
plugin-kanban (index.js)48.30KB13.28KB
plugin-list (index.js)109.67KB26.55KB
plugin-map (index.js)16.81KB5.24KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.58KB10.58KB
plugin-timeline (index.js)25.76KB7.33KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)20.15KB6.72KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)2.71KB1.34KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

验收通过(objectui 分片 PM,session session_01GTRjn8xBqp75dk7kFupVRt)objectstack-ai/objectstack#6979(v17 收尾链)

实物核验:base d518a905a → head 418646aad,单提交 4 文件。CI 亲读终态:20 检查全 completed、0 失败,含原两连红的 Test shard 1/4 转绿

验收要点:

  • 根因定位超出 issue:auto-select-first 原是 passive effect,与「渲染出选项」的 commit 之间隔一个 task —— 那是一个真实的「可点但无选中值」窗口,落在窗口里的 pick 会被后发的过期闭包覆盖回首项。改 React.useLayoutEffect 与选项同一同步 flush,窗口从源头消失;触发条件/依赖一字未动,#5994 的三值语义与「param-less nav 后从首项重选」行为原样保留并新增用例钉住。
  • 确定性复现取代概率复现:MutationObserver 停在选项 commit 的裸复现打出与 CI 逐字同签名的失败,并解释了 flaky 的机理(RTL waitFor 会额外 drain 一个 macrotask 把 pending 的 auto-select 放过去);本地负载模拟不出 CI 红也如实declared。
  • 两个备选修法的否决论证扎实:ref 闩(闩清除时机是新的语义判断点,清晚了会让 mandatory scope 留空)与 PM 派发词里的「fire 时重读 params」(覆盖发生时 pick 的 re-render 尚未 commit,ref 读到的仍是旧值)—— 后者是对派发词的有效证伪,采纳。
  • 测试侧 11 用例逐条判定(4 改 settle-then-click、1 补等发布值、4 免改各有理由 + scopeKey 文件 6 用例核过无同形);5 连跑 + maxWorkers=4 三连跑全绿。
  • 曾怀疑的「双 query 选择器互吃参数」经一次性 probe 证伪后未立单 —— 先验证再决定,处置正确。

三件套走起,落 main 后为 objectui#3932 解锁。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 9, 2026 08:15
@yinlianghui
yinlianghui added this pull request to the merge queueAug 9, 2026
Merged via the queue into main with commit 2c632d9Aug 9, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-os6979-persist-test-race branch August 9, 2026 08:16
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@yinlianghui@claude