Skip to content

fix(console): honour the field-level maxLength override in buildSections - #5635

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-5595-buildsections-maxlength-override
Aug 21, 2026
Merged

fix(console): honour the field-level maxLength override in buildSections#5635
os-sales merged 1 commit into
mainfrom
claude/issue-5595-buildsections-maxlength-override

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#5595

The defect

apps/console/src/components/FormPage.tsxbuildSections merges a FormView's field
overrides with the target object's field definitions. Its own docstring states the rule:

Field-level FormField overrides take precedence over object defaults.

Every key in the loop is built that way — override.label ?? def.label,
override.required ?? def.required, override.placeholder ?? def.placeholder, … — except
one, which read the object definition unconditionally:

maxLength: def.maxLength,

So an author who set a tighter per-form limit (a short public intake form over a column
whose object-level ceiling is generous) silently got the generous one. No diagnostic, no
warning, and the form still submits — the symptom is a value the author believed the input
refused being accepted.

It is load-bearing rather than decorative: the merged row reaches the DOM at two
maxLength={field.maxLength} sites, the textarea arm and the default input type="text"
arm.

The fix, per triage's ruling on the fork the card posed — honour the override:

maxLength: override.maxLength??def.maxLength,

?? rather than ||, matching the sibling keys, so an explicitly declared 0 stays a
value the author wrote. This is a declared≠enforced restoration, not a feature: the
docstring already promised it and every sibling key already implemented it. The override
can only narrow what the input allows — the object's storage ceiling still decides at
submit time, so nothing accepted before is now rejected anywhere but at the keyboard.

Line numbers re-derived on current origin/main — all four had drifted

The card cited :340 / :367 / :875 / :983 against the merged ref cad512fe1.
#5594 (PR #5624) has since landed in this same file, adding predicate wiring, an exported
isFieldVisible and a RenderableField field. Located by code, not by number, at
f52d36c96:

WhatCard (cad512fe1)Actual (f52d36c96)
Docstring Field-level FormField overrides take precedence…340393
maxLength: def.maxLength,367425
maxLength={field.maxLength}textarea arm875978
maxLength={field.maxLength} — default input arm9831086

All four drifted, all in the same direction, by 53–103 lines. The mechanism the card
described was otherwise exactly right.

The pre-registered pin: INVERTED, not deleted

