Skip to content

fix(plugin-grid): 行「更多操作」触发器改为数「真正会渲染的项」,空则不渲染 - #3761

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3562-rowactionmenu-guard
Aug 8, 2026
Merged

fix(plugin-grid): 行「更多操作」触发器改为数「真正会渲染的项」,空则不渲染#3761
yinlianghui merged 1 commit into
mainfrom
claude/issue-3562-rowactionmenu-guard

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3562

孪生半件(plugin-grid),关闭本单。data-table 半件是已合并的 PR #3756Part of,不关单)——两者是同一裁定、同一形状在两个渲染路径上的落地。本 PR 修的是报告人那一屏:console 对象列表的行菜单。

PM 裁定(方案 A,2026-08-08):守卫改为数谓词过滤后真正渲染的项,零则不渲染触发器,含 :219 的 inline primary 按钮路径;visible: false 真值门逐字保留(那是 #3758)。

落点(已按 origin/main@e473b6c29 复核,行号以内容为锚)

packages/plugin-grid/src/components/RowActionMenu.tsx 修改前的守卫:

const hasMenu = Boolean(
  (canEdit && onEdit) ||
  (canDelete && onDelete) ||
  menuDefs.length > 0 ||
  (rowActions?.length ?? 0) > 0,
);

数的是**「handler 是否接了」+「声明了几条」**;而项一级的过滤在后面各自跑第二遍——RowActionMenuItem(自定义动作的 visible)、BuiltinRowActionItemvisibleWhen)、RowActionInlineButton(inline primary 的 visible)。于是「handler 都在、声明都在,但这一行的每一项都被谓词压掉」这个状态可达:触发器活过守卫,菜单里什么都不渲染。

