Skip to content

refactor(types,react,components,fields)!: converge the widget metadata carrier to field, retiring schema (#3233) - #3296

Merged
xuyushun441-sys merged 1 commit into
mainfrom
claude/issue-3233-field-single-carrier
Aug 3, 2026
Merged

refactor(types,react,components,fields)!: converge the widget metadata carrier to field, retiring schema (#3233)#3296
xuyushun441-sys merged 1 commit into
mainfrom
claude/issue-3233-field-single-carrier

Conversation

@xuyushun441-sys

Copy link
Copy Markdown
Contributor

Closes#3233

按 2026-08-03 维护者裁决走路线 B 直接落 v17:生产者侧收敛到 field,从 widget 契约删除 schema,删掉全部消费端 field || schema 容忍。不做弃用期。

前提核对(实施前)

前提结论
FieldWidgetComponentPropsschema 仍是已声明键✅ 成立(packages/fields/src/widgets/types.ts#3221/PR #3230 封口的那版)
field || schema 读法仍存在✅ 成立,32 处(issue 估「约 25 处」;主流写法是 field || (props as any).schema,只有 4 处写成字面 field || schema,所以按字面量 grep 会严重低估)

两条生产者路径

1. 表单路径 —— renderFieldComponentpackages/components/src/renderers/form/form.tsx

原状 schema={props.field || props.schema || props}field同时下发。唯一调用点无条件设 field: field.field || field(永远真值),所以链上后两项不可达,schema=== props.field。改为只传 field

但这里有一个 issue 未提及、且必须区分的事实(本单最大的一个坑,被 form-write-error-message.test.tsx 抓了个正着):renderFieldComponent 的注册表查找会命中两类不同的组件——

  • field:<type> 命中 → field widget,契约是 FieldWidgetComponentProps,载体 field
  • 裸名兜底命中 → 普通 SDUI 组件(展示型 textalertbadge …),契约是 SchemaRenderer 给每个注册组件的通用 schema 节点。这个键不在本次退役范围内

无差别删掉 schema 会让后一类渲染成 undefined.className 直接崩。所以查找被拆成两步并显式判定 isFieldWidget,只在 field widget 分支省略 schema,兜底分支逐字保留原解析。这条区分本身有专门测试钉住(见下)。

2. SDUI 路径 —— 注册适配器(packages/fields/src/withFieldCarrier.tsx,新增)

SchemaRenderer没有 field-widget 专用分支:它给所有注册组件统一传 schema。而 registerField() 是把 widget 裸注册field:<type>(及裸名),所以 SDUI 节点走到 widget 时只有 schema 没有 field —— 这才是那 32 处 field || schemaschema 分支的真实来源。

翻译点收在唯一的接缝上:withFieldCarrierregisterField() / capability-multiselect 全部经它注册。它按引用转发节点(不拷贝、不裁剪、不改名),并吃掉schema(契约里没有这个键了,继续转发只会随 widget 的 ...props 展开变成 DOM 上的野属性)。

withFieldCarrier已导出为公共 API —— 这是仓外 widget 作者的迁移工具:裸注册的第三方 widget 在 SDUI 路径上拿不到 field,若不给它这个适配器,它们只能自己重新长出一个 (props as any).schema 读法,等于把刚删掉的容忍原样搬到仓外。⚠️ 这是本 PR 唯一的 API 面新增,若维护者认为超出授权范围,可以只回退这一处导出(内部仍需要该函数)。

载荷等价性证明

不变量:过去经 schema 送达的每条载荷,必须以 field 送达完全相同的对象。两条路径各有一个按对象同一性(不是结构相等)断言的测试 —— 结构相等在渲染器改为下发重建副本时依然会绿,而副本会打断按 metadata 引用做记忆化的 widget。

路径文件证明方式
表单packages/components/src/renderers/form/__tests__/form-field-carrier.test.tsx探针 widget 断言 props.fieldtoBe 作者写下的那个 metadata 对象(含 .field 已声明槽与无 .field 的内联 config 两种形态)
SDUIpackages/fields/src/__tests__/field-carrier-sdui.test.tsx一个 spy 坐在注册表条目的位置,记录 SchemaRenderer 实际传下来的 schema 对象,再转发给被测适配器;断言 widget 的 fieldtoBe 那个对象(不能对作者字面量断言 —— SchemaRenderer 求值时会浅拷贝节点)

另外钉住的:

  • 两条路径都断言 'schema' in props === false键缺失,不是 undefined —— 否则 field || schema 在实践中依然可解,类型说没了而运行时还在);
  • 表单字段自带 schema: 键也无法把第二载体从元数据侧复活;
  • 裸名兜底分支仍然收到 schema(上面那条区分的反向断言);
  • registerField()真的过了适配器 —— 用真 SchemaRenderer + 真 BooleanField 端到端跑:节点上的 widget: 'checkbox' / name 只能经载体到达,能选中 Checkbox 而非 Switch 且 id 正确,就证明节点完整落到了 field
  • props.schema 现在是编译错误(widget-props-contract.test.tsx / TextAreaField.mobileFullscreen.test.tsx 各加一条 @ts-expect-error)。

每条新增/改动的测试都做了 sabotage 验证(改坏源码或断言 → 看它红 → 还原 → 看它绿),共 6 次:还原旧的双传(2 条红)、把 field 改成下发副本(2 条红)、适配器改为转发 schema(1 条红)、适配器改为下发副本(1 条红)、registerField 跳过适配器(1 条红,症状正是「渲染成 Switch,找不到 checkbox」——即本次收敛被指控的静默失效)、isFieldWidget 恒真(1 条红)。

迁移(面向 widget 作者,宿主元数据不受影响)

SDUI JSON / 对象元数据零改动。变的只是 widget 的编写契约

-const config = field || (props as any).schema;+const config = field;
+import { withFieldCarrier } from '@object-ui/fields';+-ComponentRegistry.register('color', ColorField, { namespace: 'field' });+ComponentRegistry.register('color', withFieldCarrier(ColorField), { namespace: 'field' });

仓外 widget 若继续读 props.schema 且未经适配器重新注册,在 v17 会读到 undefined 并静默渲染空态 —— 该风险维护者已知并接受,缓解是大版本边界 + 变更集里的高声量迁移说明。

变更集

.changeset/field-widget-single-metadata-carrier.md,含完整迁移说明。

⚠️minor 而非 major,尽管这是破坏性变更:AGENTS.md「版本号策略」明令 objectui 的 changeset 不得声明 major(fixed 组任一 major 会把 39 个包整体推上 18,脱离 @objectstack 的节奏),破坏性语义写在正文里。这一条由 scripts/check-changeset-no-major.mjs + changeset-guard.yml 机械强制;本地两个 guard 均已跑过通过。

文档

content/docs/guide/plugin-development.mdskills/objectui/guides/plugin-development.md:删掉 schema 作为已声明 pass-through 键的说法,新增「field 是唯一载体(v17,breaking)」小节 + withFieldCarrier 注册示例。未触碰 content/docs/releases/

验证

结果
vitest run packages/fields packages/components111 files / 1052 tests passed
vitest run packages/plugin-form packages/plugin-grid packages/plugin-detail packages/react151 files / 1489 tests passed
vitest run packages/app-shell packages/plugin-designer packages/sdui-parser packages/plugin-report279 files / 2404 passed, 1 skipped
vitest run packages/core packages/layout packages/mobile packages/plugin-dashboard packages/plugin-list packages/plugin-kanban packages/plugin-calendar packages/providers packages/runner packages/i18n143 files / 2137 passed, 24 skipped
turbo run type-check(fields / components / react / plugin-form / app-shell)33 tasks successful
turbo run lint(fields / components / react)0 errors(warning 数与基线一致)
turbo run build(fields / components)10 tasks successful
changeset guards两个都通过

测试一律从仓根用 pnpm exec vitest run <paths> 跑(#3288pnpm --filter <pkg> test -- --run <paths> 会静默忽略路径过滤)。

范围外发现

未触碰 #3291toDomProps)与 #3290aria-required)。


Generated by Claude Code

…a carrier to `field`, retiring `schema` (#3233)
`schema` was a second carrier for what `field` already means. Two producers fed
it — `SchemaRenderer` passed the authored node as `schema`, and the form
renderer's `renderFieldComponent` passed `schema={props.field || props.schema ||
props}` alongside `field` — so ~30 widgets resolved their config as
`field || schema`: one concept, two spellings, a de-facto second contract
(AGENTS.md #0.1).
Producer-side convergence, per the 2026-08-03 maintainer ruling (route B, landed
in v17 rather than behind a deprecation window):
- `FieldWidgetComponentProps` no longer declares `schema`; reading `props.schema`
is a compile error.
- The form renderer passes `field` only when the resolved entry is a `field:`
widget. It still passes `schema` when the bare-name fallback answers with a
plain SDUI component (the display `text` widget, `alert`, …) — that key is the
universal node contract and is NOT retired.
- The SDUI node → `field` translation happens exactly once, in a new
`withFieldCarrier` adapter that every built-in registration goes through. It is
exported so out-of-repo widgets can reach the same guarantee instead of
re-growing a `field || schema` read.
- All `field || schema` reads in `@object-ui/fields` become plain `field` reads.
Payload equivalence is pinned by object identity on both paths:
`form-field-carrier.test.tsx` (form) and `field-carrier-sdui.test.tsx` (SDUI).
Closes#3233
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa
@vercel

vercelBot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 3, 2026 12:53pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.1 KB350 KB
Entry fileindex-O4vRNoL3.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.47KB3.09KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)18.38KB4.49KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)3.65KB1.42KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.25KB0.53KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)476.59KB104.67KB
core (index.js)2.25KB0.80KB
create-plugin (index.js)9.28KB2.98KB
data-objectstack (index.js)136.23KB34.75KB
fields (index.js)223.38KB54.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.46KB0.96KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)5.37KB1.72KB
i18n (useObjectLabel.js)26.14KB6.07KB
i18n (useSafeTranslation.js)3.26KB1.44KB
layout (index.js)37.96KB10.54KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.05KB1.53KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.98KB12.37KB
plugin-charts (index.js)60.54KB17.13KB
plugin-chatbot (index.js)180.09KB42.72KB
plugin-dashboard (index.js)112.01KB28.86KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)230.56KB56.80KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)111.49KB26.95KB
plugin-gantt (index.js)162.25KB39.55KB
plugin-grid (index.js)185.08KB49.04KB
plugin-kanban (index.js)47.89KB13.18KB
plugin-list (index.js)104.94KB25.32KB
plugin-map (index.js)16.81KB5.24KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.55KB10.59KB
plugin-timeline (index.js)25.76KB7.33KB
plugin-tree (index.js)8.34KB2.82KB
plugin-view (index.js)83.67KB20.43KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)19.28KB6.38KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)2.46KB1.21KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)0.20KB0.18KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for freeto 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.

fieldschema 是同一个意思的两个载体,~25 个 widget 读 field || schema

2 participants

@xuyushun441-sys@claude