Skip to content

fix(plugin-form): declare object-master-detail-form formType as a closed, measured vocabulary - #6177

Merged
yinlianghui-tw merged 1 commit into
mainfrom
claude/issue-5939-master-detail-formtype
Aug 24, 2026
Merged

fix(plugin-form): declare object-master-detail-form formType as a closed, measured vocabulary#6177
yinlianghui-tw merged 1 commit into
mainfrom
claude/issue-5939-master-detail-formtype

Conversation

@yinlianghui-tw

Copy link
Copy Markdown
Collaborator

Part of #5939

⚠️ Deliberately Part of, not Fixes. The card asks two questions and this PR answers one: the declaration is tightened, and the renderer fall-through remedy is reported for a ruling rather than implemented (§4). Merging this should not close the card until that ruling lands.

object-master-detail-form declared formType as a bare string while the sibling object-form declared the same key as an enum of six. Both funnel into ObjectForm, which switches on those six names, so a value outside them matched no branch and fell through to the flat field list with no diagnostic.

The declared set here is simple | tabbed — measured against the master-detail composition, not copied from the sibling.

Verified on e8f171366 (final commit; every gate reading below is from that tree).


1. The per-value measurement (step 1 of the dispatch order)

Mounted object-master-detail-form with two parent sections (Sec Oneref, Sec Twomemo) and one detail collection, then drove the master-detail's own bottom Save bar. Counts are calls on one stub dataSource.

valuebranch reachedparent half rendershost Save barhonoured?
simpleObjectForm.tsx:1134SimpleObjectForm sections pathinline, both sections, both inputsbatchTransaction ×1, create ×0
tabbedObjectForm.tsx:236TabbedForminline, 2 tabs, both inputsbatchTransaction ×0, create ×1✅ presentation (see the caveat in §4)
wizardObjectForm.tsx:260WizardFormonly step 1 mounts (memo absent)click advanced to Step 2 of 2; nothing persisted
splitObjectForm.tsx:287SplitForminline, 2 panelsbatchTransaction ×0, create ×1
drawerObjectForm.tsx:316DrawerFormportal dialog, outside the host: 0 forms, 0 inputs in the containerno Create button in the host at all
modalObjectForm.tsx:346ModalFormsame portal shapesame
'wizzard' (out-of-vocabulary control)no branch — falls throughboth inputs render, section headers gonesaves— the harm

Two independent statements already in the repo agree with the measured two, which is what the enum is derived against rather than a second guess:

  • MasterDetailFormSchema.formType?: 'simple' | 'tabbed' (MasterDetailForm.tsx:93) — the type contract for the same key.
  • formType === 'tabbed' ? 'tabbed' : 'simple' (ObjectForm.tsx:215) — the coercion ObjectForm applies when it routes a subforms schema into this block. Declaring fewer than two would have made the renderer itself emit an out-of-vocabulary value.

Declaring all six would have minted a declared-but-inert vocabulary — choices an authoring UI offers and this composition cannot honour. The table is pinned as regression coverage in packages/plugin-form/src/masterDetailFormTypeVocabulary.test.tsx, so a value that starts or stops being honoured turns it red instead of drifting.


2. ⭐ The green-because-it-never-ran result, reproduced — then turned into a real reading

This is the card's whole argument, so it is measured in both directions rather than asserted.

Ablationapps/console/src/__tests__/public-block-binding-reach.test.tsx restored to its pre-#3840 generic sampling by deleting the two by-name samples (sections, formType), so the probe once again hands object-master-detail-form the generic 'x' for a string input. The mutation was confirmed on disk, per anchor, before either leg ran:

sections-by-name-sample: 0 (was 1)
formType-by-name-sample: 0 (was 1)
generic-array-sample : 1 (the ['name'] path, intact)
generic-default-sample : 1 (the 'x' path, intact)

