Skip to content

toPredicateInput 对已经是 ${…} 模板的字符串二次包裹,动作面的谓词一律判成 true(visible 永远显示 / disabled 永远置灰) #3871

Description

@yinlianghui

来源

发现自 #3848 实施(ActionRunner.execute 的 disabled 执行门)的探针对照。#3848 的派发形状原本是「先过 toPredicateInput 归一,再据归一结果求值」,逐形状实测时发现该复合对已经是 ${…} 模板的字符串给出恒定 true,于是 #3848 的执行门改为「归一只用于判门、verdict 仍读原始值」,并把这条单独立单。不在 #3848 修法面(那单只碰 packages/core/src/actions/ActionRunner.ts),未认领

机理

packages/core/src/evaluator/predicateInput.tstoPredicateInput 对字符串无条件包裹:

if (typeof value === 'string') return `\${${value}}`;

它假定入参是裸表达式(record.done == true)。但 ${…} 模板也是本仓文档在册的拼法(AGENTS.md §4 的 hidden?: string; // expression: "${data.role != 'admin'}"),而 EvaluatorPredicateInput 这个类型自己就把「${…} 模板字符串」列为合法的归一输出。于是一个已经归一好的值被再包一层:

'${x}''${${x}}'

ExpressionEvaluator.evaluate 的单模板快路是 /^\$\{([^}]+)\}$/,[^}]+ 跨不过内层的 },匹配失败;落到全局 replace,内层表达式取到 '${x',evaluateExpression 抛错,catch 里 return match 原样退回 —— 最终结果是原字符串(非空),Boolean(非空字符串) = true

所以 evaluateCondition(toPredicateInput('${任意}')) 恒为 true,与表达式本身的取值无关(只留下一条 console.warn)。

实测(worktree @ origin/main = f0a625aa7b00c93a48329e1c456b1aeacfceba43,一次性探针,未提交)

context 为 { user: { role: 'admin' } },同一个谓词两条路:

disabled '${user.role === "admin"}' (取值 TRUE)  | evaluateCondition(原始值)=true  | evaluateCondition(toPredicateInput(...))=true
disabled '${user.role === "guest"}' (取值 FALSE) | evaluateCondition(原始值)=false | evaluateCondition(toPredicateInput(...))=true   ← 恒真
disabled 'user.role == "guest"'     (裸,FALSE)  | evaluateCondition(原始值)=false | evaluateCondition(toPredicateInput(...))=false  ← 裸表达式正常

即:裸表达式正常,${…} 拼法恒真

影响面(按 toPredicateInput 的调用点)

复合成 useCondition(toPredicateInput(x)) / evaluateCondition(toPredicateInput(x)) 的落点都中招:

  • packages/components/src/renderers/action/action-button.tsx(visible / disabled / enabled)
  • 同目录 action-icon.tsxaction-group.tsx(内联按钮 + 下拉项)、action-menu.tsxaction-bar.tsx
  • packages/app-shell/src/views/DeclaredActionsBar.tsx
  • packages/plugin-detail/src/renderers/record-quick-actions.tsxRelatedList.tsx
  • packages/core/src/actions/ActionEngine.ts:237(getActionsForLocationvisible 过滤)

用户可见后果按键分两向:visible: '${…}' → 恒真 → 永远显示(该藏不藏);disabled: '${…}' → 恒真 → 永远置灰(作者写什么都解不开)。ActionEngine 那处是 throwOnError: true + fail-closed,但双重包裹的失败发生在 evaluate 内部的 catch 里(return match),不抛,所以也是恒真=恒显示。

对照:另两条路对同一拼法是对的(且有绿钉子)

  • packages/react 的 SchemaRenderer 走原始值:packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx:135 钉住 disabled: '${data.status === "locked"}'status: 'active'不置灰
  • page:headerevalRowPredicate(packages/core/src/evaluator/listConditional.ts),legacy 拼法原样喂 evaluateCondition:packages/components/src/__tests__/page-header-predicate-dialect.test.tsx:257 钉住 ${…} 取假时隐藏

所以这是 #3314 那个形状的又一实例:同一个谓词值,动作面渲染器与通用渲染路径给出相反答案。动作面这一侧的 ${…} 用例一个都没有(#3842/#3849 的钉子用的是裸表达式 features.locked == true),所以缺陷一直没被 CI 看见。

修法建议(未裁)

生产端一处改完即全线收敛 —— 已经是归一形态的字符串不该再包一次:

if (typeof value === 'string') return value.includes('${') ? value : `\${${value}}`;

但请先裁:这会让上面所有落点对 ${…} 拼法从「恒真」变成「按取值判」,即 visible: '${取假}' 从「显示」变「隐藏」、disabled: '${取假}' 从「置灰」变「可点」—— 方向都是正确化,但是跨 4 个包的 verdict 变更面,且与 SchemaRenderer / page:header 收敛。另一条路是判定 ${…} 在动作谓词上off-spec(@objectstack/specPredicateInput 只有裸串与 dialect envelope),那就该在发布期校验里响亮拒绝,而不是留在消费端静默恒真。两条路都比现状好,取舍属契约裁决。

#3848 的执行门已按「归一只用于判门、verdict 读原始值」落地,并在钉子里留了指向本单的 tripwire(本单修好那天该 tripwire 会红,提示按本单更新)。

Related: #3848(执行门,本单发现出处)、#3850(「空谓词」范围的三处分歧)、#3862(SchemaRenderer 第三种拼法)、#3314(渲染路径与引擎路径判定分歧的先例)、#3521(page:header 的 legacy dialect 回退)。未认领。

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpm:queuetarget:v17v17 发布窗口工作集(GA 前排查 2026-08-04)

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions