Skip to content

fix(plugin-form): a required field with a runtime defaultValue is submittable on create (#4069) - #4084

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4069-required-runtime-default
Aug 10, 2026
Merged

fix(plugin-form): a required field with a runtime defaultValue is submittable on create (#4069)#4084
yinlianghui merged 1 commit into
mainfrom
claude/issue-4069-required-runtime-default

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4069

The defect

@objectstack/spec lets a field's defaultValue be a runtime instruction
rather than a value — the DEFAULT_VALUE_TOKENS family ('NOW()',
'current_user') or a CEL Expression envelope { dialect, source }. The server
resolves those per insert, in ObjectQL.applyFieldDefaults, for any field that
arrives absent or null. #4068 therefore has every object-form container leave
such a field empty: seeding the literal text NOW() into a datetime input and
submitting it as the field's value would suppress the very resolution the
declaration asked for.

Correct for an optional field. Combined with required: true it deadlocked:

remind_at: Field.datetime({ required: true, defaultValue: 'NOW()' }),

the control opened empty, the client-side required rule refused the submit, and
there was nothing sensible for the user to type — the declaration had already
said what the value is, and omitting the field is exactly what makes the server
supply it.

Premise verified against origin/main before implementing (issue bodies are
leads). A probe reproducing the issue's own recipe — make the runtime-token
field in createDefaults.test.tsx required: true and submit — measured
dataSource.create never being called, on both paths:

PROBE ObjectForm create.calls = []
PROBE body has required error = true
PROBE ModalForm create.calls = []
PROBE ModalForm markers = 3

The ruling this implements

Maintainer, 2026-08-10 (issue #4069) — Option A now; B may layer on later; C
and D rejected
:

A: in create mode, a field declaring a runtime defaultValue (token or
CEL envelope) suppresses the client-side required rule and is omitted from
the payload — the producer guarantees the value at insert, so the field is not
"missing". Single authority preserved, no client-side re-implementation of the
server's clock/actor.

No framework or server changes (C rejected), and no lint refusal (D rejected —
required: true + a runtime default is coherent authoring, storage-level
required with a producer-guaranteed value). The resolved preview (B) is a
separate follow-up card and is deliberately not started here.

What changed

One classifier, two consumers. #4068 introduced the runtime-default test
inside isSeedableDefault in schemaDefaults.ts. Rather than write a second
one, that predicate is lifted out as isRuntimeDefault and isSeedableDefault
is re-expressed in terms of it. Seeding and the required rule are the same fact
seen twice — a field whose value the producer supplies is neither seedable nor
missing — and a second copy would be free to disagree about, say, a CEL
envelope, at which point a form would seed a field it also refuses to submit.

Two levers, both gated on create:

  1. isRequiredInForm(field, isCreateForm) replaces the four
    required: field.required || false sites that derive a runtime form field
    from the object schema — ObjectForm, sectionFields (which serves Modal /
    Drawer / Tabbed / Split / Wizard, and the wizard's own cross-step gate), and
    the no-sections paths in ModalForm / DrawerForm. It is also re-applied
    over a form view's required override, because what excuses the field is the
    runtime defaultValue on the object field, not which layer asserted
    required.
  2. omitServerResolvedDefaults(payload, objectSchema) in each container's
    create branch.

Why the second lever is not optional. Suppressing the rule alone leaves half
the bug. A rendered control registers with the form whether or not anything
seeded it, so an untouched runtime-default field still reaches the payload as
undefined — or as '' once anything focuses it. undefined is invisible to a
JSON.stringify inspection (it cost this PR one lap: an early probe printed a
clean-looking payload while the keys were in fact present) yet is still a KEY a
data source may translate into an explicit column write; and '' is neither
absent nor null, so it stores a blank and defeats the declaration outright.
Emptiness is isMissingForRequired, the same predicate the required rule uses,
so "left empty" cannot come to mean two different things in the two halves.

isCreateFormMode is the single "no persisted record" test, now shared by the
seeding gate and this one so they cannot drift.

Boundaries, each pinned in both directions

Mode Declared Left empty Expected
create required + a runtime default yes submits; the key is absent
create required + a runtime default no (user typed) submits the typed value
create required, no default yes still refused
create required + a static literal user cleared the seeded control still refused
edit required, anything user blanked it still refused

The runtime shapes in the fixtures come from @objectstack/spec's own
DEFAULT_VALUE_TOKENS rather than a hand-copied list, so a token added to the
family tomorrow is covered without editing the suite, and the fixture cannot
drift from the classifier.

Deliberate consequence: the required marker and aria-required go with the
rule in the create case, since one boolean drives all three. That is the honest
reading — in create mode the user really is not required to provide the value —
and it removes the "required marker over an input the user may skip" oddity the
filer flagged under option A. Surfacing what the server will supply is option
B's job.

Not extended to requiredWhen, the conditional-required CEL rule: it is
resolved downstream in the form renderer against the live record, outside this
package's surface. Filed separately as an observation-class finding rather than
widened here.

Verification

Reverse verification, direction predicted before running: the create-mode pins
must go red and the two boundary pins (static-literal cleared, edit blanked)
must stay green, since they describe unchanged behavior. Reverting only the
behavioral levers to origin/main — the seven container/sectionFields files,
keeping schemaDefaults so the classifier stayed importable — gave exactly
that: 13 failed | 41 passed, with every failure in the create-mode group and
both boundary pins plus the pure-predicate block still green. Restored via a
patch file, never git stash.

pnpm exec vitest run packages/plugin-form/ --maxWorkers=2
  Test Files  39 passed (39)
       Tests  406 passed (406)          # 378 before; +28 new

pnpm --filter @object-ui/plugin-form type-check     # tsc --noEmit, clean
pnpm --filter @object-ui/plugin-form lint           # 0 errors (539 pre-existing warnings)

Downstream consumer sweep — prefix filter '...@object-ui/plugin-form',
i.e. the packages that consume it, after building each closure first:

packages/plugin-form     406 passed (39 files)
packages/plugin-designer  25 passed (3 files)
packages/plugin-view     107 passed (10 files)
apps/console + packages/app-shell   3244 passed | 1 skipped (346 files)

apps/site and the three examples declare no test script. plugin-designer
first failed to resolve @object-ui/mobile from plugin-grid; that is the
stale-artefact trap, not this change — building its dependency closure turned it
green with the change still applied.

Release

patch on @object-ui/plugin-form: a behavior change to a released package's
create-form semantics, with no new exported API — schemaDefaults and
sectionFields are internal modules, absent from the package index. The only
signature widening is an optional recordId on the internal
SectionFieldsContext. Happy to let the Bump Policy gate arbitrate.


Generated by Claude Code

…ubmittable on create (#4069)

`@objectstack/spec` lets `defaultValue` be a runtime instruction rather than a
value — the `DEFAULT_VALUE_TOKENS` family (`NOW()` / `current_user`) or a CEL
Expression envelope — which `ObjectQL.applyFieldDefaults` resolves per insert
for any field arriving absent or null. #4068 therefore leaves such fields empty
in a create form: seeding the literal text `NOW()` and submitting it would
suppress the very resolution the declaration asked for.

Correct for an optional field; combined with `required: true` it deadlocked.
The control opened empty, the client-side required rule refused the submit, and
there was nothing sensible for the user to type. Measured on origin/main:
`dataSource.create` was never called, on both the flat and sectioned paths.

Per the maintainer's 2026-08-10 ruling on #4069 (option A), in CREATE mode a
runtime `defaultValue` now suppresses the client-side required rule and the
field is omitted from the payload — omitted, not sent empty, because a rendered
control registers regardless of seeding and would otherwise carry `undefined`
(a key a data source may still write) or `''` (neither absent nor null, so it
stores a blank and defeats the declaration).

Seeding and the required rule read ONE predicate, `isRuntimeDefault`, so a form
can never seed a field it also refuses to submit. Edit mode, static literal
defaults and typed values are unchanged, each pinned in both directions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 10, 2026 7:30am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.3 KB 350 KB
Entry file index-d89msYfb.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.66KB 3.13KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
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) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 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.65KB 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) 484.11KB 106.78KB
core (index.js) 3.04KB 1.15KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 140.66KB 36.25KB
fields (index.js) 229.15KB 56.86KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
layout (index.js) 38.84KB 10.80KB
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.74KB
mobile (index.js) 1.50KB 0.62KB
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.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 45.23KB 12.45KB
plugin-charts (index.js) 61.49KB 17.48KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 118.50KB 30.66KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 237.80KB 59.48KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.57KB 27.68KB
plugin-gantt (index.js) 162.79KB 39.67KB
plugin-grid (index.js) 187.83KB 49.82KB
plugin-kanban (index.js) 48.53KB 13.38KB
plugin-list (index.js) 109.96KB 26.64KB
plugin-map (index.js) 17.00KB 5.32KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.58KB 10.58KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
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.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.71KB 7.95KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 2.71KB 1.34KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
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 (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation plugin tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A required field whose defaultValue is a runtime token (NOW() / current_user) cannot be submitted from a create form

2 participants