Skip to content

fix(types,core,plugin-list,plugin-view,app-shell): derive ViewType from @objectstack/spec instead of re-declaring it - #8372

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-8127-viewtype-derive-from-spec
Sep 7, 2026
Merged

os-zhuang merged 3 commits into
mainfrom
claude/issue-8127-viewtype-derive-from-spec

Conversation

@claude

@claude claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #8127

What this changes

ViewType and its zod face ViewTypeSchema stop being hand-written copies of
@objectstack/spec's list-view type vocabulary and become derivations of it. Every
structure keyed on them follows from that, so the exhaustiveness guarantee they were
supposed to provide is real again rather than total-over-a-copy.

Per the triage ruling on the card, this is the derivation route — page is not
hand-added to an eleven-arm literal.

The measurement that decided the design

The spec models two unions here, and conflating them is what makes this look like
"add page in twelve places" when it is not:

spec symbol members what it means
ListViewSchema.shape.type 10, with page every list-view type
VisualizationTypeSchema 9, without page what a user can switch between — the union AppearanceConfig.allowedVisualizations is typed on

The spec's own docblock on the expanded list-view schema says why: it carries
"the type: 'page' ⇄ pageName binding check". A type: 'page' view mounts a
published page in place of rows
; it is not a visualization that draws records.

⇒ ViewType must gain page. Structures that enumerate what a renderer draws
must not — and now cannot, because they derive from the subtraction rather than
restate a list.

The sharpest statement of the bug, measured on 5505aec

@object-ui/types' published validator already accepted viewType: 'page' — its
ViewKindEnum (zod/objectql.zod.ts) was derived from the spec all along:

viewType:page accepted by objectui ListViewSchema? true
parsed viewType = "page" | pageName = "welcome"
control (viewType:nonsense) accepted? false

while the renderer resolved it to exactly what it resolved a typo to:

specType=page               -> viewType="grid"
specType=kanban             -> viewType="kanban"
specType=gallery            -> viewType="gallery"
specType=nonsense-control   -> viewType="grid"

That is declared !== enforced on a published surface: the validator says yes, the
renderer silently draws something else, and a spec-valid view is byte-indistinguishable
from a misspelling. The derivation mechanism already existed in this repo — it had just
never been applied to the two faces the card names.

The promise is true again — ablation

LIST_VIEW_KINDS' docblock promises "a kind added to the union fails the build HERE".
Adding a synthetic member to the derived union, rebuilding @object-ui/types so it
reaches dist, then type-checking @object-ui/core:

=== marker count BEFORE mutation: 0
=== marker count AFTER mutation:  1
53:export type ViewType = NonNullable< SpecListView['type']> | 'sunburst' | 'list' | 'detail';
types build exit=0
=== marker present in dist? 1
MUTATION_LEG_EXIT=2
src/utils/normalize-list-view.ts(168,7): error TS2741: Property 'sunburst' is missing in type
'{ grid: true; kanban: true; ... tree: true; }' but required in type 'Record< ListViewVisualization, true>'.
restore: git diff HEAD is EMPTY

Restore leg: source clean, MARKER_IN_DIST=0, @object-ui/core type-check exit 0.

Build-failing vs merely page-carrying — the honest split

The card names five lists with no type comparison at all. Four of the five are now
build-failing; none of them merely gained a word.

site before after
core/utils/normalize-list-view.ts LIST_VIEW_KINDS total over the stale copy build-failing on ListViewVisualization; new UNDRAWABLE_VIEW_KINDS makes the other half of the union total too
plugin-list/ViewSwitcher.tsx ViewType + its 2 maps a third local re-declaration build-failing — alias of ListViewVisualization
plugin-list/index.tsx SDUI enum ×2 two 7-entry literals build-failing — one total Record; both had drifted, missing chart and tree
plugin-list/ListView.tsx allowlist 9-name literal array derived — calls isListViewVisualization, the map's own predicate
app-shell/CreateViewDialog.tsx picker type: string, never compared build-failing — total Record
types/designer.ts UnifiedViewType 8-arm literal, had drifted derived from spec VisualizationType (gains tree)
plugin-view DEFAULT_VIEW_LABELS / DEFAULT_VIEW_ICONS / iconMap total over the stale copy keyed on the full ViewType, so these do carry page — they label any view type that exists

