Skip to content

fix(plugin-form): refuse a submit with no target instead of reporting success - #6386

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-6300-variant-form-missing-datasource
Aug 25, 2026
Merged

fix(plugin-form): refuse a submit with no target instead of reporting success#6386
os-support-ai merged 1 commit into
mainfrom
claude/issue-6300-variant-form-missing-datasource

Conversation

@os-support-ai

@os-support-aios-support-ai commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6300

TabbedForm, WizardForm, SplitForm, DrawerForm and ModalForm each opened handleSubmit with

if(!dataSource){awaitschema.onSuccess?.(data);returndata;}

— a success signal emitted without consulting a declared submitHandler and without persisting anything.

Re-derived measurement — both sides, not inherited

The card states a limit on its own table and asks for both sides to be re-derived. Done, on the merged tree (2e11c8c5b), driving each formType through ObjectForm with a submitHandler declared and nodataSource:

variantcard saidmeasured hereagrees?
tabbedonSuccess 1 · submitHandler 0rendered · onSuccess 1 · submitHandler 0yes
wizardonSuccess 1 · submitHandler 0rendered · onSuccess 1 · submitHandler 0yes
splitonSuccess 1 · submitHandler 0rendered · onSuccess 1 · submitHandler 0yes
drawer(source-read only)rendered · onSuccess 1 · submitHandler 0now measured
modal(source-read only)rendered · onSuccess 1 · submitHandler 0now measured
simple(not measured; source-read as "throws")never renders a form at alldiffers — see below

Where the numbers differ from the card

1. The simple contrast is "refuses at LOAD", not "throws at submit". With no dataSource and no inline customFields, SimpleObjectForm's schema effect takes the branch it comments as "No objectName or dataSource and no inline fields — cannot proceed", and no submittable form is mounted — measured rendered=false, onSuccess=0. Its 'DataSource is required for form submission (inline mode not configured)' throw is not reached on this path. That is very likely why the #6176 dev's simple leg "failed in its own harness": there was nothing to submit.

This changes where the fix has to live, not whether it is needed. The five variants build their fields from sections and therefore do render, so their refusal has to be at submit time.

2. SimpleObjectForm's own inline carve-out bypasses a declared submitHandler too — measured simple + customFields + submitHandler + no dataSourceonSuccess 1 / submitHandler 0. Its carve-out is checked before the seam. The triage direction ("when a submitHandler is declared, a missing dataSource must never bypass it") is followed here for the five, which means simple now differs from them in that one cell. Deliberately left alone in this PR — ObjectForm.tsx is outside this card's five files, and it is #6176's defect class rather than this one's. Recorded as its own card, #6388, and reported to the PM.

3. MasterDetailForm reachability, measured rather than inferred: with no dataSource, the parent form's early return reached handleSaved and produced toast.success("Created"), toastError 0, and a formKey bump that remounts and clears the parent form. Exactly the described failure.

The fix

One rule, in one place (submitTarget.ts), consumed by all five containers. A form has a submit target when it has either a dataSourceor a declared submitHandler (the host owns the write and needs no adapter of its own). With neither, one shape is still legitimate — fields authored inline, whose onSuccessis the write:

  • (a) a non-empty customFieldsSimpleObjectForm's own test, and the one the object-form element gate already keys requiresDataSource on, under the comment "The one escape hatch is inline customFields, which is exactly what hasInlineFields gates on inside the component, so the two stay in step."
  • (b)sections whose fields are all inline runtime FormField objects — the sectioned variants' inline mode, since they have no customFields of their own.

Everything else throws. The throw sits inside the persistence chain, as the branch taken when none of the real routes applies: that is where the fact belongs, it narrows dataSource for the routes that use it with no assertion, and each container's catch runs schema.onError and rethrows — which is what turns the MasterDetailForm reading from toast.success("Created") into a reported failure with the form left intact.

No accept set is widened and no key is added. packages/spec is untouched. #6176 / PR #6299's work on the normal path is untouched — the seam check it added is what limb-first precedence now routes through.

