fix(fields): honour an authored max_length on every rich-content field (objectui#8438) - #8768
Conversation
…d (objectui#8438)
`markdown`, `html` and `richtext` are three registry keys served by ONE widget,
`RichTextField` — and that widget read `maxLength` / `max_length` nowhere, while
`buildValidationRules` (no field-type gate) compiled the same key into a
react-hook-form rule for every field. A cap authored on any of the three was
therefore enforced at SUBMIT and invisible before then: no native stop, no
character counter, nothing named in `aria-describedby`.
The card was filed as "`richtext` is missing from ObjectForm's maxLength guard
and EmbeddableForm's DEFAULT_MAX_LENGTH". Re-measured on the branch base,
neither list could have carried the cap:
- `ObjectForm`'s guard writes `formField.maxLength`, but a registered widget's
metadata carrier is `formField.field` — a different object. Ablating that
assignment changed no rendered attribute for any of its four types, on
either the registered or the builtin render path. Left in place (it is live
for the other form-field producer) with the measurement recorded at the site.
- `EmbeddableForm`'s `DEFAULT_MAX_LENGTH` did deliver 5000 for `markdown` and
`html`; `RichTextField` then dropped it unread.
So the cap was lost for all three keys, not for `richtext` alone. `RichTextField`
now dual-reads `maxLength ?? max_length` off its carrier — the same read
`TextAreaField` has carried since framework#1878 §3 — and forwards it to the
native stop, the `CharacterCount` counter and the `aria-describedby` wiring, on
both the inline surface and the fullscreen dialog.
The list question objectui#4831 asked and its fix declined to answer is answered
here: `RICH_TEXT_FIELD_TYPES` is derived from `RICH_TEXT_CELL_RENDERERS` — THE
table — and published, and `EmbeddableForm`'s cap table spreads it instead of
naming rich-content types. A list that names no member can omit none.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH
✅ Console Performance Budget
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
Size Limits
|
|
Director seat adoption record — POST-MERGE audit — summon #20, Contract review (
|
| Rule | Reading | Status |
|---|---|---|
| Increment = landed change | Squash merge: c03d03bb2d has one parent (efbd566d), PR head not an ancestor. git diff c03d03bb2d^1 c03d03bb2d is byte-identical (ignoring index lines) to git diff e3fb3b62(merge-base) refs/pr-review/8768. 8 files, +515/−13. |
✅ |
Card routing constraint — answer the list question, never add 'richtext' twice |
EmbeddableForm: derived (spreads RICH_TEXT_FIELD_TYPES). ObjectForm: unchanged, comment records why. No 'richtext' literal added anywhere. |
✅ |
| Card pin rule — by behaviour, not by list membership | richtextMaxLength.8438.test.tsx: every assertion reads DOM (maxlength attr, counter text, aria-describedby resolved to node text). The only two list-symbol mentions are in the docblock (lines 24, 35). |
✅ |
Acceptance literal "removing 'richtext' from either list turns both red" |
Unsatisfiable as written (no literal exists post-change). PR substitutes ablations A (widget read) and B (table key); stated, not absorbed. Accepted as the stronger form. | ✅ (declared) |
| Claim file surface | packages/types/src/field-types.ts was outside both lists; edited comment-only, declared as a deviation. |
✅ (declared) |
| Clause-② declaration | Claim said no ("no new published surface"); diff adds barrel exports ⇒ yes mandatory under the directional rule. Seat corrected at 5596159712. |
✅ corrected — see F2 for completeness |
| Contract-review tier | In-seat review 5596181770 ran at opus (TIER_DEFAULT) under a "quota-exhaustion exemption". objectstack:.claude/skills/pm-dispatch/references/contract-review.md:60: 「契约复核 ⛔ 不适用额度耗尽豁免降档:豁免对象是派发」. The merged head had no mandated-tier verdict until this one. |
|
| Dual carrier (2026-08-22 ruling: label on BOTH carriers) | Card #8438 events: needs:contract-review labeled 05:09:50Z, unlabeled 05:13:43Z. PR #8768 events: never labeled. |
|
| Governed surface | node scripts/check-governed-queue-guard.mjs --test <8 paths> → NOT GOVERNED. No docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md. |
✅ |
| Changeset presence | .changeset/8438-richtext-maxlength-visible.md present; Changeset Declaration / Bump Policy / Fixed Group / Overwrite checks green. |
✅ |
| Release-notes prohibition | content/docs/releases/ untouched. |
✅ |
Derived judgments
(a) Published barrel of @object-ui/fields. One new line at packages/fields/src/index.tsx:2528: export { RICH_TEXT_FIELD_TYPES, type RichTextFieldType } from './widgets/richTextDisplay.js';. Neither name was on the barrel at the base parent (grep on efbd566d = 0 hits). Shapes: RICH_TEXT_FIELD_TYPES: readonly RichTextFieldType[] = Object.keys(RICH_TEXT_CELL_RENDERERS) as RichTextFieldType[] (richTextDisplay.tsx:199-201), value ['markdown','html','richtext']; RichTextFieldType = keyof typeof RICH_TEXT_CELL_RENDERERS (line 174, pre-existing at module level). RICH_TEXT_CELL_RENDERERS, richTextSyntax, richTextCellRenderer remain unpublished. Consumers at merge: RICH_TEXT_FIELD_TYPES → packages/plugin-form/src/EmbeddableForm.tsx:27,101 only; RichTextFieldType → none outside its module. The seat's Point-1 argument checks out: packages/fields/package.json exports has no deep subpaths, so a sibling package cannot reach the table except via the barrel. plugin-form already depended on @object-ui/fields (5 prior imports) — no new edge, no phantom dep.
(b) RichTextField and the three keys. packages/fields/src/index.tsx:3059-3061 resolves markdown/html/richtext to the one RichTextField; the widget discriminates on type only for display (resolveRichTextFieldType, richTextSyntax). The new read at RichTextField.tsx:382 — (field as { maxLength?: number }).maxLength ?? richField?.max_length — sits above the type discrimination, so behaviour is identical for all three keys by construction. What happens at the cap: the value is forwarded as native maxLength on the <Textarea> (:146, both inline and dialog — Textarea is a plain React.ComponentProps<'textarea'> spread), so the browser refuses further input; nothing truncates a pre-existing over-long value, CharacterCount reports the honest n of max and clamps the near-limit status to 0 left; the submit-time rule from buildValidationRules (index.tsx:2889, no type gate) is unchanged and still rejects over-long stored values. Consistency with the sibling: line-for-line the same as TextAreaField.tsx:80,91-93,122-125,136-158,190-240 — same dual read, same useId pair, same CharacterCount with announceNearLimit inline / false in the dialog, same appended-vs-assigned aria-describedby split, same footer={(draft)=>…} render-prop (FullscreenFieldEditor passes footer through). Only the testId strings and font-mono differ.
(c) The two lists. EmbeddableForm DEFAULT_MAX_LENGTH (:95-102): markdown: 5000, html: 5000 literals replaced by ...Object.fromEntries(RICH_TEXT_FIELD_TYPES.map(t => [t, LONG_TEXT_MAX_LENGTH])); four short-text literals kept, textarea now via the named LONG_TEXT_MAX_LENGTH. ObjectForm guard (:877-884): code unchanged (still text | textarea | markdown | html), 27-line comment added above it. No third list exists; git grep RICH_TEXT_FIELD_TYPES shows exactly one consumer. The derived route lands in one of the two sites; the other is deliberately left as-is — see F3 for the accuracy of the comment justifying that.
(d) packages/types/src/field-types.ts. 18 changed lines; filtering out lines beginning * / /** / */ leaves zero — comment-only, no runtime or type change. The rewritten docblock (:348-359) is now consistent with the landed behaviour.
(e) Surface delta. New exports: 1 runtime value + 1 type (above). Removed exports: none. package.json of fields/plugin-form/types: untouched (no exports/sideEffects/files movement). New keys on published payloads: none — applyDefaultMaxLengths now also stamps maxLength: 5000 onto a richtext customField, but that is the in-memory FormField config (same key it already stamped on markdown/html), not a wire payload; the submit payload is unchanged.
Semver / changeset
.changeset/8438-richtext-maxlength-visible.md: '@object-ui/fields': minor, '@object-ui/plugin-form': patch. No major (AGENTS.md:240 rule; check-changeset-no-major green).
fields: minor— right: a new published export is additive surface.plugin-form: patch— defensible: a bug fix whose behaviour change (public-formrichtextnow capped at 5000 like its siblings; authored caps now visible) is stated in the body. It narrows accepted input on public forms, which some would callminor; moot for versioning since the 39-packagefixedgroup takes the max level (minor) anyway.@object-ui/types—src/moved (comment-only) and is not named in the frontmatter.scripts/check-changeset-presence.mjsis a presence gate ("an EMPTY frontmatter counts"), not a per-package naming gate, so this is compliant, and a comment-only change warrants no bump. Note only (F7).- Governed paths in the diff: none.
Boundary flags
- Clause-②:
yes(published surface grows). The seat's correction is right. It is incomplete on the carrier side: the label was placed on the card only, never on PR fix(fields): honour an authored max_length on every rich-content field (objectui#8438) #8768 (F2). - The mechanical widening-tell gate is blind to this class in objectui.
node scripts/pm/check-widening-tells.mjs --declaration no --diff landed.diff→no widening tell(exit 0): T3 reads onlypackages/spec/api-surface/*.json, which objectui does not have, so a barrelexport {…}is invisible to it. The falsenowas caught by the dev's self-report, not by the gate (F4).check-clause2-carriers.mjs --pair 8768exits 2 post-merge (PR not open) — no reading, as expected. - Cross-package: new
@object-ui/fields→@object-ui/plugin-formimport edge is pre-existing; no cycle (richTextDisplayimports nothing from plugin-form). - Bundle:
Bundle AnalysisandConsole Performance Budgetgreen;fields (index.js)62.05 KB gzip.
Findings
- F1 (non-blocking, process record) — no mandated-tier verdict at merge. Card comment 5596181770 applied the quota-exhaustion exemption to the contract review;
contract-review.md:60restricts that exemption to dispatch. The landed note 5596421332 records the downgrade honestly. This review supplies the missing verdict; nothing to fix in code. - F2 (non-blocking, process record) — dual carrier not honoured.
needs:contract-reviewwas applied to finding(plugin-form):richtextis missing from ObjectForm's maxLength guard and EmbeddableForm's DEFAULT_MAX_LENGTH — amax_lengthon a richtext field is enforced at SUBMIT but invisible in the UI, and uncapped by default #8438 (05:09:50Z) and never to fix(fields): honour an authored max_length on every rich-content field (objectui#8438) #8768 (PR events: onlypackage: types,plugin,tests,package: fields, thenready_for_review05:20:04Z). The 2026-08-22 ruling puts it on both. Moot for a merged PR; recorded for the round report. - F3 (non-blocking, source-comment accuracy) —
packages/plugin-form/src/ObjectForm.tsx:850-863overclaims. The comment states the guard "reaches nothing, for every one of the four types it names" and "Ablation, both configurations … byte-identical". Static trace at the merged head says the guard IS live for this producer in the no-registration configuration:mapFieldTypeToFormType('textarea')='field:textarea'(field-type-alias.ts:111); withfield:textareaunregistered,renderFieldComponent(form.tsx:3647-3655) finds no widget,switch (type)matches nocaseand falls todefault:(form.tsx:3892), which reads(fieldProps as any).maxLength(:4001) and renders<Input maxLength>(:4030) — andfieldPropscarries exactly the key the guard writes (maxLengthis not pulled off in theform.tsx:2292-2310destructure and rides through the:2791spread). The claim holds on the registered-widget path (widgets read thefieldcarrier;toDomPropsdoes not forwardmaxLength), which is the production configuration. Not executed here. Suggested docs-only touch: narrow the comment to "the registered-widget path". No runtime effect; the comment already says leave the guard in place. - F4 (non-blocking, out of scope for this card) — gate gap.
check-widening-tells.mjsT3 cannot see objectui barrel exports (noapi-surfaceledger). Worth an objectstack note; not this PR's defect. - F5 (non-blocking) — README.
packages/fields/README.mdhas no mention ofRICH_TEXT_FIELD_TYPES;check:readme-exportsonly checks that README-cited names exist (not the reverse), so no gate is owed. Optional docs follow-up. - F6 (non-blocking, nit) —
RichTextFieldTypere-export has zero external consumers. Justified as the element type of the published array (otherwise unnameable by consumers); acceptable minimal growth. - F7 (non-blocking) —
@object-ui/typesnot named in the changeset for a comment-onlysrc/edit. Compliant with the presence gate; moot in the fixed group. - F8 (non-blocking, confirmation) — test file honesty. No assertion reads a source list; negative controls present (uncapped rich field: no
maxlength, 0 counters, nocharcountinaria-describedby;number/jsonget no default).describedTextresolves ids rather than string-matching; its tolerance of dangling ids is stated and is theFormControlupstream-parity note the dev left un-filed, correctly.
Dev report open_questions / deviations, answered:
- OQ1 (apply
needs:contract-review?) — A is right; the seat applied it on the card. Complete only when both carriers carry it (F2). - D1 (new published surface vs claim's
no) — correctly flagged, not absorbed;Clause-②: yesstands. - D2 (file surface +1,
field-types.ts) — verified comment-only; the "a published statement my change falsifies is mine to fix" ground holds. - D3 (scope shape: fix in
RichTextField) — not creep; the card's own acceptance criterion (native attribute + counter on arichtextfield) is unreachable from either list because the widget never read the key. Verified by static trace of the carrier split (form.tsx:2796field: field.field || field). - D4 (ObjectForm list left without
richtext) — correct outcome; the recorded justification is over-broad (F3).
CI at merge
Head 3b991c926f: 35 check runs, 34 latest-per-name — 31 success, 3 skipped, 0 failure/cancelled. Skipped: Test (coverage shard ${{ matrix.shard }}/4), Test (coverage), dependabot (all workflow-conditional, not gates). Green includes Type Check, Lint, Test (shard 1-4/4), Build & E2E, Changeset Declaration, Changeset Bump Policy, Changeset Fixed Group Check, Governed Surface Queue Guard, README Export Check, Bundle Analysis, Control Byte Scan, Inert vi.mock Specifier Check. Combined status: success (Vercel). Merged 2026-09-09T05:36:33Z by os-zhuang as c03d03bb2d.
Implemented-by: branch claude/issue-8438-richtext-maxlength-guard (merged as c03d03bb2d)
Reviewed-by: director seat summon #20 (isolated fable subagent, transcript-verified before adoption)
{"pr":8768,"repo":"objectui","head":"3b991c926ff77e95c14dda7ea51a9b02555b6f74","verdict":"PASS WITH FINDINGS","blocking":[],"clause2":"yes","semver_ok":true,"governed":false,"ci":"34 latest-per-name: 31 success, 3 skipped (Test (coverage shard), Test (coverage), dependabot), 0 failed; combined status success"}
Generated by Claude Code
Fixes #8438
What this is
markdown,htmlandrichtextare three registry keys served by ONE widget,RichTextField. That widget readmaxLength/max_lengthnowhere, whilebuildValidationRules— which has no field-type gate — compiled the same key into a react-hook-form rule for every field. So a cap authored on any of the three was enforced at submit and invisible before then: no native stop, no character counter, nothing named inaria-describedby. The person is told the limit only after writing the text.The card's two sites were re-measured, and neither could carry the cap
The card is filed as "
richtextis missing from ObjectForm's maxLength guard and EmbeddableForm's DEFAULT_MAX_LENGTH". Both premises were tested on this branch's base (e3fb3b6) by rendering a form with the four long-text types and reading the DOM:textarea,max_length: 1111110/111markdown,max_length: 222html,max_length: 333richtext,max_length: 444⇒ the card's "two of the three get the cap" does not hold: none of the three did. The two literals
markdownandhtmlsitting in ObjectForm's guard were inert, and so wasmarkdown: 5000/html: 5000in EmbeddableForm — the card's line "a public form'srichtextfield takes unbounded input where itsmarkdowntwin stops at 5000" is false in its second half. Themarkdowntwin did not stop at 5000 either.Then the guard itself was ablated — the whole
formField.maxLength/formField.minLengthassignment replaced by a comment — and the rendered output was byte-identical, on both the registered-widget path and the builtin path.formFieldis the FORM FIELD; the metadata carrier a registered widget reads isformField.field, a different object, so that assignment has never reached a registered widget for any of the four types it names.⇒ Adding
'richtext'to that list — literally OR derived — would have changed nothing observable. That is exactly the false-green form objectui#4250 named and objectui#4831 warned about, arrived at from the other direction.The list question, answered
The routing constraint on this card was to answer objectui#4831's question ("should the list become every type that reaches the long-text widget rather than hand-written literals?") rather than patch two literals silently. Both halves are answered, differently, because measurement says the two sites are not the same kind of thing:
RICH_TEXT_CELL_RENDERERSinpackages/fields/src/widgets/richTextDisplay.tsx, whose docblock says "THE table, singular … there is no second copy left to forget", with objectui#5498 precedent for consumers spreading it. Its key set is now published asRICH_TEXT_FIELD_TYPESandDEFAULT_MAX_LENGTHspreads it. That table no longer names a rich-content type, so it can no longer omit one. The four short-text entries stay literal on purpose — each is a distinct widget with a distinct cap and no table to derive from; the root cause being removed is "one widget, N keys, a list that knows N-1".RichTextFieldnow dual-readsmaxLength ?? max_lengthoff its carrier — the same readTextAreaFieldhas carried since framework#1878 §3 — and forwards it to the native stop, the sharedCharacterCount, and thearia-describedbywiring, on the inline surface and the fullscreen dialog. Three keys, one widget, one read: this is the derived answer, taken one level below the lists.Pinned by behaviour, and the ablations
packages/plugin-form/src/richtextMaxLength.8438.test.tsx, 10 tests. Nothing asserts that a literal is in a list. Every assertion reads the DOM: the native stop, the counter's digits, and the text of the nodes the control NAMES inaria-describedby(ids resolved, not string-matched).markdownandhtmlare asserted besiderichtextbecause they are the same widget. Two negative controls: a rich field with no authored cap gets no stop and no counter, and a type outside the table gets no default.Two one-shot ablations, each mutating a committed file, proving the mutation landed on disk by anchor count and blob hash, and restoring via
git checkout HEAD --verified by an identical blob hash:maxLengthdual-read inRichTextField⇒Tests 6 failed | 4 passed (10). All three ObjectForm cap tests AND all three EmbeddableForm default-cap tests turn red together; the four that stay green are the two controls, the textarea sibling and the authored-cap-wins unit, none of which depend on the read.richtextfromRICH_TEXT_CELL_RENDERERS⇒Tests 1 failed | 9 passed (10), and the one isrichtext: an uncapped public-form field is capped at the long-text default.'richtext'from either list turn both tests red. After this change there is no'richtext'literal in either list to remove — that is the point of the route — so the criterion is met in its stronger form instead: ablation A is a single deletion that reddens both halves at once, and ablation B shows the EmbeddableForm half is genuinely carried by the derivation and not by something incidental.Behaviour change, stated
markdown/html/richtextfield that already declaresmax_length(ormaxLength) now shows a counter and stops at the cap. Before, it silently accepted the overflow and failed on submit. A field with no authored cap is unchanged.EmbeddableForm, a public form'srichtextfield is now capped at the 5000 long-text default like its two siblings.The dispatch's claim recorded
Clause-②: nowith the rationale "no accept-set widening and no new published surface". The derived route needs one:@object-ui/fieldsgainsRICH_TEXT_FIELD_TYPES(a readonly string array) and re-exports theRichTextFieldTypeunion. Purely additive, no removal, no signature change — but it IS new published surface, so the claim's rationale is not fully true of the landed change and that is flagged here rather than absorbed.packages/types/src/field-types.tsis also edited (comment only): itsRichtextFieldMetadatadocblock asserted that these two form-side sites "are not corrected here", which this change makes false, and both of its readings were wrong besides.Gates run locally
scripts/pm/dispatch-gates.mjsdoes not exist in this repo, so the family was derived by hand from the changed-file surface. Commands, and the verdict line each printed:pnpm --filter @object-ui/fields testTest Files 149 passed (149)·Tests 2546 passed (2546)pnpm --filter @object-ui/plugin-form testTest Files 87 passed (87)·Tests 871 passed | 1 skipped (872)pnpm --filter @object-ui/{fields,plugin-form,types} run type-checkTS2307 Cannot find module, i.e. NOT MEASURED)pnpm --filter @object-ui/{fields,plugin-form,types} lint0 errors(953 / 815 / 266 pre-existing warnings)pnpm check:control-bytespnpm check:readme-exportsOK … 0 unbuilt, 0 fabricated, 0 stale omission(s)— re-run afterturbo run build --filter='./packages/*'; the first run reportedthe population COLLAPSED, i.e. NOT MEASUREDpnpm check:dist-completeness12 package(s) complete (1637 emitted files verified)pnpm check:esm-specifiersno un-ledgered package emits an extensionless relative specifierpnpm check:vi-mock-specifiers·check:vi-mock-inheritpnpm check:self-import·check:unused-deps·check:phantom-depspnpm check:i18n-keys·check:i18n-drift·check:i18n-dead-keyspnpm check:unreferenced-sources·check:designer-field-key-parity·check:side-effects-arrayDerivation, stated so it can be checked: new exports on a published barrel pulled in
readme-exports/dist-completeness/esm-specifiers; a new cross-package import pulled inunused-deps/phantom-deps/self-import; a new test file pulled in the twovi-mockgates; any edit pulled incontrol-bytes; the widget edit touches i18n call sites' neighbourhood, so the three i18n gates were run. Repo-wide scans (pnpm lintat the root, the doc gates, the e2e suites) are CI's, not this branch's, and were not run here.DESIGNER_FIELD_TYPESwas measured and excluded by the card and is untouched.Reads of the card and its comments went through the zero-quota web payload channel and unauthenticated repo-scoped REST; both were probed before use and both returned 200. No sanitizer truncation was found in the issue body or in either comment: the tag-shaped fragments in them survived intact in storage, and both comments end with a complete attribution footer.
🤖 Generated with Claude Code
https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH
Generated by Claude Code