No rebuild leg is required and none was run: the root vitest.config.mts aliases every @object-ui/* specifier to packages/*/src, so the mutation reaches the module under test directly — there is no dist/ in the resolution path to go stale. The declaration leg was moved with git checkout against committed state in both directions, and the whole script carried trap restore EXIT INT TERM.

LEG A — before the fix (declaration reverted to type: 'string'; on-disk proof 0 enum / 1 string):

× object-form asks the data layer for its objectName
Test Files 1 failed (1)
Tests 1 failed | 15 passed (16)

object-master-detail-formpassed — green, while carrying the identical latent crash. It never reached the section loop.

LEG B — after the fix (declaration enum: ['simple','tabbed']; on-disk proof 1 enum / 0 string):

× object-form asks the data layer for its objectName
× object-master-detail-form asks the data layer for its objectName
Test Files 1 failed (1)
Tests 2 failed | 14 passed (16)

and the two failures are now the same reading. Quoted with the block name in backticks where the runner prints it wrapped in angle brackets, so the body sanitizer cannot eat the identifier:

AssertionError: `object-form` threw during render — that is a crash, not a binding verdict:
… Component "object-form" failed to render … Cannot read properties of undefined (reading 'map')
AssertionError: `object-master-detail-form` threw during render — that is a crash, not a binding verdict:
… Component "object-master-detail-form" failed to render … Cannot read properties of undefined (reading 'map')

The declaration is what decided whether the probe exercised the block or routed around it. Restoration was verified afterwards: probe anchors back to 1/1, declaration back to 1 enum / 0 string, git status --porcelain empty.


3. ⚠️ What catches an out-of-vocabulary value now — and what still does not

Caught.manifestFromConfigs serializes inputs into the manifest the JSX-page compiler and the save gate validate against, and sdui-parser's checkType raises invalid-enum at severity error when a prop's only declared arm is an enum (packages/sdui-parser/src/validate.ts:198). While the key was a bare string, every string passed. Pinned in apps/console/src/__tests__/masterDetailFormTypeManifest.test.ts for 'wizzard', 'x', 'wizard' and 'drawer', with 'simple' / 'tabbed' asserted clean and a reachability guard ahead of both (that guard earned its place: the manifest key is plugin-form:object-master-detail-form, and the first draft asserted against the bare name, which would have made every "no diagnostic" claim vacuous).

Not caught, stated plainly. Per #5155's standing maintainer ruling, rejection lives at the zod/publish boundary, and that boundary is @objectstack/spec's, not this repo's:

ObjectMasterDetailFormPropsSchema.safeParse({objectName: 'po',details: [],formType: 'wizzard'}).success// → true, still

That is asserted as a test in the same file rather than left as prose, so this PR cannot be read as "the hole is closed". Filed as the cross-repo half with the full measurement in it: objectstack#11873. The spec is not touched here.


4. The renderer fall-through — reported, not implemented

The card's second remedy (reject/report an unknown formType instead of falling through) is not in this PR, per the dispatch order. The census it asked for, over every tracked file (git ls-files, so node_modules and build output are out by construction), excluding this PR's own four files:

valueoccurrences
wizard34
modal19
tabbed13
simple11
drawer11
split10
out-of-vocabulary0

98 authored literal values, every one of them inside the six-name vocabulary. Nothing in the repo — fixtures, examples, catalog entries, tests — authors an out-of-vocabulary formType today. So the blast radius of a reject-or-report change is zero in this repo; it is not zero for already-published apps, which is the part only the PM can rule on.

One measurement that qualifies the vocabulary, reported rather than quietly shipped:submitHandler — the seam MasterDetailForm hands the parent form so the save routes through batchTransaction — is read in exactly one place, ObjectForm.tsx:820 inside SimpleObjectForm. TabbedForm and SplitForm call dataSource.create directly instead. So tabbed, which this PR declares, is presentationally honoured but escapes the atomic batch: the parent commits on its own and a failing child leg orphans it. Dropping tabbed from the enum was rejected as the wrong lever — it would contradict both in-repo statements of the vocabulary and make ObjectForm.tsx:215 emit an undeclared value. Filed as #6176 instead, with the measurement.