Limb (b) is not decoration — a customFields-only rule broke this repo's own published example

packages/plugin-form/README.md documents and ships a WizardForm element rendered with schema={wizard} and no dataSource prop, annotated // dataSource omitted: every step lists inline fields and introduced by "The inline shape is what lets a wizard run with no data source at all". Its inline fields live in sections[].fields, not customFields. Measured against an intermediate customFields-only version of this guard:

README wizard (inline secs) | rendered=true | onSuccess=0 | onError=DataSource is required for form submission (inline mode not configured)
tabbed (inline secs) | rendered=true | onSuccess=0 | onError=DataSource is required for form submission (inline mode not configured)
split (inline secs) | rendered=true | onSuccess=0 | onError=DataSource is required for form submission (inline mode not configured)

That is the over-broad refusal the card warned about, caught before it shipped. Limb (b) is all-or-nothing on purpose — it keeps normalizeSectionField's own taxonomy, where only shape (3) is self-describing and shapes (1)/(2) name fields that only an object schema can resolve. One bare name anywhere means the form did need metadata it could not get, and it refuses.

Ghost-assertion guard — every new assertion captured failing, then passing

Ablation: the five variant files reverted to unmodified origin/main bytes — git checkout 2e11c8c5b -- against the five variant paths — with the test file left as written; measured, then restored. Both legs proved on disk by blob hash rather than by exit code, and the restore leg proved by an empty git diff HEAD. No rebuild is involved: these tests import the containers by relative path and run from source under the root vitest config.

mutation proof (disk blob == origin/main blob, != HEAD blob)
TabbedForm.tsx disk=3ee782c965fd base=3ee782c965fd head=900b4e111193 MUTATED
SplitForm.tsx disk=019531292509 base=019531292509 head=827b6460fc2f MUTATED
WizardForm.tsx disk=a19a5e655f44 base=a19a5e655f44 head=8a4ec4f8f5d4 MUTATED
DrawerForm.tsx disk=2b76bfea0e5c base=2b76bfea0e5c head=030264133df8 MUTATED
ModalForm.tsx disk=9c1cda697019 base=9c1cda697019 head=8f59da0cd9bc MUTATED
guard-symbol grep in the five: 0 · 0 · 0 · 0 · 0
restore: restore-diff-empty=YES · all five RESTORED · guard-symbol grep: 2 · 2 · 2 · 2 · 2

BEFORE — unmodified origin/main source:Tests 18 failed | 13 passed (31) · VITEST_EXIT=1. Every failure reads AssertionError: expected "vi.fn()" to be called 1 times, but got 0 times — the false-success signature: submitHandler, onError and toast.error all silent.

× formType tabbed|wizard|split|drawer|modal: the host seam runs and success is reported from ITS result (5)
× formType tabbed|wizard|split|drawer|modal: onSuccess is NOT called and the reason reaches onError (5)
× formType drawer|modal: the dialog is NOT dismissed over an unwritten record (2)
× formType tabbed|wizard|split|drawer|modal: BOUNDARY — one bare field name among inline ones still refuses (5)
× MasterDetailForm with NO dataSource: no success toast, no reset — the failure is reported instead (1)

AFTER — with the fix:Test Files 1 passed (1) · Tests 31 passed (31) · VITEST_EXIT=0.

Which fixture is which

The 13 that pass in both readings are deliberately the non-discriminating half — they are what makes the 18 failures attributable to the absence of a submit target rather than to a blanket change:

fixturerolemainafter
block 4 · all five, dataSource present, create really called (5 cases)degenerate controlpasspass
block 3 · drawer/modal, customFields only (2 cases)carve-out limb (a)passpass
block 3 · all five, inline section fields — the README's shape (5 cases)carve-out limb (b)passpass
block 5 · MasterDetailFormwith a dataSource, batch commits, save confirmed (1 case)control for the reachability pathpasspass
block 1 · the declared seam runs with no adapter (5)the pinfailpass
block 2 · refuse loudly; dialog not dismissed (7)the pinfailpass
block 3 · BOUNDARY, one bare name among inline ones still refuses (5)keeps the carve-out from wideningfailpass
block 5 · MasterDetailForm with no adapter: no success toast, no reset (1)the user-facing halffailpass