The silence is closed, the renderer is not widened

⛔ This PR does not add a page renderer — that is a feature, not this card. A
type: 'page' view still falls back to a grid. What changes is that it says so once,
using this repo's existing loud-fail-open convention (core/evaluator/fieldRules.ts,
objectstack#5149), so a spec-valid view no longer degrades identically to a typo.

i18n: ten locale packs, zero new keys — and why

Triage priced this as "⚠️ i18n 是硬工作量:十个语言包,每个要一个 label 和一个 description".
Measured, that work is not owed. Every locale-backed view-type label belongs to a
visualization picker or switcher, and page is correctly absent from those by
construction. check:i18n-keys, check:i18n-drift and check:i18n-dead-keys are all
green with no locale file touched.

⚠️ One real trap found on the way: building the bundle keys from a stem
(t(...viewType${stem}...)) type-checks fine and turns ten packs' worth of coverage into
a prefix check. check:i18n-keys caught it ([undeclared-dynamic-family]). The keys are
back to literals inside the total record — totality from the Record, checkability from
the literals.

验收备注

  • The card's site list is incomplete, and its line numbers are off this tree. Re-taken
    on 5505aec: zod/views.zod.ts is line 26 (card/comment said 25); the ListView.tsx
    allowlist is 2241 (card said 2224); the SDUI enums carry 7 entries each, not the
    nine the card states.
  • A third local re-declaration the card does not mention: plugin-list/src/ViewSwitcher.tsx
    declared and exported its own nine-arm ViewType. The card describes that file's two
    maps as total over the shared union; they were total over this private copy. One level
    deeper than filed.
  • A fourth, in a pin test. plugin-view/src/__tests__/ViewSwitcher.test.tsx carried
    const ALL_VIEW_TYPES: ViewType[] = [...] under the comment "adding a member to the union
    without extending this list is a compile error here too". That claim was false — a short
    array is assignable to an array type; the annotation rejects an invalid member and says
    nothing about a missing one. It compiled green at eleven entries and only the runtime
    toEqual went red. Now satisfies Record< ViewType, true>, which actually delivers it.
  • A second hardcoded allowlist at ListView.tsx:2293 was deliberately left alone — it sits
    inside a commented-out useEffect ("DISABLED: interfering with schema-defined views"). Dead
    code, not a site.
  • Triage's "third instance of the class" question is unresolved here, not answered. The two
    earlier instances (the capability set, ObjectFieldGroup.visibleWhen) could not be confirmed
    as local re-declarations from this checkout: it is shallow and both fixes are outside the log
    window. ⛔ Not guessed. What this PR does establish is that the pattern is at least
    four re-declarations of this one vocabulary, two of which nobody had filed.
  • Out of scope, not filed — same family, different concept (defaultViewType / designer
    variant lists), all outside the declared file surface and none of them the page defect:
    types/objectql.ts:1804 and :1988, types/zod/objectql.zod.ts:258, plugin-view/index.tsx:76,
    app-shell/.../anchors.ts:269, app-shell/.../ViewVariantInspector.tsx:191.

Review

⚠️ Clause-② — this widens the accept set. needs:contract-review is on the card and on
this PR; ⛔ neither carrier cleared by me. Staying draft, not enqueued, auto-merge not armed.


Generated by Claude Code

…om the spec instead of re-declaring it

`ViewType` (packages/types/src/views.ts) and its zod face `ViewTypeSchema` were
hand-written eleven-arm copies of `@objectstack/spec`'s list-view type
vocabulary. `@objectstack/spec@17.3.0` added `page`; neither copy followed, so
every structure keyed on them was total over the COPY and compiled green while
being incomplete — including `LIST_VIEW_KINDS`, whose own doc comment promises
that "a kind added to the union fails the build HERE".

Measured on 5505aec: `@object-ui/types`' published validator ACCEPTS
`viewType: 'page'` (its `ViewKindEnum` was already derived) while
`normalizeListViewSchema` resolved `page` to exactly what it resolved a typo
to — `viewType: 'grid'`, silently.