sys_approval_requestlist_item 声明的 approve / reject / recall 等动作,visible 是写给审批人的;admin 在「全部」视图里逐行都不满足 → hasMenu 为真、0 项 —— 正是报告实测的 data-state=open / z-index: 50 / 128×10 / [role=menu] children === 0。本组件一条分隔线都没有,全压掉恰好是 0,与报告逐字吻合(data-table 那条路径在两个 handler 同时在场时会无条件渲染一条分隔线,children 至少为 1 —— 这也是 PR #3756 判定落点不在 data-table 的旁证之一)。

一处早已存在的同类先例:ADR-0066 D4 的能力门(framework#3923)当初就是因为同样的理由被折进 hasMenu 的(注释原文:applied ONCE to the whole declared set so the inline CTA, the overflow menu and hasMenu all agree)。按记录求值的谓词没有被一并折进去,本 PR 补上这一半。packages/plugin-grid/src/__tests__/rowCrudEffectiveOps.test.tsx 的辅助函数甚至已经把期望行为写在注释里了(RowActionMenu renders the "⋮" only when at least one entry survives its gates)——在此之前那句话只对能力门成立。

修复

守卫改为数真正会渲染的菜单项,零则整个触发器不渲染。

  • 单一可见性源:新增 isBuiltinRowActionVisible / isCustomRowActionVisibleplanRowActionMenu三个item 渲染器都读它们。项与触发器因此在结构上无法各说各话(不是靠两处写法碰巧一致)。与 PR fix(data-table): 行「更多操作」触发器改为数「真正会渲染的项」,空则不渲染 #3756 的 data-table 实现保持结构平行(同样的函数名、同样的 plan 形状),不跨包 import。
  • :219 的 inline primary 路径:它本来就在谓词失败时 return null,所以行为不变;变的是它现在读的是同一个共享函数,而不是自己那份 useRowPredicate + 真值判断。裁定点名要覆盖这条路径,覆盖方式是接入单一来源而不是新增拦截。
  • 求值不用 hookevalRowActionVisibility@object-ui/coreevalRowPredicatefallback: false + warnOnError + fields,与 useRowPredicate 同参),因为动作条数是变量,一项一个 useRowPredicate 会把 hook 数量绑到 rowActionDefs.length 上。布尔短路照抄本包 bulkEligibility 里写明的理由(objectui#3492:布尔交给引擎会 fault 并 fail-closed,visible: true 曾因此把按钮对所有人藏起来)。
  • 守卫落在行级visibleWhen / visible按记录求值的,同一张表第一行可能保留 Edit、第二行一项不剩。RowActionMenu 本来就由 _actions 列的 cell 逐行实例化,所以决策天然落在这里,无需像 data-table 那样新抽 per-row 组件。
  • plan.menuCount 而不是 plan.count:本表面还有 inline 按钮路径,而 inline 按钮不参与触发器的决策(一行只有一个存活 primary 时,历来就是「有 inline、无 ⋮」)。命名上把这件事写死,避免下一个读者误加。

刻意改的两处(都在裁定第 4 条内)

  1. visible: false 真值门逐字保留!def.visiblefalse 读成「未设门」,于是该项渲染并计数——正是两个 item 组件历来的行为。重新裁决它会改变哪些项渲染,越出本单,已由 行动作声明 visible: false 仍然渲染该菜单项——可见性门用真值判断(#3492 在行菜单上的未收口部分) #3758 单独记录。测试里正向钉住了这一条。
  2. maxInlineActions 的 slice 仍跑在「声明的 primaries」上:不按存活重算。重算会把下一个 primary 提进被压掉那个的槽位,即改变某一项渲染在哪里;本单只管触发器是否渲染。测试里也钉住了。(顺带发现的槽位浪费问题已单独立单,见下。)

列对齐

操作列(_actions)是 ObjectGrid表级注入的(hasActions || hasRowActions),表头与每行的单元格都不由本次改动决定;本 PR 只改单元格内部的内容。没有动作可显示的行渲染一个空单元格,与「完全没接 handler 的表」历来的形态一致(与 PR #3756 同一约定)。测试里用真实 ObjectGrid 钉住了混合行(一行有触发器、一行没有)的 td 数量一致。

测试

packages/plugin-grid/src/components/__tests__/RowActionMenu.emptyGuard.test.tsx(新增,23 例):

  • 报告人的形状:整行被压掉 → 无触发器、[role=menu] 不存在、单元格内无按钮;
  • 只压掉一部分 → 触发器保留;常规 Edit + Delete → 触发器在(报告人的对照组不变);什么都没接 → 无触发器(旧行为不变);
  • 全部自定义动作被 visible 压掉(approve / reject / recall 三条,即 sys_approval_request 的形状)→ 无触发器;其中一条存活 → 触发器保留;
  • legacy 字符串 rowActions 没有谓词可失败,因此永远保住触发器(刻意保留);
  • :219 inline primary:谓词失败 → 既无 inline 按钮也无触发器;同一 def 在通过的行上照常 inline;被压掉的 primary 把下一个 primary 提进槽位;
  • 真实 ObjectGrid 列对齐:全行被压 → 0 触发器 + 表头在 + 每行空单元格;混合行 → 1 个触发器且两行 td 数量相同;
  • planRowActionMenu 纯函数 11 例:计数、只掉被压的那一项、同一表格两行不同结论、只有 handler 没有对象裁定不计数、存活动作按声明序、inline 被过滤但不计入 menuCount、legacy 计数、disabledWhen 的项仍然算数、谓词报错 fail-closed(不产生幽灵项/幽灵触发器)、visible: false 真值门正向钉住。

既有的行菜单测试(RowActionMenu.test.tsx / RowActionMenu.capabilityGate.test.tsx / rowCrudEffectiveOps / column-features / legacyRowActionDispatch)未改动即全绿。

正向(仓库根,flock 串行,--maxWorkers=2NODE_OPTIONS=--max-old-space-size=4096):

pnpm exec vitest run packages/plugin-grid/ --maxWorkers=2
 Test Files  55 passed (55)
      Tests  479 passed (479)

反向验证(先写下预测,后运行)

预测(原文存档于运行前):把守卫那一行换回旧式声明计数(共享可见性函数、plan、item 渲染全部保留),则 4 红 19 绿——

  1. renders NO trigger when every built-in item is predicate-suppressedcanEdit && onEdit 为真,旧守卫复活空触发器)
  2. suppresses the trigger when every CUSTOM action is invisible for the rowmenuDefs.length === 3 > 0
  3. ObjectGrid every row suppressed → no triggers…(0 → 2)
  4. ObjectGrid mixed rows keep identical td countscanEdit 是表级的,旧守卫给两行都发触发器:1 → 2)

并明确预测不会红的两条:inline 套件里那两条(单个 primary 占掉 inline 槽 → menuDefs 为空且无 handler → 旧守卫同样为 false)。它们钉的是 :219 这条路径,结构上无法察觉守卫被换回去——照模板想当然写成「全红」是不对的。11 条纯函数用例不经过守卫,同样保持绿。

实测:

 × renders NO trigger when every built-in item is predicate-suppressed
 × suppresses the trigger when every CUSTOM action is invisible for the row
 × every row suppressed → no triggers, header intact, empty cells (the reported shape)
 × mixed rows keep identical td counts (one row with a trigger, one without)
 Test Files  1 failed (1)
      Tests  4 failed | 19 passed (23)
AssertionError: expected 1 to be 2  // mixed-rows:两行都长出了触发器

4 红 19 绿,与事先写下的预测逐条一致(含"哪两条不会红"的预测)。

其余门

  • pnpm --filter @object-ui/plugin-grid type-check → 0(tsc --noEmit + typetests 均通过)
  • pnpm --filter @object-ui/plugin-grid lint0 error(563 warning 为全包既有基线)
  • node scripts/check-control-bytes.mjs → OK;另对改动文件手工扫过控制字符类(grep -naP\x00-\x08\x0b\x0c\x0e-\x1f),无命中
  • node scripts/check-changeset-no-major.mjs → OK(changeset 为 patch

消费半径清查(不是只扫本包)

全仓 grep RowActionMenurow-action-trigger:源码消费者只有 plugin-grid/src/ObjectGrid.tsx_actions 列的 cell)与包 barrel index.tsx 的再导出;app-shell/src/views/ObjectView.tsx 只在注释里提到它,无 import。跨包无任何 import。e2e live 三个 spec(action-modal / list-row-action-cel / screen-flow)都以「先等到触发器、点开、断言存在的项」为形态——list-row-action-cel 显式依赖 "Edit" is always present,即 menuCount >= 1,不受影响。

备注(评审可留意)

三个新的 export function 与 PR #3756 一样与组件同文件(保持结构平行、且「守卫和项读同一个函数」这件事对下一个读者是自证的),代价是 react-refresh/only-export-components 由 1 条变 4 条 warning。该文件因既有的 formatActionLabel 导出本来就已经放弃了 fast refresh,故无新增能力损失;若评审倾向拆出独立模块(本包已有 bulkEligibility.ts 这样的纯逻辑模块先例),我照改。


顺带发现、未在本 PR 修、已单独立单:maxInlineActions 的槽位分配跑在声明序上,因此被 visible 压掉的 primary 会白占一个 inline 槽,把本该 inline 的下一个 primary 挤进溢出菜单(详见 issue 正文)。改它属于「某一项渲染在哪里」,越出本单裁定第 4 条。


Generated by Claude Code

… declarations

The object list's row overflow trigger was guarded on whether row-action
HANDLERS were wired and how many actions were DECLARED
((canEdit && onEdit) || (canDelete && onDelete) || menuDefs.length > 0 ||
rowActions.length > 0), while the menu's items were filtered a second time -
per item, per record - against `visibleWhen` / `visible`. A row whose every
item was predicate-suppressed therefore kept a trigger that opened an empty
menu, which reads as a broken page: sys_approval_request declares its
list_item actions (approve / reject / recall) gated for approvers, so an admin
in the "all" view failed every one of them row by row and every row grew a
128x10 empty box with zero [role=menu] children (objectui#3562).

The trigger is now decided by the items that will actually render for that
row, and the decision is per ROW because the predicates are per record: within
one grid a row that keeps an action keeps its trigger while a row with nothing
left renders none. The guard and all three item renderers - the menu item, the
built-in Edit/Delete item and the inline `variant: primary` button - read the
SAME visibility rule (isBuiltinRowActionVisible / isCustomRowActionVisible via
planRowActionMenu), so the trigger and its contents cannot drift apart. The
capability gate was already folded into this guard for exactly this reason
(framework#3923); the per-record predicates were not.

Which items render is unchanged: the `visible: false` truthy gate is preserved
verbatim (objectui#3758), and the maxInlineActions slice still runs on the
declared primaries so a suppressed primary does not promote the next one into
its slot. The actions column is table-level and untouched, so a row with
nothing to offer renders an empty cell and every row keeps the same cell count.

Fixes objectui#3562

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
@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 12:31pm

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-CWwxr8jM.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.28KB 105.96KB
core (index.js) 2.96KB 1.13KB
create-plugin (index.js) 9.85KB 3.18KB
data-objectstack (index.js) 138.22KB 35.46KB
fields (index.js) 230.90KB 56.84KB
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) 26.14KB 6.07KB
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.09KB 42.72KB
plugin-dashboard (index.js) 117.06KB 30.24KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 232.79KB 57.42KB
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.71KB 49.68KB
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

@yinlianghui
yinlianghui marked this pull request as ready for review August 8, 2026 12:36
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 8, 2026
Merged via the queue into main with commit 1a33b1a Aug 8, 2026
19 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3562-rowactionmenu-guard branch August 8, 2026 12:36
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.

console: 平台内置「审批请求」(sys_approval_request) 行「更多操作」菜单展开后 0 个菜单项——渲染成 128×10 的空白方块

2 participants