All six paths named in the dispatch are pinned: the five variants, and the MasterDetailForm reachability path.

Verification

All readings below are from the tree of the pushed commit c33ddf2fa (verified at 3107b210f, whose tree 68cbc1a34 is byte-identical — same tree, collapsed history), after merging origin/main (631d81dbf) and rebuilding the dependency closure.

checkverdict line
pnpm --filter @object-ui/plugin-form type-checkTYPECHECK_EXIT=0
pnpm --filter @object-ui/plugin-form lint✖ 690 problems (0 errors, 690 warnings) · LINT_EXIT=0
pnpm exec vitest run packages/plugin-form/Test Files 68 passed (68) · Tests 705 passed (705) · PKGTESTS_EXIT=0
downstream consumers (console binding-reach, master-detail manifest, app-shell previews, react element-data-source + spec-bridge)Test Files 53 passed (53) · Tests 745 passed (745) · DOWNSTREAM_EXIT=0
node scripts/check-changeset-presence.mjs✅ 7 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
node scripts/check-changeset-no-major.mjs✅ No changeset declares a major bump
control-byte scan over the changed filesclean

The type-check script's second project (tsconfig.test.json) demonstrably compiles the new test file — tsc -p tsconfig.test.json --listFiles | grep -c submitTargetRefusal.test.tsx1 — so "typecheck is clean" is a statement about the new assertions too, not only about src.

Declared narrowing: the repo-wide pnpm lint (turbo run lint) was narrowed to the one package whose files changed. Evidence, all three parts: (1) the population is eslint's own — eslint . over the package, not a hand-picked file list; (2) the count is read from --format json: 98 files, 0 errors, 690 warnings; (3) eslint.config.js declares no parserOptions.project / projectService, so linting is not type-aware and this diff cannot move a verdict in any file it does not touch. CI runs the full farm regardless.

Notes for the PM


Generated by Claude Code

… success
TabbedForm, WizardForm, SplitForm, DrawerForm and ModalForm each opened
handleSubmit with `if (!dataSource) { await schema.onSuccess?.(data); return
data; }` — a success signal emitted without consulting a declared
`submitHandler` and without persisting anything.
All five now share one answer with SimpleObjectForm and the `object-form`
element gate: a form has a submit target when it has a `dataSource` or a
declared `submitHandler`; with neither, the one legitimate shape is inline
fields, whose `onSuccess` is the write. Anything else throws, reaching
`schema.onError` and rethrowing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3224.5 KB3266.6 KB
Main entry chunk (gzip)154.1 KB350 KB
Entry fileindex-B7ljdRtf.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.96KB4.16KB
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.84KB114.57KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.18KB47.97KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
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.66KB12.84KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.46KB34.48KB
plugin-designer (index.js)211.95KB42.75KB
plugin-detail (index.js)245.10KB62.31KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)129.12KB31.40KB
plugin-gantt (index.js)164.14KB39.87KB
plugin-grid (index.js)201.79KB54.60KB
plugin-kanban (index.js)52.87KB14.57KB
plugin-list (index.js)112.63KB27.45KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.70KB7.69KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.55KB20.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)3.75KB1.85KB
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.72KB2.24KB
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

@os-support-ai
os-support-ai marked this pull request as ready for review August 25, 2026 19:29
@os-support-ai
os-support-ai added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit 5d79fafAug 25, 2026
28 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-6300-variant-form-missing-datasource branch August 25, 2026 19:40
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationplugintests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

All five variant forms report SUCCESS when dataSource is missing — onSuccess fires, submitHandler is never consulted, nothing is persisted

2 participants

@os-support-ai@claude