Skip to content

fix(plugin-form): honour the declared submitHandler seam in every form variant - #6299

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-6176-submithandler-variant-forms
Aug 25, 2026
Merged

fix(plugin-form): honour the declared submitHandler seam in every form variant#6299
os-litant merged 2 commits into
mainfrom
claude/issue-6176-submithandler-variant-forms

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#6176

ObjectFormSchema.submitHandler is documented as the seam a host uses to own persistence — "the form validates and hands the collected values to the host INSTEAD of calling dataSource.create / dataSource.update". ObjectForm forwards the key into every variant it routes to (the {...schema} spread carries it), but only SimpleObjectForm ever read it. TabbedForm, WizardForm, SplitForm, DrawerForm and ModalForm persisted directly instead. This is a declared-vs-enforced restoration against ADR-0034 item 4 / #2679, which chose the atomic batch.

Premise reproduced before any source was touched

Measured on origin/main @ c456d91f4, master-detail parent with two sections and one detail collection, driving the master-detail's own bottom Save bar. Counts are calls on the same stub dataSource:

parent formTypebatchTransactiondataSource.createobserved argument
simple10
tabbed01["po", {"ref":"PO-1"}]
wizard00Save bar drives Next
split01["po", {"ref":"PO-1"}]
drawer / modal00parent half is in a portal dialog

That is the card's measurement, including its observed argument, reproduced exactly.

⚠️ The first version of that probe read 0 / 0 on every row — simple included — because it clicked before the form had mounted. The simple positive control is what caught it; the numbers above are from the corrected probe.

The consequence is worse than "the batch is bypassed"

With formType: tabbed (and split), the child leg is never attempted at all. Measured on the emulated path (a dataSource with no batchTransaction, whose child create fails):

simple (on main)tabbed / split (on main)
creates issuedpo, po_linepo only
child leg attemptedyesno
compensating deletedelete('po','po1')none
operator seeserror toastsuccess toast

So the parent commits alone, the entered line items are silently discarded, nothing rolls back, and the save is confirmed as successful.

The fix

Each of the five variants now checks schema.submitHandler first, with the same precedence SimpleObjectForm uses, and declares the key on its own schema interface. Two supporting choices:

  • One writePayload per handler. The create-mode omitServerResolvedDefaults call was hoisted so the host-owned route and the direct route cannot write different payloads. On the non-handler path the value is provably identical (in the edit branch mode === 'edit', so writePayload === data).
  • Declared by reference, not restated.submitHandler?: ObjectFormSchema['submitHandler'] rather than a copied signature, so the variant key and the canonical key can never drift. (This also removed the 15 no-explicit-any warnings the restated version introduced.)

WizardForm additionally guards its default success arms with !schema.submitHandler, mirroring ObjectForm, so a host that owns the write also owns the outcome instead of double-confirming.

Evidence

Per-point ablation — five repair points, each reverted alone to c456d91f4, with the mutation proven on disk each leg (seam marker 0 in the ablated file, 1 in the other four) and restoration proven the same way. Restore ran under trap … EXIT INT TERM; git diff HEAD --stat was empty afterwards.

revertedred casesstill green
TabbedForm3 — tabbed in all three blocks20/23, incl. the whole vocabulary file
SplitForm4 — split in all three blocks + the vocabulary file's split case19/23
WizardForm1 — wizard seam pin22/23
DrawerForm1 — drawer seam pin22/23
ModalForm1 — modal seam pin22/23

simple stayed green in every leg — the control that keeps the negative assertions from passing vacuously.

⚠️ Worth noting from the TabbedForm leg: the entire pre-existing vocabulary file stayed green, including its tabbed case. That case asserts only presentation, so it passes in both the broken and the fixed world — it was never an instrument for this defect. The new pins are.

New file submitHandlerSeam.test.tsx (12 cases) pins, in order of what matters:

  1. a failing child leg leaves no committed parent — for simple/tabbed/split, with a positive probe that the child leg was actually attempted, so the rollback assertion cannot pass on a form that never submitted;
  2. the seam itself for all six renderers, mounted through ObjectForm so the forwarding path is the real one — positive (handler received the values) and negative (create/update untouched);
  3. the master-detail reading: one batchTransaction carrying both legs, zero independent creates.

Vocabulary deliberately unchanged — and one open question

