Skip to content

feat(react): 未求值的 ${…} 到达 DOM 时给出开发期响亮诊断 (#4795 方向 3) - #5129

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4795-unevaluated-expr-diagnostic
Aug 18, 2026
Merged

feat(react): 未求值的 ${…} 到达 DOM 时给出开发期响亮诊断 (#4795 方向 3)#5129
yinlianghui merged 1 commit into
mainfrom
claude/issue-4795-unevaluated-expr-diagnostic

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Part of #4795 —— 维护者裁定(评论 5312061331)的方向 3,render-time 半边。

做了什么

SchemaRenderer 渲染路径上加一条仅开发构建的响亮诊断:当一个未求值的 ${…} 即将被原样交给 renderer / 展开进 DOM 时,console.error 点名 —— 节点 typeid、残留所在的键(按作者书写的通道拼写)、表达式原文,以及当下真正走得通的通道。

  • 不改变任何求值行为。诊断只读:不写 schema、不加 DOM 属性(所以快照不动)。
  • ⛔ 不实现方向 1(声明式文本键求值集),措辞也不预告它。
  • ⛔ 方向 2 按裁定永久否决,未触碰。
  • 生产零开销:整个模块挂在模块加载期的 NODE_ENV 常量之后,打包器直接折叠掉这个分支。

前提复测(基线 0046d8f8c,含 PR #5122)

卡面表格在新基线上逐行实测(真 renderer,dataSource: { total: 99, n: 99 }):

写法 实测渲染 诊断
{ type: 'ui:statistic', value: '${data.n}' } 字面量 ${data.n} 上屏 响 ✅
{ type: 'ui:card', title: '${data.total}' } 字面量上屏,并落成 DOM 属性 title="${data.total}" 响 ✅
{ type: 'ui:button', label: '${data.total}' } 字面量上屏 响 ✅
{ type: 'ui:statistic', props: { value: '${data.n}' } } 空白(求出了 99,落成 React prop,而 renderer 读 schema.value) 静默 —— 见下「不覆盖的那一类」
{ type: 'ui:statistic', properties: { value: '${data.n}' } } 99 静默 ✅
{ type: 'ui:text', content: 'Total: ${data.total}' } Total: 99 静默 ✅

成因判断复核通过:evaluation memo 处理 contentproperties.*props.* 与谓词键,title / label / value / description 原样透传(SchemaRenderer.tsx 求值 memo)。

⚠️ 请维护者过目:卡面标题那句话已被 #5122 改变

卡面写的是「content 外没有任何文本键既被求值又被读回」。在含 PR #5122 的新基线上这句已不再成立:写成 properties 信封时,value / title / label 三个键既被求值又被读回(实测均为 99)—— 因为 #5122properties.* 参与求值,而既有的 hoist 又把求值后的值抄到节点顶层,正好是 StatisticRenderer / CardTitle / Button 读的位置。

这条是裁定(2026-08-17)之后才由 #5122 造成的新事实,因此诊断文案里我没有写它:裁定原文把可用通道列为「content, or host pre-resolution」,而「properties 信封是不是 ui:* 的正式创作面」是契约/教学面判断(#4786 刚把 props 信封这个 workaround 从教学面撤掉),属维护者的裁量,不该由我在一条诊断消息里替仓库定下来。请裁:文案是否加上 properties

不覆盖的那一类(如实记录)

props 信封那一行(求值了但读不回 → 渲染成空白)诊断不响,而且按定义响不了:那里根本没有未求值的 ${…} 到达 DOM,值已经变成 99 了,只是落错了地方。裁定的方向 3 只认「未求值的 ${…} 到达 DOM」,所以这一类不在本 PR 的射程内 —— 它是卡面的另一半,随方向 1 / 契约裁量走。

设计取舍

挂点:放在 metadata 解构之后,扫 componentProps。那正是离开本组件去 DOM 的那组值(下面被展开成 React props,renderer 也从 schema. 加同名键读到同一批)。解构剥掉的全是 schema 元数据 —— visible / visibleWhen / hidden / disabled / … 按设计就装着原始谓词源码(它们被当条件求值,从不被放置)。所以剥离清单天然就是诊断的排除清单,不需要第二份会漂移的副本。这条不是注释里的说法,有 7 条钉盯着(见反向验证 (c))。

深度:与求值完全同深 —— 只扫一层。嵌套的 aria: { label: '${…}' } 今天保留原文是既定决定(#4799 在两个信封上都钉住了这个浅层性),再深一层就会把仓库尚未决定要改的形态报成缺陷,诊断就比它所代表的契约更吵。求值哪天变深,这个扫描跟着变深,不提前。

表达式的定义:复用求值器自己的 \$\{([^}]+)\} —— 少一个字符的 ${} 与未闭合的 ${ 对求值器都不是表达式(实测:原样返回),对诊断也就不是。这是把散文和代码示例挡在诊断之外的关键。

开销:每节点每渲染一次浅遍历 + includes('${'),仅 dev;命中后按 schema 对象 WeakSet 去重(与同文件 validateSchemaOnce 同一套路),重渲染不刷屏。

测试

packages/react/src/__tests__/SchemaRenderer.unevaluatedExpressionDiagnostic.test.tsx,25 钉:

假阳性普查:把诊断临时改成落盘记录(绕开测试里的 console mock),跑遍消费半径 —— react + components + fields + plugin-dashboard + plugin-grid + plugin-list,488 文件 / 5682 测试全绿,共 9 次触发,全部来自我自己的测试与探针文件,既有测试零假阳性

 Test Files  488 passed (488)
      Tests  5682 passed (5682)

仓根 type-check 81/81;eslint 改动文件 0 errors(23 条 no-explicit-any warning,与周边同款);check-control-bytes OK,另做了控制字节自扫。

反向验证(先书面预判,再跑;四条全部与预判一致,无反转)

变异 预判 实测
(a) 摘掉调用点 4 条 render 级钉红,9 条纯函数钉照绿 4 failed | 21 passed,正是那 4 条
(b) 把收集器 no-op 化 上述 4 条 + 2 条纯函数钉红(#5092 教训) 6 failed | 19 passed,正是那 6 条
(c) 把扫描挪到 metadata 解构之前 7 条谓词钉红(证明「剥离清单=排除清单」是承重的) 7 failed | 18 passed,正是那 7 条
(d) 正则放宽成 [^}]* 恰好 1 条(${} 那条)红 1 failed | 24 passed

留后续

publish-time 半边不做:侦查后自然挂点落在 CLI 校验器文件,#5115 席正在飞,按不撞文件纪律留给后续,在此如实记录。

方向 1 的重启条件在裁定评论 5312061331 第 2 条(第一个真实的 schema 编排仪表盘需求撞上这个缺口),本 PR 未预告、未实现。是否关卡留给 PM 验收时定 —— 卡面还载着方向 1 的重启条件。


Generated by Claude Code

…DOM (#4795)

A value only reaches the user if SchemaRenderer EVALUATES it and the renderer
READS IT BACK, and those two sets do not fully overlap. Where they miss, the
failure was silent: measured on this baseline with dataSource { n: 99 },
`{ type: 'ui:statistic', value: '$' + '{data.n}' }` puts the raw source text on
screen, because the evaluation memo covers `content`, the `properties` / `props`
bags and the predicate keys and passes every other top-level key through
untouched. An author -- increasingly, an AI authoring metadata -- got no signal
at all.

SchemaRenderer now reports such a value once per node via console.error, naming
the node type and id, the key the raw source survived on (spelled the way it was
authored), the expression source verbatim, and the channels that work today. It
also catches the harder shape: an expression that WAS evaluated but THREW, which
ExpressionEvaluator returns as its own source text.

Diagnostic only -- no evaluation behaviour changes, no DOM attribute is added,
and the module sits behind the module-load NODE_ENV constant a bundler folds
away, so production pays nothing.

Two boundaries are deliberate. The scan is exactly as deep as evaluation is --
shallow -- because a nested value keeps its raw source today by decision
(objectui#4799 pinned that on both bags). And schema METADATA is never reported:
the predicate keys hold raw source by design, and the scan reads the set of
values that actually leaves for the DOM, after the metadata destructure has
stripped them.

Part of objectui#4795 (Direction 3, per the maintainer's 2026-08-17 ruling).
Direction 1 stays deferred behind its named restart condition; this does not
pre-empt it. Direction 2 is permanently rejected and untouched.

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

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

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 25.13KB 5.40KB
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) 40.21KB 10.79KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.71KB 1.22KB
auth (invitation-status.js) 1.22KB 0.70KB
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) 5.02KB 0.88KB
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) 502.19KB 112.21KB
core (index.js) 4.11KB 1.62KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 159.03KB 44.08KB
fields (index.js) 234.25KB 58.48KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.84KB 1.45KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 39.16KB 10.97KB
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) 9.35KB 3.31KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.42KB 1.42KB
permissions (evaluator.js) 5.12KB 1.74KB
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.81KB 0.83KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.75KB 18.37KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 127.85KB 32.73KB
plugin-designer (index.js) 212.39KB 42.83KB
plugin-detail (index.js) 241.22KB 60.46KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 123.33KB 29.93KB
plugin-gantt (index.js) 164.10KB 39.87KB
plugin-grid (index.js) 197.97KB 53.15KB
plugin-kanban (index.js) 52.72KB 14.54KB
plugin-list (index.js) 111.17KB 26.99KB
plugin-map (index.js) 18.72KB 6.09KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.97KB 11.33KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 83.81KB 20.49KB
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.66KB 3.50KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 31.55KB 10.70KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.28KB 0.68KB
react (schema-input.js) 1.45KB 0.83KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (index.js) 4.77KB 2.16KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 10.76KB 3.17KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 6.92KB 2.40KB
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 (dashboard-filter-alias.js) 6.23KB 2.74KB
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) 3.05KB 1.52KB
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

PM 验收:ACCEPT(session_01GTRjn8xBqp75dk7kFupVRt,objectui 分片;Part of #4795 —— 方向 3 render-time 半边)

实物核验:merge-base 0046d8f8c(= PR #5122 合并点,基线要求内建满足);head 4160edaa2;4 files, +640 纯增;标识 grep msg/diff 双零,trailer 唯一正确;releases/components/CLI/docs 全零触碰(⛔ 边界被 diff 面直接证实);控制字节含 ESC 位零命中 ✅。CI 亲读:19/20 终态零失败(4 shards、Type Check、Lint、Build & E2E 全绿),唯一在跑的是 Live E2E(informational,非阻塞)✅。

验收要点:

  1. 挂点取舍是本单的结构贡献:诊断放在 metadata 解构之后 ⇒ 谓词键剥离清单天然就是排除清单,不需要第二份会漂移的副本 —— 变异 (c) 证明这条是承重的(挪到解构前 7 条谓词钉全红);(d) 证明「复用求值器自己的表达式定义」同样承重。这个「让既有机制兼任清单」的形记入案头。
  2. 假阳性普查 488 files / 5682 tests 零假阳性(9 次触发全来自席位自己的文件)—— 诊断类改动的上线证据标准,后续同类派发引用。
  3. 前提复测推翻卡面标题的半边并如实上交:PR fix(react): evaluate expressions under a node's properties, not just props (#4799) #5122 之后 properties 信封下 value/title/label 已「既求值又读回」—— 席位没有擅自把这条写进诊断文案(open question 1 采 A:维持裁定原文措辞),而是作为契约问题上交。PM 背书 A:「properties 信封算不算 ui:* 正式创作面」与 propsproperties 同现时,alias 优先级按「读法」相反 —— 配置袋读到 properties,React prop 读到 props #5123(props/properties 优先级分歧)同族,一并进决策箱裁;裁定后改文案是一行事。
  4. content 外没有任何文本键既被求值又被读回 —— statistic.value / card.title / button.label 无法绑定表达式 #4795 关卡处置:采席位建议 A,保持 open —— 卡现承载:方向 1 重启条件(裁定评论)+ properties 通道措辞待裁。PM 将把 content 外没有任何文本键既被求值又被读回 —— statistic.value / card.title / button.label 无法绑定表达式 #4795 标签转 needs-user-decision(pm:queue 摘除:方向 3 已落,无 dispatchable 工作)。
  5. publish-time 半边按不撞文件纪律留后续(objectui check 的 knownTypes 是手写的注册表副本,已与真实注册表漂移:crud 通过校验但无任何渲染器,运行时落 unknown-component 占位符 #5115 席当时在飞)—— 记入队列备忘,PR5128 落 main 后可另立小卡。
  6. finding(plugin-dashboard): the #3291 toDomProps whitelist stops at packages/fields — SDUI widgets elsewhere still close their DOM leak by hand, if at all #4425 追加实测样本(attach 纪律)、诊断模块不出公共面(最小暴露)—— 均正确。

三件套照常:本评论 → undraft → auto-merge(SQUASH)。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 18, 2026 03:42
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 18, 2026
Merged via the queue into main with commit 144ef9b Aug 18, 2026
20 of 21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4795-unevaluated-expr-diagnostic branch August 18, 2026 03:43
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.

2 participants