Skip to content

fix(fields)!: FieldWidgetComponentProps 不再声称拥有全部键 (#3221) - #3230

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-3221-widget-props-index-signature
Aug 2, 2026
Merged

fix(fields)!: FieldWidgetComponentProps 不再声称拥有全部键 (#3221)#3230
os-zhuang merged 1 commit into
mainfrom
claude/issue-3221-widget-props-index-signature

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#3221

packages/fields/src/widgets/types.ts 里的 [key: string]: any 是 objectstack#4075 的机制:一个声称拥有全部键的类型,永远不可能被报告「缺了某个键」。本 PR 把它换成一个从真实调用点推导出来的封闭类型。

三个后果,逐条关掉

现象之前现在
props.required / props.error(spec 的 FieldWidgetPropsSchema 声明、本地没有)合法读取,类型 any,运行时恒 undefined编译错误
拼错 prop(readOnly vs readonlyonchange)编译通过、静默失效编译错误
对该类型做结构/parity 比较原理上无效,批次 7 的符号守卫是唯一探测器可比较了

新类型怎么定的:先读调用点,再写类型

Issue 给了两个候选。& React.HTMLAttributes< HTMLElement > 被排除,原因是实测的:它声明的 onChange?: FormEventHandler 与本契约的 onChange: (val: T) => void 在交集下冲突(strictFunctionTypes 逆变,赋值不成立),且不覆盖 data-*,更不覆盖 schema / dataSource / dependentValues 这些真正在传的键。

于是走「显式声明」。候选集不是猜的:先只删索引签名、跑 tsc,让编译器把真实缺口列出来,再逐条判断该不该存在。分三组:

  • 受控输入契约(原有):value / onChange / field / readonly / disabled / className / errorMessage / onUploadingChange
  • 宿主管道(表单渲染器 renderFieldComponent 与内联编辑宿主确实转发的):schemadataSourcedependentValuesdependsOnemptyHintcompactonSelectRecordonCreateNew
  • DOM 透传:idnameautoFocustabIndexonBlur/onFocus/onClick、全部 aria-*(React.AriaAttributes,闭集),以及 data-*

data-*模板字面量索引签名而不是 [key: string]。这是关键区别:模板键让 keyof 保持有限,所以 props['data-testid'] 合法、props.required 依然报错 —— 开放的只是 HTML 本来就开放的那一族。

爆炸半径实测很小:全仓 turbo run type-check 78/78 绿,plugin-detail 内联编辑器、plugin-grid 单元格编辑器、app-shell 的 metadata inspectors、表单渲染器全部零改动通过 —— 因为封闭集就是从它们身上推出来的。

顺带:让读取侧也说实话

约 20 处 (props as any).x 改为直接读类型(compactdataSourcedisablednameidonCreateNewonSelectRecordcontextRecorddependentValues)。留着它们等于保留了「拼错也能编译」那一半缺陷,而且恰恰留在最要命的位置上。

未动的是 (field || (props as any).schema) 这类元数据配置读取(约 25 处)—— 它们后面紧跟 as any,收紧收益低、风险高,且属于 field/schema 双载体问题,不在本 issue 范围内。

批次 7 的三条钉扎

_IndexSignatureStillThere / _RequiredSilentlyReadsAsAny / _ErrorSilentlyReadsAsAny 就是为本次改动写的、会在删除索引签名时报红的钉扎(#3224 已合并)。它们按预期删除了 —— 但没有留空,而是换成反向断言(类型现在是封闭的、两个键确实缺失),再加一条 data-* 仍开放的钉扎。这样把类型重新放宽回 [key: string]失败一个测试,而不是悄悄通过。

新增 packages/fields/src/__tests__/widget-props-contract.test.tsx:用 @ts-expect-error 钉住四类拒绝(requirederrorreadOnlyonchange),用一个正向字面量钉住每个透传键仍被接受(过度收紧会报红),再用两个渲染断言证明 aria-* / data-* / disabled 真的还能到达控件 —— 封闭类型不能是靠丢掉真实行为换来的。

范围边界:没有碰 #3222

没有errorMessage 改名为 error,没有新增 required。本 PR 只让类型说实话,好让 #3222 变成编译器可判定的问题。

按要求汇报:移除索引签名后,error/errorMessage 的分歧没有在任何地方产生类型错误(全仓 typecheck 全绿)。查证下来原因比「命名分歧」更尖锐,这条直接关系到 #3222 的选型,已同步评论到该 issue:

全仓没有任何宿主向 field widget 传 errorMessage 表单渲染器用独立的 < FormMessage /> 渲染校验信息,从不转发这个 prop。读它的 7 个 widget(Email/Currency/Url/RichText/Percent/TextArea/Phone)拿它算 aria-invalid={!!errorMessage},而它恒为 undefined —— 所以这 7 个 widget 的 aria-invalid 永远是 false

也就是说这个槽位不只是名字和 spec 不一致,它在两种拼写下都是死的#3222 的决策因此不只是改名,而是「谁来生产它」。

注:上一版正文里 < FormMessage /> 被 GitHub 的正文消毒器当成 HTML 标签吞掉了(< 紧跟字母)。此处及后续一律在 < 后留一个空格。

验证

pnpm --filter @object-ui/fields type-check → exit 0
npx turbo run type-check --concurrency=2 → 78 successful, 78 total
npx turbo run build --filter=!@object-ui/site → 43 successful, 43 total
npx vitest run packages/fields/ (根配置) → 38 files / 483 tests passed
pnpm --filter @object-ui/fields lint → 0 errors
node scripts/check-spec-symbol-derivation.mjs → exit 0(未修改该脚本)
node scripts/check-changeset-fixed.mjs → exit 0

顺带发现(已另开 issue,不在本 PR 修)

两条都是同一类:索引签名在时,「读了但没人写」根本不可能被看见。

🤖 Generated with Claude Code

https://claude.ai/code/session_01PRJtkgUAaVG11FsJQbvZWA

…key (#3221)
`FieldWidgetComponentProps` ended in `[key: string]: any`. That is the
objectstack#4075 mechanism: a type that claims to have every key can never be
reported as missing one. Three consequences, all closed here:
- `props.required` and `props.error` — both declared by the spec's
`FieldWidgetPropsSchema`, neither declared here — were legal reads typed
`any` and `undefined` at runtime forever;
- a misspelled prop (`readOnly` for `readonly`) compiled and did nothing;
- any structural/parity comparison against the type was useless in principle,
which is why objectui#3161's batch-7 symbol guard was the only detector.
The index signature is replaced by a closed set derived from the real call
sites, not guessed: the controlled-input contract, the host plumbing the form
renderer forwards (`schema`, `dataSource`, `dependentValues`, `dependsOn`,
`emptyHint`, `compact`, `onSelectRecord`, `onCreateNew`), and DOM pass-through
(`id`, `name`, `autoFocus`, `tabIndex`, focus/click handlers, `aria-*`, and
`data-*` as a template-literal key so `keyof` stays finite). Every consumer in
the monorepo compiles unchanged.
Also read through the type instead of around it: ~20 `(props as any).x` reads
of keys the type now declares. Leaving them would have kept the "a typo
compiles" half of the defect alive at exactly the sites that matter.
The three batch-7 tripwires written to go red on this change
(`_IndexSignatureStillThere` / `_RequiredSilentlyReadsAsAny` /
`_ErrorSilentlyReadsAsAny`) are replaced by their inverse, plus a new
`__tests__/widget-props-contract.test.tsx` that pins the closed contract with
`@ts-expect-error` and proves the pass-through behaviour still renders.
Deliberately NOT resolved here: the `error` / `errorMessage` divergence
(objectui#3222). This change only makes it visible to the compiler.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRJtkgUAaVG11FsJQbvZWA
@vercel

vercelBot commented Aug 2, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 2, 2026 8:25pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.1 KB350 KB
Entry fileindex-C51mv2gK.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.12KB3.41KB
auth (LoginForm.js)17.86KB5.29KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.43KB2.09KB
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.12KB104.47KB
core (index.js)2.25KB0.80KB
create-plugin (index.js)9.28KB2.98KB
data-objectstack (index.js)136.23KB34.75KB
fields (index.js)223.43KB54.66KB
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.91KB10.53KB
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.90KB12.35KB
plugin-charts (index.js)60.53KB17.12KB
plugin-chatbot (index.js)180.09KB42.72KB
plugin-dashboard (index.js)111.87KB28.82KB
plugin-designer (index.js)210.51KB42.50KB
plugin-detail (index.js)230.54KB56.77KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)111.39KB26.94KB
plugin-gantt (index.js)162.26KB39.53KB
plugin-grid (index.js)185.04KB49.00KB
plugin-kanban (index.js)47.82KB13.18KB
plugin-list (index.js)104.86KB25.30KB
plugin-map (index.js)16.80KB5.24KB
plugin-markdown (index.js)13.65KB4.67KB
plugin-report (index.js)40.48KB10.57KB
plugin-timeline (index.js)25.76KB7.32KB
plugin-tree (index.js)8.34KB2.82KB
plugin-view (index.js)83.54KB20.39KB
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

@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

PM 验收:ACCEPT,放行合并队列

CI 15 项 13 绿 2 skipped、零红,已独立复核。范围边界守住:errorMessage 未改名、required 未新增。

定类型的方法是对的:先让编译器列缺口,再逐条判断

没有猜该声明哪些键,而是先只删索引签名、跑 tsc,让它列出 52 个真实缺口,再逐条判断该不该存在。这是这类改动唯一可靠的做法——凭直觉列白名单,要么漏键(炸下游)、要么多列(把索引签名换成一堆没人用的可选键,防御力照样是零)。

爆炸半径的证据也给对了:全仓 78/78 绿,且 plugin-detail 内联编辑器、plugin-grid 单元格编辑器、app-shell inspectors、表单渲染器零改动通过——因为封闭集就是从它们身上推出来的。"没有下游需要改"在这里不是运气,是方法的必然结果。

排除 & React.HTMLAttributes<HTMLElement> 也是基于实测而非品味:它的 onChange?: FormEventHandlerstrictFunctionTypes 下与本契约的 onChange: (val: T) => void 逆变冲突,且不覆盖 data-* 与真正在传的领域键。

data-* 用模板字面量索引签名是这个 PR 的关键手法

`data-${string}`keyof 保持有限,所以 props['data-testid'] 合法、而 props.required 依然报错。开放的只是 HTML 本来就开放的那一族。这正好躲开了 objectstack#4075 的机制——问题从来不是"有索引签名",而是"keyof 变成了 string"。这条值得记进方法学。

把三条钉扎换成反向断言,而不是删空

批次 7 那三条(_IndexSignatureStillThere / _RequiredSilentlyReadsAsAny / _ErrorSilentlyReadsAsAny)按预期删除,但换成了反向断言 + data-* 仍开放的钉扎。于是把类型重新放宽回 [key: string]失败一个测试,而不是悄悄通过。删掉一条守卫时用相反方向的守卫接上——这是正确做法,不是"顺手把测试删了"。

正向字面量钉住每个透传键仍被接受(过度收紧会报红)、两条渲染断言证明 aria-* / data-* / disabled 真的还能到达控件,这两条同样必要:封闭类型不能是靠丢掉真实行为换来的

约 20 处 (props as any).x 一并改掉,是对的

留着它们等于保留了"拼错也能编译"那一半缺陷,而且恰恰留在最要命的位置。未动的约 25 处 field || (props as any).schema 属于下面那个双载体问题,不在本单范围——这个切分正确。

open question(field / schema 双载体)我不在本 PR 处置,另开单

recommendation 是"B 最终、A 现在",而 A 正是本 PR 所发。同意:不阻塞。但我不会照 B 直接派工,理由见新单——schemaSchemaRenderer实际传给 widget 的键,第三方 widget 可能在读它,这和 #3226 是同一个陷阱(仓内证据回答不了仓外消费者的问题)。已另开单记录,按用户指示本轮后暂停,不派发。

#3231 / #3232 两条越界发现已入队。


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 2, 2026 20:31
@os-zhuang
os-zhuang added this pull request to the merge queueAug 2, 2026
Merged via the queue into main with commit 785b8a5Aug 2, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-3221-widget-props-index-signature branch August 2, 2026 20:32
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FieldWidgetComponentProps[key: string]: any 让每个 widget prop 检查失效

2 participants

@os-zhuang@claude