Skip to content

fix(form): a server rejection that names fields now marks those fields (objectstack#3896) - #2966

Merged
os-zhuang merged 1 commit into
mainfrom
claude/sharing-rules-schema-bypass-h4c7xr
Jul 29, 2026
Merged

fix(form): a server rejection that names fields now marks those fields (objectstack#3896)#2966
os-zhuang merged 1 commit into
mainfrom
claude/sharing-rules-schema-bypass-h4c7xr

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

The server always said which field

@objectstack/objectql's validators throw VALIDATION_FAILED with fields[] — one entry per offending field, each carrying a human message — and both the REST layer and the runtime dispatcher serve that as a 400 with the entries intact.

Every form dropped them. The submit handler caught the rejection, ran the message through extractWriteErrorMessage, and showed one undirected toast. The user was told something was wrong but not what — on a surface that already knows how to mark an input, and already does exactly that for client-side validation. On a long form the offending field is usually off-screen, so the submit button appears to do nothing.

This is the general fix behind the specific one in #2962. That PR had to add a client-side hint precisely because the server's precise rejection only ever arrived as a toast, after Save.

Now both failures share one implementation

The toast naming the fields and the scroll-and-focus of the first offender (#2793) were extracted from the client-side invalid handler into announceFieldErrors; the server path calls it. To the person filling in the form these are the same event — only the referee differs.

Three layers, each of which was dropping the detail:

@object-ui/react — new extractFieldErrors(err), exported beside extractWriteErrorMessage / isPermissionError. Normalises the three shapes the error can arrive in:

ShapeWhere it comes from
validationErrorsa typed ValidationError from the ObjectStack adapter
details.fieldsthe raw @objectstack/client error — its details falls back to the whole response body, and the validation envelope has no details key, so this is where fields[] lands
fieldsa hand-rolled error of the same shape; the server duck-types these identically, so the client must not be pickier than the server

Entries with no usable field are dropped rather than guessed at — marking an innocent input is worse than the generic toast we already show.

@object-ui/data-objectstacknormaliseClientError maps a 400 VALIDATION_FAILED onto the ValidationError class that has sat in errors.ts since the package was written: exported, and never once constructed. Its validationErrors: Array<{ field, message }> shape was already exactly right for this.

create also now normalises at all. Only update did, so a rejected insert reached callers as the raw client error with no typed shape to branch on — and a create is the path that most often trips required-field validation.

@object-ui/components — the form renderer applies the entries via form.setError and takes over the failure, but only when every rejected field has a visible input to carry it. If the server also rejected something this form does not render, it falls through to the banner, whose top-level message concatenates every field's reason — so the part the user cannot see inline is still said out loud instead of silently dropped.

Why this matters beyond one form

It removes the reason for the client-side predicate mirroring added in #2962. A form should not have to guess what the server will reject in order to warn about it beforehand — mirrored predicates drift, and the copy that drifts is the one users read.

Verification

  • 14 new unit tests across the two helpers — the real wire envelope, the adapter's typed error, the hand-rolled shape, the message/code fallback, entries with no field, and the non-field failures that must stay on the old path.
  • 6 form-renderer tests driving a real rejected submit: the reason appears under the input, the first offender is scrolled to and focused, the toast names the field's label, every rejected field is marked, an unrenderable field falls through to the banner, and a 403 marks nothing.
  • 107 files / 1073 tests green across components, plugin-form, react, data-objectstack. tsc --noEmit clean on all three changed packages. eslint 0 errors (remaining warnings are the file's pre-existing no-explicit-any convention).

Non-field failures — 403, permission denials, anything without fields[] — take exactly the path they took before.


Generated by Claude Code

…s (objectstack#3896)
The server has always said which field it rejected. objectql's validators throw
VALIDATION_FAILED with fields[] — one entry per offending field, each carrying a
human message — and both the REST layer and the runtime dispatcher serve that as
a 400 with the entries intact.
Every form dropped them. The submit handler caught the rejection, ran the
message through extractWriteErrorMessage, and showed one undirected toast: the
user was told something was wrong but not WHAT, on a surface that already knows
how to mark an input and already does exactly that for client-side validation.
On a long form the offending field was often off-screen, so the submit button
appeared to do nothing.
Now the two failures share one implementation. The toast naming the fields and
the scroll-and-focus of the first offender (#2793) were extracted from the
client-side invalid handler into `announceFieldErrors`; the server path calls
it. To the person filling in the form these are the same event — only the
referee differs.
Three layers, each of which was dropping the detail:
- @object-ui/react: new extractFieldErrors() normalises the three shapes the
error can arrive in — a typed ValidationError from the adapter, the raw
client error (whose `details` falls back to the whole response body, which is
where fields[] lands), and a hand-rolled error carrying `fields` directly, a
shape the server duck-types identically. Entries with no usable field are
dropped rather than guessed at: marking an innocent input is worse than the
generic toast.
- @object-ui/data-objectstack: normaliseClientError maps a 400
VALIDATION_FAILED onto the ValidationError class that has sat in errors.ts
exported and never once constructed — its validationErrors shape was already
exactly right. `create` now normalises at all: only `update` did, so a
rejected insert reached callers as the raw client error, and create is the
path that most often trips required-field validation.
- @object-ui/components: the form renderer applies the entries via
form.setError and takes over the failure ONLY when every rejected field has a
visible input to carry it. If the server also rejected something this form
does not render, it falls through to the banner, whose top-level message names
every field — so the part the user cannot see inline is still said out loud.
Non-field failures (403 / permission denials / anything without fields[]) take
exactly the path they took before.
Verification: 14 new unit tests across the two helpers plus 6 form-renderer
tests driving a real rejected submit; 107 files / 1073 tests green across
components, plugin-form, react and data-objectstack; tsc clean on all three
packages; eslint 0 errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuViRSR1j6GJjf9qGbnqFX
@vercel

vercelBot commented Jul 29, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredJul 29, 2026 12:19pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)27.9 KB350 KB
Entry fileindex-CWevfLbx.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.20KB2.97KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.12KB3.41KB
auth (LoginForm.js)17.86KB5.29KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.43KB2.09KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.25KB1.01KB
auth (org-roles.js)6.72KB2.85KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)18.38KB4.49KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)3.65KB1.42KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.25KB0.53KB
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)450.54KB98.04KB
core (index.js)2.16KB0.78KB
create-plugin (index.js)9.28KB2.98KB
data-objectstack (index.js)129.96KB32.71KB
fields (index.js)221.06KB54.17KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.46KB0.96KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)5.37KB1.72KB
i18n (useObjectLabel.js)25.17KB5.80KB
i18n (useSafeTranslation.js)3.26KB1.44KB
layout (index.js)38.45KB10.67KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.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)4.42KB1.27KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)1.77KB0.77KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)6.84KB2.42KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.90KB12.35KB
plugin-charts (index.js)57.26KB16.24KB
plugin-chatbot (index.js)179.93KB42.67KB
plugin-dashboard (index.js)109.60KB28.33KB
plugin-designer (index.js)210.56KB42.56KB
plugin-detail (index.js)216.52KB53.02KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)103.32KB25.08KB
plugin-gantt (index.js)162.26KB39.53KB
plugin-grid (index.js)179.45KB47.03KB
plugin-kanban (index.js)47.82KB13.18KB
plugin-list (index.js)98.19KB23.17KB
plugin-map (index.js)16.80KB5.24KB
plugin-markdown (index.js)13.65KB4.67KB
plugin-report (index.js)37.77KB10.00KB
plugin-timeline (index.js)25.03KB7.11KB
plugin-tree (index.js)8.36KB2.81KB
plugin-view (index.js)85.47KB20.82KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.55KB0.67KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)3.19KB1.38KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)18.70KB6.09KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)2.16KB0.94KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
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)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)0.77KB0.41KB
types (disclosure.js)0.20KB0.18KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (index.js)1.86KB0.91KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)0.20KB0.18KB
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.04KB1.93KB
types (system-fields.js)2.39KB1.17KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)0.75KB0.46KB
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-zhuang
os-zhuang marked this pull request as ready for review July 29, 2026 23:35
@os-zhuang
os-zhuang merged commit 0ded602 into mainJul 29, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/sharing-rules-schema-bypass-h4c7xr branch July 29, 2026 23:36
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-zhuang@claude