Skip to content

fix(app-shell,components): 动作 disabled 的「已声明」判定补上 !== '' 半边,disabled: '' 不再永久置灰 (#3842) - #3851

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3842-disabled-declared-gate
Aug 8, 2026
Merged

fix(app-shell,components): 动作 disabled 的「已声明」判定补上 !== '' 半边,disabled: '' 不再永久置灰 (#3842)#3851
yinlianghui merged 1 commit into
mainfrom
claude/issue-3842-disabled-declared-gate

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3842

disabled 的「是否声明了门」判定停在 != null,少了 visible 一族已收敛的 hasDeclaredVisibilityGate(!= null && !== '')的另一半。两处落点改读同一处定义。

机理:为什么这半边是缺陷,而 visible 那半边不是

disabled: '' 被判成「声明了门」,verdict 交给求值入口 —— 求值入口把空谓词读成「没有条件 → true」(toPredicateInput('')undefinedevaluateCondition(undefined)true)。

方向是全部:在 visible 上这个 true 意味着「显示」,于是过宽的「已声明」判定与宽松的空谓词互相抵消,visible: '' 两种写法都渲染(这就是 PR #3843 只能把 '' 当文档、当不了变异探测器的原因,那边注释里写明了)。在 disabled 上同一个 true 意味着「禁用」,两个错误叠加:空谓词从「没有门」变成「永久置灰」。同一个空谓词,两个 key 得到相反待遇。

两处落点前后对照

packages/app-shell/src/views/DeclaredActionsBar.tsx:212 一带(最烫的一处:动作是服务端声明的 objectDef.actions[],宿主是审批收件箱记录区):

- disabled={((action as any).disabled != null ? isDisabledPred : false) || loading}
+ disabled={(hasDeclaredVisibilityGate((action as any).disabled) ? isDisabledPred : false) || loading}

packages/components/src/renderers/action/action-button.tsx:164(两条腿):

- (schema as any).disabled != null
-   ? isDisabled
-   : schema.enabled != null
+ hasDeclaredVisibilityGate((schema as any).disabled)
+   ? isDisabled
+   : hasDeclaredVisibilityGate(schema.enabled)
      ? !isEnabled
      : false

命名按 #3842 的派发裁定沿用历史名(谓词与 key 无关,同一实现两个名字即轻度方言),两处各就地注释说明。

action:button 同形实证(改前先探针,不按拼法推断)

issue 明示 action-button.tsx:164 未单独实证。先写 pin 测试、在未改动的源码上跑,得到的红正是同形证据:

× an empty-string `disabled` is not a declared gate — the button stays clickable
  Error: expect(element).not.toBeDisabled()
  Received element is disabled:  (button ... disabled="" type="button")
× an empty `disabled` falls THROUGH to the legacy `enabled` leg
Tests  2 failed | 9 passed (11)

同一次运行里,disabled: true / false / 未声明、以及 enabled 四形状全绿 —— 坏的只是「已声明」的判定,不是求值。

enabled legacy 腿:按推演行为等价,不是修复

该腿取反(disabled = !isEnabled),所以空谓词的 true 落成「不置灰」,与「没有门」同一结果。四形状在两种判定下逐一相同:

enabled != null(旧) hasDeclaredVisibilityGate(新)
'' 有门 → !true = 不置灰 没门 → false = 不置灰
true 有门 → !true = 不置灰 有门 → !true = 不置灰
false 有门 → !false = 置灰 有门 → !false = 置灰
未声明 没门 → false 没门 → false

所以没有任何测试能靠还原这条腿变红 —— 这一点直说,不包装成覆盖率。改它只为「已声明」在两条腿上只有一种拼法。四个 enabled 钉子留着,钉的是推演所断言的语义(enabled: false 必须仍然置灰),那才是将来重写这段链条会静默弄坏的东西。

真正被这次收紧移动的是穿透一例:disabled: '' 不再是门之后,链条落到 legacy 腿而不是在空谓词上短路,所以 disabled: '' + enabled: true 从置灰变可点(改前红、改后绿,见上)。

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

方向 A —— 两处落点还原 disabled 腿的 != null。预判:红,共 4 例(每包 2 例)。'' 在本侧真的变异探测器,与 visible 侧相反(#3843 的 M2 记账说明了为什么)。实跑:

× DeclaredActionsBar ... an empty-string `disabled` is not a declared gate — Approve stays clickable
× DeclaredActionsBar ... an empty-string `disabled` leaves an Approve that actually DISPATCHES
× action:button ... an empty-string `disabled` is not a declared gate — the button stays clickable
× action:button ... an empty `disabled` falls THROUGH to the legacy `enabled` leg
Tests  4 failed | 33 passed (37)

预判命中:正好这 4 例,无第五例。

方向 B —— 只还原 enabled 腿的 != null。预判:零红(上表的推演结论)。实跑 Tests 11 passed (11)。推演由实测确认,而不是只被断言。

行为变化面(刻意窄)+ 一处诚实披露

只有 disabled: '' 改变:从置灰变可点,即「没有谓词」本来的含义。disabled: true 仍置灰,disabled: false 与未声明仍不置灰,任何表达式取值的 disabled 判定不变。

披露:实测 ActionRunner.execute 的执行门(ActionRunner.ts:666,disabled != null && disabled !== false)对空谓词同样判「已禁用」,handler 一次不跑、回 {"success":false,"error":"Action is disabled"}。所以本 PR 之后,disabled: '' 的动作按钮能点了,点下去收到 Action is disabled 报错。比原来「永久置灰、无任何解释」是改善,但那半边不在 #3842 的范围内(PM 已裁范围为两处渲染落点),已另立 #3848,不在此扩范围。

顺手发现(均已独立成单、未认领,不在本 PR)

验证

固件扫面:全仓 grep 无任何既有 fixture 拼 disabled: '' / enabled: '',所以没有 fixture 被这次收紧重新判决(无需重拼 / 补声明 / 整例替换)。

# 涉及面(components 动作面 + app-shell views + 谓词归一 parity)
npx vitest run --maxWorkers=2 packages/components/src/renderers/action/ \
  packages/components/src/__tests__/action-bar.test.tsx \
  packages/components/src/__tests__/action-group.test.tsx \
  packages/components/src/__tests__/page-header-actions.test.tsx \
  packages/app-shell/src/views/__tests__/ \
  packages/react/src/hooks/__tests__/actionPredicate.parity.test.tsx
→ Test Files 18 passed (18) | Tests 195 passed (195)

# 末次编辑后按 CI 同命令全仓复跑(PR #3841 教训:vitest 的 esbuild 掩盖类型红)
npx turbo run type-check --concurrency=2
→ Tasks: 78 successful, 78 total

npx eslint (四个改动文件) → 0 errors(仅既有 no-explicit-any warning)
pnpm run check:control-bytes → OK(scanned 3758 tracked text files);另做 grep -naP 控制字节自扫,零命中

新增 11 例(packages/components/.../__tests__/action-disabled-declared-gate.test.tsx)+ 6 例(DeclaredActionsBar.test.tsx,沿用 PR #3843 收紧后的 importOriginal 偏 mock 桩形态:求值入口是真的,hasDeclaredVisibilityGate 从真源模块取)。DeclaredActionsBar 侧多钉一例「空谓词的按钮真的能 DISPATCH」—— 「没有 disabled 属性」只是这个论断的一半,而这个组件的点击处理器就是发出 approve 调用的那个。

Changeset:.changeset/action-declared-disabled-gate-3842.md(@object-ui/app-shell + @object-ui/components patch,写明 disabled: '' 从永久置灰变可点、以及穿透到 legacy enabled 腿这一连带后果)。


Generated by Claude Code

… '' 不再永久置灰 (#3842)

`disabled` 的「是否声明了门」停在 `!= null`,少了 `visible` 一族已收敛的
`hasDeclaredVisibilityGate`(`!= null && !== ''`)的另一半。于是 `disabled: ''`
被判成「声明了门」,verdict 交给求值入口 —— 而求值入口把空谓词读成
「没有条件 → true」(`toPredicateInput('')` → `undefined` →
`evaluateCondition(undefined)` → `true`)。

方向是这半边成为缺陷、而 `visible` 那半边不是的原因:在 `visible` 上这个 `true`
意味着「显示」,过宽的「已声明」判定与宽松的空谓词互相抵消,`visible: ''` 两种写法
都渲染;在 `disabled` 上同一个 `true` 意味着「禁用」,两个错误叠加 —— 空谓词从
「没有门」变成「永久置灰」。同一个空谓词,两个 key 得到相反待遇。

两处落点改读同一处定义:

- `app-shell` 的 `DeclaredActionsBar`:最烫的一处。动作是服务端声明的
  (`objectDef.actions[]`),宿主是审批收件箱记录区,元数据里一个 `disabled: ''`
  (授权表单留空、模板求值成空串)渲染出谁都点不动的 Approve / Reject,且无法与
  「元数据本意」区分。#3835 是同一surface 反方向的失败。
- `components` 的 `action:button`:改前先自行探针实证同形(issue 只按拼法逐字相同
  推断,未单独实证):`disabled: ''` 时渲染出的按钮带 `disabled=""`,pin 测试红。

行为变化面刻意窄:只有 `disabled: ''` 改变 —— 从置灰变可点,即「没有谓词」本来的
含义。`disabled: true` 仍置灰,`disabled: false` 与未声明仍不置灰,任何表达式取值的
`disabled` 判定不变。一处连带后果写明:`action:button` 上空的 `disabled` 现在会
**穿透**到 legacy 的 `enabled` 回退腿,而不再在空谓词上短路,所以同时写了
`disabled: '' + enabled: true` 的动作变为可点。

`action:button` 的 legacy `enabled` 腿也改读同一定义,但那部分是按推演
**行为等价**、不是修复:该腿取反(`disabled = !isEnabled`),空谓词的 `true` 本来
就落成「不置灰」,与「没有门」同一结果;四形状在两种判定下逐一相同。推演表与
「为什么没有任何测试能区分这条腿的改动」一并写在 pin 旁边。

`hasDeclaredVisibilityGate` 在两处沿用历史名(#3842 派发裁定):谓词与 key 无关,
同一实现两个名字即轻度方言;两处落点各就地注释说明。
@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 8:43pm

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-C--YVudt.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) 482.41KB 106.35KB
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) 236.14KB 58.81KB
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。

核验:功能 diff 恰 3 行(两落点 + enabled 腿统一到 hasDeclaredVisibilityGate),命名按裁定复用带就地注释;'' 在 disabled 侧变异探测器的机理(与 visible 侧相消 vs 相加)写进门注释与钉子 docblock。证据纪律为本族标杆:action-button 同形先探针实证后改(issue 仅推断);enabled 腿如实报为「行为保持的一致化推导,无测试可区分」而非包装成修复 —— 唯一移动的 precedence 边缘(disabled:'' + enabled:true)单独钉红转绿;反向 B 预判零红实测零红,无信号方向如实上报;一条自我怀疑(空白串)被探针证伪并如实记录。17 新钉;19/19 CI 零失败;changeset 写明行为面与 fall-through 后果。

衍生三单:#3848(ActionRunner 与渲染器对空谓词判定分歧,点击被 runner 挡 —— 端到端另一半)、#3849(同族门位还剩 5 处,issue 只点了 7 中之 2 —— PR3851 落 main 后即派候选)、#3850(「空谓词」在三个定义里三种外延,契约裁决类,两轴分析在案)—— 立单边界合规,#3850 归分诊席判级。


Generated by Claude Code

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.

动作 disabled 的「已声明」判定用 != null,disabled: '' 把按钮永久置灰(#3492 同族的另一半 predicate,探针实证)

2 participants