FormPage.fieldSpec.test.ts (added by #5542) carried
expect(row.maxLength).toBeUndefined() — recording the OLD answer explicitly rather than
assuming it. That assertion is inverted to expect(row.maxLength).toBe(40), and kept
in place.

Inverting rather than deleting, deliberately: the assertion is what made this gap findable
at all. #5542 chose to record whether each newly-declarable key was honoured instead of
assuming it, and the one recorded as not-honoured became this card. Deleting it would
retire the mechanism that produced the finding; keeping it means the same line now names
the honoured answer. Its objectSchema argument is null, so the 40 can only have come
from the field override.

The file's own docstring said "the keys this renderer does not honour are recorded as not
honoured rather than assumed" — updated in the same commit, since that sentence is no
longer true of this key.

Reverse verification — three legs, mutation confirmed on disk in both directions

The fix was committed first, so every leg restores against a real commit. Each leg is a
script carrying trap restore EXIT INT TERM, and each asserts anchored grep -c counts
for both the fixed line and the mutant line before it measures anything — an editor's
exit code is not evidence a replacement landed.

No rebuild leg is required here, and that is a property of the imports rather than an
omission:
the tests import ./FormPage, a relative path inside the same app, not an
exports-resolved package boundary. Nothing resolves through any dist/, so the mutated
source is what vitest loads. (The console's dependency closure was built before the
type-check — see below — but that is a different requirement.)

Leg A — the fix removed (maxLength: def.maxLength, — the pre-fix state)

Anchors: fixed 1 → 0, mutant 0 → 1, bytes 64351 → 64329.

Tests 5 failed | 65 passed (70)
× builds the same rows from a spec carrying the previously-undeclarable keys
AssertionError: expected undefined to be 40
× lets a tighter per-form maxLength beat the object ceiling
AssertionError: expected 200 to be 40
× keeps an explicit 0 rather than falling through to the object ceiling
AssertionError: expected 200 to be +0
× caps the text input at the FORM ceiling, not the object one
Expected the element to have attribute: maxlength="40"
Received: maxlength="200"
× caps the TEXTAREA arm too — a second, independent JSX site
Expected the element to have attribute: maxlength="120"
Received: maxlength="5000"

Direction observed: turns red, as predicted, and the two DOM legs reproduce the card's
symptom literally — the object's ceiling arriving on the element the user types into.

Leg B — the object fallback dropped (maxLength: override.maxLength,)

This is what makes the fallback cases controls rather than decoration: they are scoped to
the same merge branch and they can fail.

Anchors: fixed 1 → 0, mutant 0 → 1, bytes 64351 → 64334.

Tests 3 failed | 67 passed (70)
× CONTROL — falls back to the object ceiling when the form asks for none
× merges section fields with object schema definitions (pre-existing test)
× falls back to the object ceiling when the form declares no maxLength

All five leg-A cases stay green here. Without these controls, "the form's 40 wins" would
be equally satisfied by a merge that lost the object side entirely.

Leg C — a fabricated floor (override.maxLength ?? def.maxLength ?? 255)

The remaining control asserts the attribute is absent when neither side declares a
ceiling; leg C is what proves that assertion can fail.

Anchors: fixed 1 → 0, mutant 0 → 1, bytes 64351 → 64358.

Tests 2 failed | 68 passed (70)
× leaves maxLength undefined when neither side declares one
× CONTROL — no ceiling on either side leaves the attribute off entirely

Without it, every assertion above would be equally satisfied by a renderer that caps every
input at a number of its own choosing.

After all three legs the trap restored the tree to byte-identical with the commit
(git diff HEAD empty; fixed-anchor count back to 1).

Tests

Two homes, on purpose:

Gates run locally, at f078f7a9c — the final commit

Every result below was produced against this sha; nothing changed in the tree afterwards
(git status clean, git diff HEAD empty).

GateVerdict line
vitest run apps/console/ (repo root)Test Files 67 passed (67) · Tests 730 passed (730)
pnpm --filter @object-ui/console type-check0 errors (tsc --noEmit && tsc -b tsconfig.node.json --force)
check-changeset-presence.mjs✅ 4 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/formpage-maxlength-override-5595.md.
check-changeset-no-major.mjs✅ No changeset declares a major bump.
check-changeset-fixed.mjs✅ All workspace packages are in the changeset fixed group.
check-control-bytes.mjs✅ check-control-bytes: OK (scanned 4681 tracked text file(s); skipped 85 binary).
check-lint-coverage.mjs✅ lint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).
check-type-check-coverage.mjs✅ type-check coverage: 45/46 via type-check … 1 not compiled. + ✅ test type-check coverage: 41/41 packages compile their tests
check-phantom-dependencies.mjs✅ Every in-scope import is declared by the package that publishes it.
check-package-self-import.mjs✅ No package names itself inside its own src/.

Exit codes were captured before any pipe (cmd > file 2>&1; EXIT=$?), and each row
quotes the gate's own verdict line rather than a bare $?.

Two toolchain notes worth recording:

  • The first type-check returned 340 errors, all cascading from TS2307 /TS2882
    against workspace packages — the unbuilt-dependency-closure trap in a fresh worktree, not
    this change. pnpm --workspace-concurrency=2 --filter '@object-ui/console^...' build
    first, then 0 errors. Judging the first run would have sent someone chasing a
    non-existent problem in FormPage.tsx.
  • A first attempt at the console suite used --project '|@object-ui/console|' (copying the
    |…| decoration vitest prints around project names). It failed loudly
    No projects matched the filter — rather than passing while running nothing.

