Skip to content

fix(components): requiredWhen decides at submit time, not on the first TRUE it ever saw (#4161) - #4201

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4161-requiredwhen-submit-snapshot
Aug 10, 2026
Merged

fix(components): requiredWhen decides at submit time, not on the first TRUE it ever saw (#4161)#4201
yinlianghui merged 1 commit into
mainfrom
claude/issue-4161-requiredwhen-submit-snapshot

Conversation

@yinlianghui

@yinlianghui yinlianghui commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #4161

The symptom, reproduced

A component-level pin renders the reporter's exact shape — a plain field a, and a field b carrying requiredWhen: '!(has(record.a) && record.a == "x")' — then drives the reported sequence: predicate TRUE at mount, user sets a = "x", submit.

On origin/main @ cb13400, with the tests present and the fix reverted:

× ... > lets submit through once the predicate flips FALSE after mount
✓ ... > still refuses submit when the predicate flips TRUE after mount
× ... > re-opens submit after a blocked attempt once the predicate flips FALSE
✓ ... > keeps enforcing a STATICALLY required field (control)
✓ ... > saves an edit form whose predicate was already FALSE at mount (control)

AssertionError: expected "vi.fn()" to be called at least once

The failing assertion is the submit handler never being called — the client-side half of the reporter's "zero POST". The same test asserts, one line earlier, that aria-required HAS already disappeared from the control, so what is pinned is the divergence between the two layers, not "the predicate never re-evaluated".

Root cause — the mount-time-snapshot hypothesis does not survive measurement

The issue reads "submit-time validation uses the mount-time snapshot". The observable symptom is right; that mechanism is not, and the discriminator is in the run above: the flip-to-TRUE direction is GREEN on unfixed main. A predicate that becomes TRUE after mount does start blocking submit. A mount-time snapshot could not do that.

What actually happens, from react-hook-form@7.84.0:

  1. useController calls control.register(name, { ...props.rules, value }) inside a React.useRef(...) initializer argument. useRef discards the result after the first render, but the argument expression is still evaluated on every render — so registration does run per render, with the current rules. Measured with an instrumented probe: three register calls for the field in the renders following the flip.
  2. register writes _f: { ...(field && field._f ? field._f : { ref }), name, mount: true, ...options } — an additive merge. A key absent from options is not deleted.

So the rules object could gain entries live but never lose them. The renderer only put validate.required into rules while required was true (form.tsx, the old if (required) { ... } block); once the predicate went FALSE the key simply stopped being spelled, the merge left the previous validator in place, and the field stayed required forever. Same probe, after the flip:

AT MOUNT (a="" => required TRUE)   b._f keys = [ 'ref','name','mount','validate','value' ] | validate = [ 'required' ]
AFTER FLIP TO FALSE (a="x")        b._f keys = [ 'ref','name','mount','validate','value' ] | validate = [ 'required' ]
register calls for b since flip: [{"name":"b","keys":["value"]},{"name":"b","keys":["value"]},{"name":"b","keys":["value"]}]

The validation layer was append-only, latched on the first TRUE the field ever produced — not snapshotted at mount. The two hypotheses agree on every control the reporter ran (star flips; edit dialog already-FALSE at mount saves; direct POST 201) and disagree only on the TRUE direction, which is why the filing landed where it did.

The fix

packages/components/src/renderers/form/form.tsx, one call site:

  • The validate.required entry is registered unconditionally and decides required-ness when it runs. Always spelling the key gives RHF's merge something to overwrite; deciding at call time means the answer is right even for the closure RHF captured at mount, so the fix does not rest on that useRef argument re-evaluation continuing to happen.
  • The verdict it reads is published by the renderer on every render into a per-form Map — and it is the same resolveFieldRuleState result that draws the asterisk and aria-required, taken from the same variable. One evaluation, two consumers.

Deliberately not done: re-evaluating the predicate inside the validator. That would be a second evaluation site needing its own copy of the record assembly (the null seeding of declared fields, the previousRecord overlay) — the exact drift this issue is about. No change to @object-ui/core; resolveFieldRuleState was always correct, and nothing new was added to its contract.

Scope and adjacency

Verification

Reverse verification ran in the predicted direction (predicted before running): revert form.tsx only, keep the tests → the two flip-to-FALSE pins go red with the filing's signature (submit handler uncalled), the symmetric TRUE pin and both controls stay green. Output quoted at the top of this description.

Local, repo root, on the pushed tree:

$ pnpm exec vitest run packages/components/src/renderers/form/__tests__/{form-required-when-submit,form-required-falsy-values,form-aria-required-delivery,form-readonly-when-previous,form-select-value-survives-rules}.test.tsx
 Test Files  5 passed (5)
      Tests  33 passed (33)

$ pnpm exec eslint packages/components/src/renderers/form/form.tsx  (plus the new test file)
✖ 100 problems (0 errors, 100 warnings)   # all pre-existing; none on a changed line

$ node scripts/check-control-bytes.mjs
✅  check-control-bytes: OK (scanned 3877 tracked text file(s); skipped 85 binary).

CI on ec6a9ee — all 20 checks concluded, zero failures: Lint ✓ (this is where the family gates run), Type Check ✓, Test shards 1/4 ✓ 2/4 ✓ 3/4 ✓ 4/4 ✓ (shard 1 alone: 297 files / 3731 tests passed), Build & E2E ✓, Build Docs ✓, Bundle Analysis ✓, Control Byte Scan ✓, Changeset Declaration / Bump Policy / Fixed Group ✓, Internal Docs Link Check ✓, Skill Guide Path Check ✓, Live E2E (informational) ✓.

The whole-package packages/components/ local run was queued on the shared verification lock behind another agent's whole-repo run for the length of the task and was cancelled as redundant once CI's four shards — which run every file in that package on this exact commit — came back green.


Generated by Claude Code

…t TRUE it ever saw

A `requiredWhen` predicate that flipped to FALSE after the dialog mounted
updated only the display layer: the asterisk and `aria-required` both
disappeared, while submit stayed refused with "<field> is required" and no
write was ever issued (objectui#4161).

The cause is not a mount-time snapshot, which is what the symptom looks like.
The renderer hands react-hook-form its per-field rules as a `<Controller
rules>` prop, and RHF MERGES that object into the field descriptor it already
holds -- `_f: { ...previous._f, ...options }` -- so a rule key that stops being
spelled is never removed. Rules could be added live (a predicate flipping TRUE
after mount did start enforcing) but never withdrawn: the `validate.required`
entry installed on the first TRUE outlived every later FALSE. The validation
layer was append-only, latched.

The entry is now registered unconditionally and decides required-ness when it
runs, reading the live verdict the renderer publishes each render -- the same
single `resolveFieldRuleState` result that draws the asterisk, not a second
evaluation of the predicate with its own copy of the record assembly.

Both directions pinned, plus the two controls from the report: statically
required fields still enforce, and an edit form whose predicate was already
FALSE at mount still saves.

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 8:35pm

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-D7oReNoe.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.88KB 3.25KB
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) 486.25KB 107.52KB
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) 226.96KB 56.30KB
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.87KB 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.52KB 17.49KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 118.52KB 30.68KB
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.58KB 27.68KB
plugin-gantt (index.js) 162.81KB 39.67KB
plugin-grid (index.js) 188.04KB 49.91KB
plugin-kanban (index.js) 48.60KB 13.41KB
plugin-list (index.js) 110.04KB 26.67KB
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

@yinlianghui
yinlianghui marked this pull request as ready for review August 10, 2026 20:47
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 10, 2026
Merged via the queue into main with commit b1e42d0 Aug 10, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4161-requiredwhen-submit-snapshot branch August 10, 2026 20:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants