Skip to content

fix(plugin-grid): inline 行动作槽位分配给存活的 primaries (#3762) - #3833

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3762-inline-slot-survivors
Aug 8, 2026
Merged

fix(plugin-grid): inline 行动作槽位分配给存活的 primaries (#3762)#3833
yinlianghui merged 1 commit into
mainfrom
claude/issue-3762-inline-slot-survivors

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3762

问题

RowActionMenu 的 inline 槽位分配跑在声明序上,且先于任何 visible 求值:

// 修改前(组件内的 useMemo)
const primaryDefs = gatedActionDefs.filter(d => d.variant === 'primary');
const max = Math.max(0, maxInlineActions);
inlineDefs: primaryDefs.slice(0, max)
menuDefs:   [...primaryDefs.slice(max), ...gatedActionDefs.filter(d => d.variant !== 'primary')]

于是当一行有 2 个以上 variant: 'primary'、且靠前那个的 visible 对这一行不成立时:

  • 被压掉的 primary 仍占住那个 inline 槽(RowActionInlineButton 对它 return null,槽位空转);
  • 后面那个通过了谓词的 primary 早已被 slice 进 menuDefs,只能渲染进「⋮」。

结果:这一行明明只有一个可见 primary、maxInlineActions(默认 1)也允许 1 个 inline,用户看到的却是「没有 inline 按钮 + 一个要点开才看得到主操作的 ⋮」——行的主 CTA 被藏进溢出菜单。

修法

槽位分配移进 planRowActionMenu排在可见性之后。该函数不再接收预先切好的 inlineDefs / menuDefs,改为接收未分区的 actionDefs(已过 ADR-0066 D4 能力门)加 maxInlineActions,自己过滤一遍再分区:

const surviving = (input.actionDefs ?? []).filter(
  (def) => isCustomRowActionVisible(def, row, scope, objectFields),
);
const survivingPrimaries = surviving.filter((def) => def.variant === 'primary');
const inlineBudget = Math.max(0, input.maxInlineActions ?? 1);
const inline = survivingPrimaries.slice(0, inlineBudget);
const custom = [
  ...survivingPrimaries.slice(inlineBudget),
  ...surviving.filter((def) => def.variant !== 'primary'),
];

存活判定沿用 PR #3816#3758)的入口isCustomRowActionVisible,声明检测按 != null && !== '',所以 visible: false 同样算「被压掉」、同样不占槽。这次没有新增第二处判定,判定与摆放收在同一个函数里 —— 全仓再没有第二处对 defs 分区,两者结构上无法漂移。

求值成本不变:仍是每个 def 一次 visible(改前 inlineDefs/menuDefs 两次 filter 作用在互斥集合上,总数相同),分区是纯数组操作。

前后对照(maxInlineActions: 1,两个 primary,靠前那个被 visible 压掉)

inline 按钮 「⋮」 菜单内容
修改前 无(槽位被压掉的 primary 空占) 存活的 primary
修改后 存活的 primary 无(没有可折叠项了)

语义边界(都未变):能进 inline 的数量(默认仍是 1)、菜单内排序(折叠的 primary 仍在 secondary 之上)、哪些项渲染、能力门(仍对声明集只施加一次、在本决定的上游)、#3562 的空菜单守卫(无可渲染项的行仍然不长「⋮」)。全部 primary 都未被门住的行(激发 maxInlineActionssys_environment Open + Upgrade Plan 那个形态)逐位不受影响 —— 声明序与存活序重合。

替换的断言(不是并存矛盾断言)

PR #3761 曾把当时的现状正向钉住:

  • a suppressed primary does NOT promote the next primary into its inline slotRowActionMenu.emptyGuard.test.tsx

那条钉子断言的正是本单要反转的行为,它的期望(upgrade 不在 inline、存在一个「⋮」)现在是错的裁定,因此按 fixture 三分类里的「整条替换」处理:删除该 case,在原地留下一段注释交代它为何被替换(沿用同文件里 #3758 替换 #3562 fixture 时的写法),新不变量写进新 describe。PR #3761 写在代码里的两处「slice 保持原状的理由」注释(planRowActionMenu 的 doc 段 + 组件内 useMemo 上方那段)也一并改写为新不变量;组件内引用旧标识符 menuDefs 的那句历史引文加了括注,避免留下悬空标识符。

新钉子(RowActionMenu.emptyGuard.test.tsx):

  • DOM:靠前 primary 被压 → 存活者拿到 inline 槽,且该行没有「⋮」(这条是正向断言,不会因为「什么都没产生」而空绿);
  • DOM:两者都存活 → 第二个仍进菜单、「⋮」回来(钉住 maxInlineActions 语义与默认值 1 未被顺手改宽);
  • DOM:全部 primary 被压 → 既无 inline 也无「⋮」(与 console: 平台内置「审批请求」(sys_approval_request) 行「更多操作」菜单展开后 0 个菜单项——渲染成 128×10 的空白方块 #3562 守卫交叉核对,重排槽位不得凭空造出触发器);
  • 纯函数:槽位给存活者而非声明首个;两个存活者第二个进菜单;maxInlineActions: 2每个槽都从存活集填(折叠的 primary 仍在 secondary 之上);maxInlineActions: 0 时不发生任何槽位分配。

planRowActionMenu 整个 describe 的入参从 inlineDefs/menuDefs 迁到 actionDefs(无 variant 即非 primary,菜单侧各 case 读法不变)。planRowActionMenu 不在包 barrel 里(barrel 只导出 RowActionMenu / formatActionLabel / RowActionMenuProps),入参换形不外溢;对外类型面零变化,所以下游包无需重编。

RowActionMenu.test.tsx 的 case 故意一条没动 —— 那里的 fixture 全部未加门,声明序与存活序重合,它们守的是当初 maxInlineActions 要修的「窄列裁切」回归;只在文件头 doc 补一句说明预算现在按存活集计。

消费半径清点

规则的调用方逐一 grep 过:planRowActionMenu 只被本组件与 RowActionMenu.emptyGuard.test.tsx 引用;RowActionMenu 只被同包 ObjectGridmaxInlineActions={schema.maxInlineRowActions ?? 1})消费,app-shell/src/views/ObjectView.tsx 里只是一句注释;packages/components 的 data-table 半边(PR #3756)没有 inline primary 这条路径,不共享代码。e2e 里只用到 row-action-trigger(未门住的 fixture)。全仓无残留的 inlineDefs/menuDefs 入参写法,仅剩两处明确标注为旧守卫引文的注释。

验证

仓根 flock 串行 + NODE_OPTIONS=--max-old-space-size=4096 + --maxWorkers=2(新 worktree 里先 pnpm --filter '@object-ui/plugin-grid^...' build)。

pnpm --workspace-concurrency=2 --filter @object-ui/plugin-grid test -- --maxWorkers=2
 Test Files  56 passed (56)
      Tests  516 passed (516)

pnpm --workspace-concurrency=2 --filter @object-ui/plugin-grid type-check
 tsc --noEmit && tsc -p tsconfig.typetests.json     (无输出,通过)

pnpm --filter @object-ui/plugin-grid lint
 ✖ 569 problems (0 errors, 569 warnings)            (全部为既有 warning,本文件新增 0)

node scripts/check-control-bytes.mjs
 ✅ OK (scanned 3747 tracked text file(s); skipped 85 binary)
grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f]' 本次改动四个文件 → 零命中

反向验证(方向先判后跑)

跑之前先写下预判:只把 planRowActionMenu 内部的分配顺序退回 #3762 前(先按声明序 slice,再 filter 可见性),保留新签名,这样测试仍能编译、信号纯粹是语义层面的。预判 恰好 3 条红,全在 emptyGuard 文件:晋升那条 DOM 钉 + 两条槽位分配的纯函数钉;另外「两者都存活」「全部被压」「maxInlineActions: 0」以及整个 RowActionMenu.test.tsx 应当保持绿——它们是不回归钉,不是本次修复的证据(尤其「全部被压」那条两种语义下都绿,其中一半是「什么都没产生」的空绿,这正是晋升钉写成正向断言的原因)。

实测与预判逐条一致:

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯
 FAIL  RowActionMenu.emptyGuard.test.tsx > inline slots go to SURVIVING primaries (objectui#3762)
       > a suppressed leading primary yields its inline slot to the surviving one
TestingLibraryElementError: Unable to find an element by: [data-testid="row-action-inline-upgrade"]
 FAIL  ... > planRowActionMenu > spends the inline slot on the surviving primary, not the declared first
AssertionError: expected [] to deeply equal [ 'upgrade' ]
 FAIL  ... > planRowActionMenu > fills EVERY slot from the survivors, and keeps folded primaries above secondaries
AssertionError: expected [ 'open' ] to deeply equal [ 'open', 'upgrade' ]
 Test Files  1 failed | 55 passed (56)
      Tests  3 failed | 513 passed (516)

事后已用 patch 逐字节还原(diff 比对确认与反向验证前的改动完全相同),并在推送前重跑一遍全量 + type-check 全绿。

Changeset

.changeset/row-action-inline-slot-survivors-3762.md@object-ui/plugin-grid: patch —— 用户可见的行为变化(某一项从菜单里移到 inline),正文写明变化面与不变面。

可达性说明(承接 issue 的诚实标注)

issue 正文明确「未在真实元数据上复现」:形态(同一 list_item 上 2 个以上 primary)确实存在且是既有测试的原始动因(cloud sys_environment 的 Open + Upgrade Plan),但靠前那个是否带 visible 未经核实。本 PR 不改变这一点 —— 代码层面的不对称是确定的且已修,可达性仍按分诊裁定(pm:queue,最坏是 CTA 摆错位置,非数据问题)。


Generated by Claude Code

…t render (#3762)

`RowActionMenu` allocated its inline slots on the DECLARED row actions, before
any `visible` predicate ran: `primaryDefs.slice(0, maxInlineActions)`. So on a
row whose LEADING `variant:'primary'` action was suppressed by its own
`visible`, that action still held the slot — `RowActionInlineButton` returned
`null` into it — while the next primary, the one that did survive the row's
predicates, had already been sliced into the overflow list. The row rendered no
inline button and a "⋮" hiding its main CTA, though exactly one primary was
visible and the budget (default 1) allowed exactly one inline button.

The slot decision moves inside `planRowActionMenu`, after visibility: it now
takes the capability-gated defs unpartitioned (`actionDefs` + `maxInlineActions`
replace the pre-sliced `inlineDefs`/`menuDefs`), filters them once, then
partitions the survivors. Survival and placement decided in one function is the
point — there is no second place that partitions the defs, so they cannot drift.
Cost is unchanged at one `visible` evaluation per def; the partition is array
work.

`maxInlineActions` keeps its meaning and its default of 1 — it is a width budget
for real buttons, and charging it for an invisible action protected no layout.
Menu order (folded primaries above secondaries), which items render at all, the
ADR-0066 D4 capability gate and the #3562 empty-menu guard are all untouched.

PR #3761's status-quo pin `a suppressed primary does NOT promote the next
primary into its inline slot` asserted the behavior this issue reverses, so it
is REPLACED (its expectations are now the wrong verdicts, not a re-spelling) —
along with the code comments that recorded why the slice stayed on declared
order. The replacement suite pins the new invariant, the unchanged budget (two
survivors still fold the second), and cross-checks the #3562 guard. The ungated
fixtures in `RowActionMenu.test.tsx` are deliberately left alone: declared order
and surviving order coincide there, so they hold the clipped-column regression
this budget exists for.

Reverse-verified by restoring the pre-#3762 allocation order under the new
signature: exactly the 3 predicted tests went red (the promotion pin plus the
two slot-allocation unit cases), 513 others stayed green.

Co-authored-by: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 8, 2026 6:29pm

Request Review

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.1 KB 350 KB
Entry file index-Cl_hvHM1.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.66KB 3.13KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 481.84KB 106.09KB
core (index.js) 2.96KB 1.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 139.61KB 35.99KB
fields (index.js) 230.82KB 56.70KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
layout (index.js) 38.53KB 10.71KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 44.98KB 12.37KB
plugin-charts (index.js) 61.04KB 17.31KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 117.21KB 30.27KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 233.78KB 57.92KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 112.10KB 27.10KB
plugin-gantt (index.js) 162.55KB 39.57KB
plugin-grid (index.js) 187.63KB 49.66KB
plugin-kanban (index.js) 48.30KB 13.28KB
plugin-list (index.js) 105.12KB 25.48KB
plugin-map (index.js) 16.81KB 5.24KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.58KB 10.58KB
plugin-timeline (index.js) 25.76KB 7.33KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 19.28KB 6.38KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.02KB 0.55KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 2.71KB 1.34KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

Copy link
Copy Markdown
Collaborator Author

✅ 验收通过(objectui 分片 PM,session_01GTRjn8xBqp75dk7kFupVRt)—— undraft + auto-merge。

核验:分配移入 planRowActionMenu 于可见性求值之后(复用 PR #3816 的声明检测入口,visible:false 也不占槽),菜单序/预算/门面/#3562 守卫四不变面明确;函数不在包 barrel,输入形状变更不出包,公开面字节不变。PR #3761 现状钉整条替换并留注释(#3758 同款处置),两处理由注释同步改写。反向验证设计到位:只还原分配顺序保新签名使信号纯语义化,预判 3 红逐条命中,晋升钉用具名正断言避开 nothing-produced 绿;修复自补丁还原并 diff 证明字节一致。19/19 CI 零失败(PM 独立复核);changeset patch 写明迁移面与不变面;trailer 自查零命中。行为变化(存活 primary 从菜单回 inline)系声明所要求,真实元数据可达性保持 issue 原有的诚实未证状态。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 8, 2026 18:50
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 8, 2026
Merged via the queue into main with commit 14c59c0 Aug 8, 2026
20 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3762-inline-slot-survivors branch August 8, 2026 18:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-grid: 被 visible 压掉的 primary 行动作白占一个 inline 槽位,把本该 inline 的下一个 primary 挤进溢出菜单

2 participants