Lint — a declared narrowing, with its three pieces of evidence

pnpm lint (turbo run lint, 46 packages) is CI's run, and it was measured taking >5
minutes in this container while another agent held the shared verify lock. What was run
instead is the complete lint population of the one package this PR touches
eslint . inside apps/console, which is that package's own lint script verbatim:

  1. Population from eslint's own config, not a guess — the run enumerated the files
    itself; no path list was supplied to it.
  2. File count from --format json167 files, errorCount0, warningCount
    201. The new FormPage.maxLength.test.tsx appears in that enumeration with 0/0, so it
    is covered rather than silently ignored. The 201 warnings are pre-existing and none fall
    in the edited region (FormPage.tsx line 425–432); CI sets no --max-warnings, so
    errors are the gated number.
  3. Invariance for untouched fileseslint.config.js sets only ecmaVersion and
    globals under languageOptions; there is no parserOptions.project and no
    projectService, so typescript-eslint runs without type information. Each file is
    judged from its own syntax in isolation, so a diff confined to apps/console cannot move
    a verdict on any file in the other 45 packages.

Broken gauges, noted and not fixed, per dispatch: check-eager-closure-budget exits 2 until
a console build writes its report, and check-doc-snippet-types exits 1 until the workspace
is built.

Scope

out of scope: #5594 — that card is already landed (PR #5624) and this branch only rebases
onto its result. No other defect was folded in; nothing outside buildSections' merge loop,
its two test homes and the changeset is touched.


Generated by Claude Code

…ons (#5595)
`buildSections` merges a FormView's field overrides with the object's field
definitions, and its docstring promises field-level overrides take precedence.
Every key implemented that — `override.label ?? def.label`, `override.required
?? def.required`, … — except `maxLength`, which read `def.maxLength`
unconditionally, so a tighter per-form ceiling was discarded with no
diagnostic. The merged row reaches the DOM at two `maxLength={field.maxLength}`
sites, so the symptom is a value the author believed the input refused being
accepted.
`??` rather than `||`, matching the siblings: an explicitly declared `0` stays
a value the author wrote. The override can only narrow what the input allows;
the object's storage ceiling still decides at submit time.
The #5542 pin `expect(row.maxLength).toBeUndefined()` recorded the OLD answer
and is INVERTED to `toBe(40)` rather than deleted — it is the pre-registered
evidence for this fix and is what made the gap findable.
Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3785.8 KB3867.2 KB
Main entry chunk (gzip)151.7 KB350 KB
Entry fileindex-Dm9aRzhe.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 (index.js)10.04KB3.72KB
app-shell (runtime-config.js)8.91KB2.99KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
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)6.35KB2.43KB
auth (index.js)2.77KB1.22KB
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.02KB0.89KB
auth (useIsWorkspaceAdmin.js)3.04KB1.45KB
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)506.99KB113.73KB
core (index.js)4.51KB1.80KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.33KB
fields (index.js)238.85KB60.13KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.22KB3.08KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
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.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
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.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.65KB18.32KB
plugin-chatbot (index.js)181.41KB43.22KB
plugin-dashboard (index.js)128.36KB32.95KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)242.15KB60.89KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.07KB30.43KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.70KB27.17KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.50KB20.68KB
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)3.77KB1.33KB
react (SchemaRenderer.js)43.66KB14.77KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
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 (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-retry.js)4.32KB2.02KB
types (index.js)3.08KB1.53KB
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 (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
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

@os-sales
os-sales marked this pull request as ready for review August 21, 2026 21:10
@os-sales
os-sales added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit cdda37aAug 21, 2026
23 checks passed
@os-sales
os-sales deleted the claude/issue-5595-buildsections-maxlength-override branch August 21, 2026 21:10
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-sales@claude