Skip to content

fix(app-shell): expand view edit-gate union failures into per-field diagnostics - #3624

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3606-union-diagnostics
Aug 7, 2026
Merged

fix(app-shell): expand view edit-gate union failures into per-field diagnostics#3624
yinlianghui merged 1 commit into
mainfrom
claude/issue-3606-union-diagnostics

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3606

问题

#3607(objectstack#5316)把 view 编辑路切到 wire 门 ViewMetadataSchema —— 这是对的,它正是 view 元数据类型注册、服务端 saveMetaItem 所判的那个 schema。但它是 z.preprocess(strip, z.union([...])),而 Zod 对 union 失败只产出一条根级 issue(code: 'invalid_union',path: [],message: 'Invalid input'),四个成员各自的真实诊断埋在 issue.errors 里(每个成员一组,按成员序)。

validateMetadataDraft 的映射是逐字照搬那条根 issue,于是编辑路的逐字段诊断全部塌陷。SchemaFormpath 做内联高亮、Monaco 按 path 定位 —— 空 path 指向不了任何东西;spec 为这些拒绝专门撰写的引导消息(#4001 那批)在编辑路上完全看不到。

改动

失败之后,按 draft 自身的判别式选定 union 成员,展开该成员的 issues。判别式复用创建路已有的 viewKind 判定,这次抽成单一的 isViewItemDraft,两条路不可能再各自漂移。

只呈现选中成员的 issues —— 四个成员全摆出来会把「这不是 view container」摆到正在编辑 ViewItem 的人脸上。issue 里提到的启发式选支(issue 数最少 / path 最深)已被一条测试钉死为错误做法。

实测:修前 / 修后

编辑路(mode: 'edit'),@objectstack/spec 17.0.0-rc.5:

body修前 path修前 message修后 path修后 message
stored ViewItem,config.type: 'not_a_real_layout'`` (空)Invalid inputconfig.typeInvalid option: expected one of "grid"|"kanban"|"gallery"|…
stored ViewItem,缺 name`` (空)Invalid inputnameInvalid input: expected string, received undefined
stored ViewItem,config.filter[0].operator 非法`` (空)Invalid inputconfig.filter.0.operatorInvalid option: expected one of "equals"|"not_equals"|…
container 带未知键 notAContainerKey`` (空)Invalid input`` (根,正确)Unrecognized key(s) on this view container: `notAContainerKey`. Until #4001 closed these shapes an unknown key was dropped silently — …
container 带单视图键 columns`` (空)Invalid input`` (根,正确)上一条 + 逐键提示:• `columns` belongs to a single VIEW, not to the container. Wrap it: `defineView({ list: { type, data, columns, … } })`, or name it — …

对 issue 正文的一处事实订正:正文把容器拒绝的消息引成 Unrecognized key(s) on this view container: … Wrap it: defineView({...})。实测这两截不是同一条消息 —— Wrap it: defineView(...) 是 spec 只挂在属于单个 view 的那批键(type/columns/data/viewKind/filters/sort)上的逐键提示;notAContainerKey 这种普通未知键拿到的是不带该提示的通用消息。表格最后两行分别钉住这两种,避免以后有人照 issue 正文去断言而扑空。

判定奇偶性:只有呈现变了

展开逻辑跑在 issue → SchemaFormIssue映射内部,位置在 ok 判定之后、且在既有的 serverRequired / flow 前向兼容两个过滤器之后 —— 驱动 ok 的那组 raw issues 一个都没动。所以判定不是「测出来没变」,而是结构上不可能变

这一点仍然写成了 pin(view verdict parity 一节):三种运行时形状 × 合法/非法 × 两种 mode,逐一断言 ok。它修前修后都是绿的 —— 这正是它的意义,它是防回归的那一半,不是「修前红修后绿」的那一半。若有人日后把这里改成 try-both 兜底改判,这组期望会翻成 true 而爆掉。

clientValidation.viewShapes.test.ts 的现有 pin 一行未改,原样通过。创建路(ViewItemSchema / ViewSchema,都不是根 union)行为零变化,并按精确 path+message 钉住,而不是只靠论证。

为什么按成员序索引,而不是拿导出的成员 schema 重解一遍

两条都测量过:

  • ViewMetadataSchema union 的 options[0]就是导出的 ViewItemWireSchema(同一个对象);但 options[1]不是导出的 ViewSchema —— 今天二者 shape 相同、行为相同,但那是手工维持的相似,正是 #5316 在本文件注释里明确拒绝依赖的东西。
  • 重解一遍还必须自己再调一次 stripViewConsoleDecorations,把 spec 的 preprocess 组合在第二个地方复刻出来 —— 又一处会漂移的复制品(config.filter[].id 那条 false positive 就是这么来的)。

读「判定时那一次运行自己产出的」错误组,是唯一能保证诊断不会描述另一个 schema的做法。代价是耦合成员顺序(spec 内部细节),这个代价用 CANARY 测试兜住:它们断言选中成员的精确 path + message,spec 一旦重排/增删 union 成员,测试直接红,而不是静默误选。

已知残留(未修,不在本 issue 范围)

只展开上的 union。成员 issue 的 path 相对于 union 节点,只有在根上它才等于 draft 绝对路径。更深处的嵌套 union(实测:config.columnsstring[] | ColumnDef[],无判别式)仍保留 Zod 自己的 Invalid input —— 但它带着真实 path config.columns,是可定位的,这与根上那条空 path 有本质区别。已在 a rejected draft always renders at least one issue 里覆盖到,不会静默丢诊断。

测试

新增 packages/app-shell/src/views/metadata-admin/clientValidation.viewDiagnostics.test.ts(18 条:5 CANARY + 2 噪音控制 + 2 兜底 + 2 创建路 + 7 判定奇偶)。

反向验证(先写预测再跑):还原实现后 7 红(5 CANARY + 2 噪音控制),打回实现后 0 红;11 条判定奇偶/创建路/兜底测试两个方向都绿

有一处预测未命中,如实记录:两条噪音控制测试最初修前也是绿的 —— 它们只有 not.toContain(...),而塌陷后的 Invalid input 恰好也不含任何被禁子串,即「因为什么都没产出所以绿」。已各补一条正向锚点断言,补后两条都变成修前红、修后绿。

vitest run packages/app-shell/src/views/metadata-admin/ → 128 passed, 1187 passed | 1 skipped
pnpm --filter @object-ui/app-shell type-check → clean
eslint (两个改动文件) → clean
node scripts/check-control-bytes.mjs → OK (3644 files)
node scripts/check-changeset-fixed.mjs / -no-major.mjs → OK

Generated by Claude Code

… diagnostics
The edit gate is `ViewMetadataSchema` = `z.preprocess(strip, z.union([...]))`
(objectstack#5316 / #3607). Zod reports a union failure as ONE root issue —
`path: []`, `message: 'Invalid input'` — and buries each member's real
diagnostics in `issue.errors`, one group per member. Mapping that root issue
literally collapsed the entire edit path onto a single un-addressable message:
`SchemaForm` highlights by `path` and Monaco locates by `path`, so an empty
path points at nothing, and the guided rejection messages the spec authored
(#4001) never reached the editor.
Failures are now expanded to the union member the draft's OWN discriminant
selects — the same `viewKind` test the create gate dispatches on, now factored
into a single `isViewItemDraft` so the two cannot drift. Only the selected
member's issues are rendered; showing all four would put "this is not a view
container" in front of someone editing a ViewItem. The rejected heuristic
alternative (fewest issues / deepest path) is pinned as wrong by a test.
Presentation only — the verdict cannot move. The expansion runs strictly inside
the issue-to-form-issue mapping, downstream of `ok` and of every issue filter,
so the set of raw issues driving `ok` is untouched. A parity block pins
`ok` across three runtime shapes x both modes.
Member selection is positional, which couples to a spec-internal detail. That
is deliberate: reading the groups the failing gate itself produced is the only
way the diagnostics cannot describe a schema other than the one that judged.
Re-parsing with the exported member schemas was measured and rejected — the
container member is not the exported `ViewSchema` object, and a re-parse would
have to re-apply `stripViewConsoleDecorations` itself, reconstructing the
spec's pipeline in a second place that can drift. Canary tests assert the
selected member's exact path + message, so reordering, adding or removing a
union member fails loudly instead of silently mis-selecting.
Fixes#3606
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
@vercel

vercelBot commented Aug 7, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 7, 2026 4:12pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

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

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.66KB3.13KB
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)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
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)480.72KB105.64KB
core (index.js)2.96KB1.13KB
create-plugin (index.js)9.28KB2.98KB
data-objectstack (index.js)137.51KB35.11KB
fields (index.js)230.87KB56.83KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)9.48KB3.27KB
i18n (useObjectLabel.js)26.14KB6.07KB
i18n (useSafeTranslation.js)4.52KB1.96KB
layout (index.js)38.53KB10.71KB
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.32KB1.64KB
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)61.04KB17.31KB
plugin-chatbot (index.js)180.09KB42.72KB
plugin-dashboard (index.js)115.50KB29.96KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)232.79KB57.42KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)112.10KB27.10KB
plugin-gantt (index.js)162.55KB39.57KB
plugin-grid (index.js)186.61KB49.34KB
plugin-kanban (index.js)48.30KB13.28KB
plugin-list (index.js)105.12KB25.48KB
plugin-map (index.js)16.81KB5.24KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.58KB10.58KB
plugin-timeline (index.js)25.76KB7.33KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
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.71KB1.34KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

metadata-admin 的 view 编辑路走 union 门后,逐字段诊断塌成一条根级「Invalid input」

2 participants

@yinlianghui@claude