Skip to content

fix(fields): 选项 widget 渲染宿主计算的 emptyHint,兜底文案走 i18n (#3231) - #3262

Merged
xuyushun441-sys merged 1 commit into
mainfrom
claude/issue-3231-empty-hint-single-source
Aug 3, 2026
Merged

fix(fields): 选项 widget 渲染宿主计算的 emptyHint,兜底文案走 i18n (#3231)#3262
xuyushun441-sys merged 1 commit into
mainfrom
claude/issue-3231-empty-hint-single-source

Conversation

@xuyushun441-sys

Copy link
Copy Markdown
Contributor

Fixes #3231

按 PM 在认领评论里的裁定走 A:widget 优先使用宿主传来的 emptyHint,自有文案退化为已翻译的兜底。

复核时发现:断链不止一处

原单认为「宿主算出的提示被 widget 丢弃」。实测下来,emptyHint 在到达 widget 之前就已经断了两次 —— 只修 widget 那一处,线上用户看不到任何变化。三处全部修掉:

  1. isOptionField 用未归一化的类型做比较(form.tsx)。

    resolvedType === 'select' || resolvedType === 'radio' || …
    

    对象派生的表单走 mapFieldTypeToFormType,发出的是带前缀的 id(field:select),一个都匹配不上 —— 于是整块 cascade 逻辑被跳过,console 里所有来自对象 schema 的选项字段压根没算过 hint。现改为 CASCADE_OPTION_FIELD_TYPES.has(normalizeFieldType(resolvedType)),与下方 stripRegisteredFieldProps 已在用的归一化保持一致 —— 同一个文件里两处对「什么是 select」的判定必须一致。

  2. stripRegisteredFieldPropsemptyHint 键剥掉了。所以即便是那些确实算出了 hint 的裸类型表单(type: 'radio'),送到已注册 widget 手里的也是空。现在与 dependentValues 一样,forward 给四个 cascade 选项类型。

    这里保持白名单而非「干脆别剥」:其余已注册 widget 会把剩余 props 铺到 DOM 节点上,未知的 emptyHint 属性是一条 React warning —— 这也正是当初剥它的原因,剥本身是正当的。

  3. 四个 widget 解构后丢弃。解构是对的(不解构会随 ...props 上 DOM),丢弃不是。

i18n 不是附带项

四份内联的空状态收敛到一个 OptionsEmptyState:宿主给了就用宿主的,没给才用自己的翻译文案(fields.options.empty / fields.options.selectFirst,十个语言包全部补齐)。门控那句话与表单渲染器共用同一个 i18n key —— 渲染器插入字段 label,独立 widget 插入原始字段名,但句式只有一份,两边在同一 locale 下不可能说出两种话。

用户可见的变化

  • 依赖门控的选项列表现在提示控制字段的标签(「Select Country first」)而不是原始元数据名,且跟随会话语言。
  • 未配置选项的列表也用会话语言说明。
  • I18nProvider 时(独立/嵌入用法)仍回落到 createSafeTranslation 的英文默认值,不会退化成裸 key。

未改动

form.tsx:1824 的内建(未注册)select 分支 —— 它本来就在消费 emptyHint,是另一条独立活路径,按 scope fence 不做统一。

测试

四个 widget 全部覆盖(它们是四份独立复制品,只测一个证明不了另外三个):

  • packages/fields/src/widgets/OptionsEmptyState.test.tsx —— 宿主给了 hint ⇒ 渲染宿主的(门控/未配置两种空态,以及 zh locale 下宿主仍然优先);没给 ⇒ 渲染已翻译的兜底(zh:请先选择country / 暂无可选项),并断言旧的英文字面量不再出现。
  • packages/fields/src/widgets/OptionsEmptyState.no-provider.test.tsx —— 无 provider 时的英文兜底。单独一个文件是必需的:挂载 I18nProvider 会通过 initReactI18next 把该实例装成 react-i18next 的全局默认,同一模块图里一旦挂过就再也观察不到「无 provider」状态。
  • packages/components/src/renderers/form/__tests__/form-empty-hint-delivery.test.tsx —— 生产者侧:field:select/field:radio/field:multiselect/field:checkboxes(console 实际走的前缀 id)都收得到;裸 radio/multiselect/checkboxes 也收得到;门控解除后 hint 撤回;非选项类 widget 仍然收不到这个键(白名单方向的反向断言)。

变异验证(逐条回退实现,证明测试确实会红):

回退 结果
widget 忽略 emptyHint + 恢复英文字面量 fields 20 failed / 8 passed
stripRegisteredFieldProps 不再 forward delivery 8 failed / 1 passed
isOptionField 回到未归一化比较 delivery 5 failed / 4 passed(恰好是四个 field: 前缀用例 + 撤回用例)

回归面:packages/i18n + packages/fields 674 passed;packages/components 505 passed;packages/plugin-form + packages/plugin-grid 733 passed;packages/app-shell 2203 passed / 1 skipped。type-check 三包全过,lint 0 error。

🤖 Generated with Claude Code

https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa


Generated by Claude Code

…lated (#3231)

`emptyHint` was declared on `FieldWidgetComponentProps`, computed by the form
renderer for a dependency-gated option list (#2284) and transported — then lost
three times over, so no registered widget could ever render it:

1. `isOptionField` compared the raw resolved type against `'select'` /
   `'radio'` / `'multiselect'` / `'checkboxes'`. Object-derived forms emit
   `mapFieldTypeToFormType`'s prefixed ids (`field:select`), which matched none
   of them, so the whole cascade block was skipped and no hint was computed at
   all for the normal console path. It now normalizes the `field:` prefix — the
   same normalization `stripRegisteredFieldProps` already applied below it.
2. `stripRegisteredFieldProps` removed the `emptyHint` key from what was left.
   It is now forwarded to the four cascade option types alongside
   `dependentValues` — an allow-list, because every other registered widget
   spreads leftover props onto a DOM node.
3. `SelectField` / `MultiSelectField` / `RadioField` / `CheckboxesField` each
   destructured it into `_emptyHint`, dropped it, and rendered a hardcoded
   English literal instead.

The four inline copies of the empty state converge on one `OptionsEmptyState`:
host hint when supplied, otherwise a TRANSLATED fallback (`fields.options.empty`
/ `fields.options.selectFirst`, added to all ten locale packs). The gate
sentence is one i18n key shared with the renderer, so the two sides cannot word
it differently.

The built-in (unregistered) `select` branch, which already consumed
`emptyHint`, is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa
@vercel

vercel Bot commented Aug 3, 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 3, 2026 8:20am

Request Review

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

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

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.47KB 3.09KB
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.12KB 3.41KB
auth (LoginForm.js) 17.86KB 5.29KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.43KB 2.09KB
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) 18.38KB 4.49KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 3.65KB 1.42KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.25KB 0.53KB
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) 476.24KB 104.56KB
core (index.js) 2.25KB 0.80KB
create-plugin (index.js) 9.28KB 2.98KB
data-objectstack (index.js) 136.23KB 34.75KB
fields (index.js) 223.53KB 54.78KB
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.46KB 0.96KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 5.37KB 1.72KB
i18n (useObjectLabel.js) 26.14KB 6.07KB
i18n (useSafeTranslation.js) 3.26KB 1.44KB
layout (index.js) 37.93KB 10.53KB
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.05KB 1.53KB
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) 60.54KB 17.13KB
plugin-chatbot (index.js) 180.09KB 42.72KB
plugin-dashboard (index.js) 112.01KB 28.86KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 230.54KB 56.80KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 111.49KB 26.95KB
plugin-gantt (index.js) 162.25KB 39.55KB
plugin-grid (index.js) 185.08KB 49.04KB
plugin-kanban (index.js) 47.89KB 13.18KB
plugin-list (index.js) 104.94KB 25.32KB
plugin-map (index.js) 16.81KB 5.24KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.55KB 10.59KB
plugin-timeline (index.js) 25.76KB 7.33KB
plugin-tree (index.js) 8.34KB 2.82KB
plugin-view (index.js) 83.67KB 20.43KB
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.46KB 1.21KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 0.20KB 0.18KB
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

@xuyushun441-sys
xuyushun441-sys marked this pull request as ready for review August 3, 2026 08:34
@xuyushun441-sys
xuyushun441-sys added this pull request to the merge queue Aug 3, 2026
Merged via the queue into main with commit 825bbe3 Aug 3, 2026
17 checks passed
@xuyushun441-sys
xuyushun441-sys deleted the claude/issue-3231-empty-hint-single-source branch August 3, 2026 08:49
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.

表单渲染器计算的 emptyHint 被每个选项 widget 丢弃,用户看到的是硬编码英文

2 participants