5. Gates

Each quotes its own verdict line; the exit code was captured before any pipe (cmd > log 2>&1; EXIT=$?), never off a tail. Root vitest only, never package-scoped (#3378). Every heavy run went through the container's shared verify lock.

gateexitits own verdict line
root vitest — new tests + console contract/parity suites + the probe0Test Files 9 passed (9) · Tests 179 passed (179)
root vitest — packages/plugin-form/ (whole package path)0Test Files 60 passed (60) · Tests 613 passed (613)
turbo run type-check --filter=@object-ui/plugin-form --filter=@object-ui/console0Tasks: 37 successful, 37 total
turbo run lint (same two packages)0Tasks: 3 successful, 3 total0 errors in both (637/203 pre-existing no-explicit-any warnings)
check:control-bytes0check-control-bytes: OK (scanned 5102 tracked text file(s); skipped 85 binary).
check:phantom-deps0Every in-scope import is declared by the package that publishes it.
check:self-import0No package names itself inside its own src/.
check:i18n-keys0Every in-scope call-site key resolves against the en pack (2820 keys)…
lint:coverage0lint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).
type-check:coverage0type-check coverage: 45/46 via type-check, 0 via their own build, 0 known-broken
changeset:check0All workspace packages are in the changeset fixed group. · No changeset declares a major bump.
check-changeset-presence01 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/master-detail-formtype-vocabulary.md.

Declared narrowing.lint was run per-package (plugin-form, console) rather than as a whole-repo eslint ., and the narrowing is a measurement rather than a skip: the population is the repo's own package boundary (not a hand-picked file list), both packages that this diff touches ran in full, and eslint.config.js enables no type-aware linting — no project, no projectService, no recommendedTypeChecked — so this diff cannot move the verdict on any file it does not contain. CI runs the whole farm regardless.

Control bytes: the four changed files were also scanned outside the gate with grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' — no matches.


Files

  • packages/plugin-form/src/index.tsx — the declaration, type: 'string'type: 'enum', enum: ['simple','tabbed'], with the exclusions recorded next to it.
  • packages/plugin-form/src/masterDetailFormTypeVocabulary.test.tsx — the vocabulary and the measurement behind it.
  • apps/console/src/__tests__/masterDetailFormTypeManifest.test.ts — what the tightening buys at the manifest/save-gate layer, and the publish-boundary gap it does not close.
  • .changeset/master-detail-formtype-vocabulary.md

Related: #3840 (where this surfaced, and the probe that read green) · #3838 · #5155 (why the enum is not by itself a rejection) · #6158 (the declared-but-inert trap this measurement exists to avoid) · #6176 (filed here) · objectstack#11873 (the spec half).

Generated by Claude Code


Generated by Claude Code

…sed vocabulary
`object-master-detail-form` declared `formType` as a bare `string` while the
sibling `object-form` declared the same key as an `enum` of six, and both
funnel into the renderer that switches on those six names. A value outside the
vocabulary matched no branch and fell through to the flat field list with no
diagnostic.
The declared set is measured, not copied from the sibling: `simple | tabbed`.
The other four are reachable branches that break the master-detail composition.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3221.2 KB3990.2 KB
Main entry chunk (gzip)153.7 KB350 KB
Entry fileindex-BfcDvOCx.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.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)505.15KB114.53KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)168.48KB46.47KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.45KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.14KB61.94KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.66KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)201.05KB54.38KB
plugin-kanban (index.js)52.89KB14.59KB
plugin-list (index.js)111.86KB27.22KB
plugin-map (index.js)20.11KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.57KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)54.84KB18.43KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
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)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
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-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.49KB2.14KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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.

2 participants

@yinlianghui-tw@yinlianghui