The object-master-detail-form.formType vocabulary stays simple | tabbed. Narrowing or widening it is a contract change and is not this card's to make.

⚠️But this fix falsifies one of the vocabulary file's stated exclusion rationales.masterDetailFormTypeVocabulary.test.tsx excluded split because it "renders inline but persists AROUND the atomic batch". The second half is no longer true — split now saves through the batch like simple. That case is updated to pin the new true reading and to record that the persistence reason for excluding split is spent. Whether split should now be admitted to the vocabulary is a contract question left open for triage — deliberately not decided here.

Gates

Run from the repo root at 403488be2 (the final commit):

gateresult
vitest run packages/plugin-form/65 files / 650 tests passed
plugin-form type-checkexit 0
plugin-form lint0 errors, 673 warnings (base c456d91f4 = 0 errors, 663 warnings; the +10 are as any stubs in the new test file — zero new warnings in the five source files)
check-changeset-presencedeclares 1 changeset
check-changeset-no-major / -fixedno major; all packages in the fixed group
check-control-bytesOK (5184 files)
check-vi-mock-specifiersOK

Consumer sweep, downstream direction (dependents of plugin-form): plugin-designer + plugin-view 34 files / 297 tests; app-shell 18 files / 262 tests; apps/console 8 files / 203 tests — all passed.

⚠️Declared narrowing. The full packages/app-shell suite hit the container's ~10-minute foreground cap (exit 143, no failures in the partial log) and was narrowed to the 18 files that reach plugin-form. The narrowing rests on a measured invariance, not a guess: no code outside packages/plugin-form supplies a submitHandler anywhere in packages/ or apps/, so the new behavioural arm is unreachable from every consumer; the only reachable change is the writePayload hoist, which is value-identical. CI runs the full farm regardless.

Generated by Claude Code


Generated by Claude Code

…m variant
`ObjectFormSchema.submitHandler` is documented as the seam a host uses to own
persistence: the form validates and hands the collected values over INSTEAD of
calling dataSource.create / dataSource.update. `ObjectForm` forwards the key
into every variant it routes to, but only `SimpleObjectForm` ever read it.
TabbedForm, WizardForm, SplitForm, DrawerForm and ModalForm called
dataSource.create directly.
MasterDetailForm supplies `submitHandler: submitViaBatch` so the parent and its
child collections commit as ONE batchTransaction (#2679 / ADR-0034 item 4).
With the parent half rendered `tabbed`, the measured reading was
`batchTransaction 0 / create 1` with args ["po", {"ref":"PO-1"}] — and the child
leg was never attempted at all: the parent committed alone, the entered line
items were discarded, no compensation ran, and a success toast confirmed it.
`split` measured identically.
Each variant now checks `schema.submitHandler` first, with the same precedence
SimpleObjectForm uses, and declares the key on its own schema interface. The
write payload is hoisted to one `writePayload` per handler so the host-owned
route and the direct route cannot diverge. WizardForm additionally guards its
default success arms with `!schema.submitHandler`, mirroring ObjectForm, so a
host that owns the write also owns the outcome.
The `object-master-detail-form.formType` vocabulary is deliberately unchanged
and stays `simple | tabbed`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mn4BZ5AVDM81pvfij1WwM9
…ence, add changeset
The five variant schemas restated `submitHandler`'s signature verbatim. Declaring
it as `ObjectFormSchema['submitHandler']` instead makes the variant key and the
canonical key `ObjectForm` forwards provably the same type — they cannot drift,
and the restated `any`s (3 per site) stop being new lint warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mn4BZ5AVDM81pvfij1WwM9
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3222.8 KB3266.6 KB
Main entry chunk (gzip)154.1 KB350 KB
Entry fileindex-BPI57SzY.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.63KB114.68KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)171.74KB47.48KB
fields (index.js)238.40KB59.89KB
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.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.33KB42.81KB
plugin-detail (index.js)244.74KB62.20KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)126.92KB30.85KB
plugin-gantt (index.js)164.17KB39.89KB
plugin-grid (index.js)201.14KB54.40KB
plugin-kanban (index.js)52.83KB14.55KB
plugin-list (index.js)111.94KB27.24KB
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.49KB7.59KB
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)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

Labels

Projects

None yet

2 participants

@os-litant@claude