Both faces now derive from `@objectstack/spec/ui` `ListView['type']`, and the
renderer-side structures derive from that in turn. `@object-ui/core` gains
`ListViewVisualization` / `isListViewVisualization` for the kinds `ListView`
actually draws, which exclude `page` for the same reason the spec's own
`VisualizationType` does. A spec-valid but undrawable kind still falls back to
a grid, but says so once instead of degrading identically to a typo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtGhnU3WnnWyiWeYQhw2aX
Building them from a stem — t(`console.objectView.viewType${stem}`) — type-checks
fine and silently downgrades ten locale packs' worth of coverage to a prefix
check. `check:check-i18n-call-site-keys` caught it as
`[undeclared-dynamic-family]`: it can only see a member missing from every pack
when the key is a literal argument.

That is the same "a guard that stopped guarding" shape objectui#8127 exists to
remove, so it would have been a poor trade to introduce here. Totality still
comes from the `Record` over the derived visualization union; checkability comes
from the literals inside it.

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

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3472.4 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-SeuNPbfN.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
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.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 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.68KB 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) 498.55KB 114.03KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 189.15KB 52.56KB
fields (index.js) 243.15KB 61.40KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
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.75KB
mobile (index.js) 1.99KB 0.87KB
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.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.16KB 3.68KB
plugin-calendar (index.js) 49.00KB 13.91KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 193.79KB 46.09KB
plugin-dashboard (index.js) 131.48KB 34.45KB
plugin-designer (index.js) 213.21KB 43.63KB
plugin-detail (index.js) 248.65KB 63.91KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.58KB 56.63KB
plugin-kanban (index.js) 55.38KB 15.72KB
plugin-list (index.js) 112.74KB 27.70KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.54KB 20.84KB
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.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
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 (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
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

@os-zhuang
os-zhuang marked this pull request as ready for review September 7, 2026 22:48
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 7, 2026
Merged via the queue into main with commit ca39427 Sep 7, 2026
34 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8127-viewtype-derive-from-spec branch September 7, 2026 23:04
os-justin pushed a commit that referenced this pull request Sep 8, 2026
`page` is simultaneously a list-view type `@objectstack/spec`'s own
`ListViewSchema` accepts, a member of both published `@object-ui/types`
faces, and absent from the set `ListView` draws -- so a spec-valid
`type: 'page'` view normalises to `viewType: 'grid'` with its `pageName`
mount target left unread in the metadata.

PR #8372 closed the DECLARATION half of objectui#8127 by deriving
`ViewType` from the spec. This states the renderer half that derivation
made visible rather than removed, mechanically instead of in prose.

Disposition-free by construction: it asserts today's behaviour, so it
reddens if a page renderer is grown (the residual set empties and the
normalised output stops being `grid`) and equally if `ViewType` is
narrowed back (the published faces stop accepting `page`). Neither
open ruling can land silently now.

Every set is asserted at its exact size before use -- a census over an
empty set passes -- and the `grid` reading carries a discrimination
control so a normalizer that answered `grid` for everything would fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 9, 2026
…ck-ai#8684)

`page` is simultaneously a list-view type `@objectstack/spec`'s own
`ListViewSchema` accepts, a member of both published `@object-ui/types`
faces, and absent from the set `ListView` draws -- so a spec-valid
`type: 'page'` view normalises to `viewType: 'grid'` with its `pageName`
mount target left unread in the metadata.

PR objectstack-ai#8372 closed the DECLARATION half of objectui#8127 by deriving
`ViewType` from the spec. This states the renderer half that derivation
made visible rather than removed, mechanically instead of in prose.

Disposition-free by construction: it asserts today's behaviour, so it
reddens if a page renderer is grown (the residual set empties and the
normalised output stops being `grid`) and equally if `ViewType` is
narrowed back (the published faces stop accepting `page`). Neither
open ruling can land silently now.

Every set is asserted at its exact size before use -- a census over an
empty set passes -- and the `grid` reading carries a discrimination
control so a normalizer that answered `grid` for everything would fail.


Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S

Co-authored-by: Claude <noreply@anthropic.com>
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