fix(form): required is presence, not truthiness — false and 0 are values - #3137
Merged
Conversation
…re values A required boolean could not be saved unchecked: the switch showed OFF, the form answered "是否完成不能为空", and turning it ON saved instantly. An AI-built task tracker could therefore only create ALREADY-DONE tasks — the one state the control shows by default was the one value it refused to save (objectstack-ai/cloud#972). Two defects stacked, either one enough on its own: 1. The `required` verdict read truthiness. @objectstack/spec FieldSchema.required (ADR-0113) is "an insert must provide a NON-NULL value", and objectql's record validator implements exactly that. react-hook-form's built-in rule instead fails whenever `isBoolean(value) && !value` — its accept-the-terms checkbox heritage — silently redefining every required boolean as "must be TRUE", a required select whose chosen option value is `false` included. It also disagreed the other way, letting a whitespace-only string through for the server to reject with a 400. The renderer no longer hands RHF its own `required`: the check is a `validate` entry keyed `required` (so the error still surfaces as `type: 'required'`, which the conditional-required cleanup keys on) backed by a new shared `isMissingForRequired` in @object-ui/core — a deliberate mirror of objectql `record-validator.isMissing`. Deleting the inherited rule also stops a `required` that rode in on `validation` from outliving a `requiredWhen` that resolved to FALSE. 2. A boolean field held `undefined` while displaying "off". A two-state control has no third state, but a field absent from `defaultValues` rendered an OFF switch backed by nothing — the create payload omitted the column (it lands null, which reads as unchecked but isn't) and the presence check above would still refuse it. `false` is now folded into `defaultValues` for every boolean-widget field the caller left unset; in `defaultValues` itself rather than per-Controller, because that object is also the dirty baseline and what the defaults-reset window replays. An authored default (or a loaded record, `null` included) still wins. WizardForm's cross-step gate had its own copy of the empty-value predicate and now imports the shared one. The field-demo renderer read `schema.defaultValue || schema.value`, throwing away an authored `false` / `0` / `''` — same falsy-as-empty class, now `??`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the renderer half of objectstack-ai/cloud#972 (authoring half:
objectstack-ai/cloud#977, merged).
The bug
An AI-built task tracker's 任务 object has a required
是否完成boolean. On thecreate form the switch showed OFF, submitting answered 「是否完成不能为空」 +
toast 「请检查表单中标记的字段:是否完成」, and turning the switch ON saved the
same form instantly. The app could only ever create ALREADY-DONE tasks — the one
state the control shows by default was the one value it refused to save.
Two defects stacked; either one alone breaks it.
1. The
requiredverdict read truthiness, not presence@objectstack/specFieldSchema.required(ADR-0113) is "an insert must providea non-null value", and objectql's record validator implements exactly that
(
isMissing=undefined | null | blank string). react-hook-form's built-inrule instead fails whenever
isBoolean(value) && !value— its accept-the-termscheckbox heritage — silently redefining every required boolean as "must be
TRUE". That also catches a required
selectwhose chosen option value isfalse(option values round-trip typed since #3090). And it disagreed in theother direction, letting a whitespace-only string through for the server to
reject with a 400.
The renderer no longer hands RHF its own
required. The check is now avalidateentry keyedrequired— RHF reports an object-form validate failureunder its key, so the error still surfaces as
type: 'required', which theconditional-required cleanup keys on — backed by a new shared
isMissingForRequiredin@object-ui/core, a deliberate mirror of the server'spredicate.
delete rules.requiredalso stops arequiredthat rode in onvalidationfrombuildValidationRulesfrom outliving arequiredWhenthatresolved to FALSE.
2. A boolean field held
undefinedwhile displaying "off"A two-state control has no third state, but a field with no entry in
defaultValuesrendered an OFF switch backed by nothing — so the form displayed"false" and submitted "nothing": the create payload omitted the column (it lands
null, which reads as unchecked but isn't) and the presence check above would
still refuse it. A blueprint field cannot carry a
defaultValue, so everyAI-authored boolean was in this state.
falseis now folded intodefaultValuesfor each boolean-widget field thecaller left unset — in
defaultValuesitself, not per-Controller, because thatobject is also the dirty-check baseline and what the defaults-reset window
replays; a value that only lived on the field is wiped by the first
form.reset. Doing it in the shared renderer covers every surface, includingthe modal/drawer create dialogs that start from a bare
{}and derive no schemadefaults at all. An authored default (or a loaded record,
nullincluded)still wins.
Also in scope — the same falsy-as-empty class
WizardForm's cross-step gate carried its own copy of the empty-valuepredicate (correct on
false/0, but it did not trim). It now imports theshared one, so the wizard and the per-field verdict cannot drift.
schema.defaultValue || schema.value, throwingaway an authored default of
false/0/''— now??.ObjectValidationEnginealready agreed;ImportWizard's!rawonly ever sees CSV strings, so"0"was never atrisk.
Tests
Full suite green (792 files / 9239 tests). New coverage in
form-required-falsy-values.test.tsxandObjectForm.requiredBoolean.test.tsx:unchecked required switch / checkbox /
false-valued select and a requirednumber at
0all submit;null,undefined,'', whitespace-only and[]are still refused; a field-authored required message and a field-authored
validateboth still work. Mutation-checked — reverting either half turns theguards red.
Verified live
Local stack against the exact metadata
apply_blueprintused to materialize(
{ type: 'boolean', required: true }, no default). Before: the reportreproduced verbatim. After: a 是否完成 = 否 task with 工时 = 0 creates and
persists as
{ hours: 0, is_done: false }; the switch ON still storestrue; ablank required text is still refused.
🤖 Generated with Claude Code