Repository navigation
fix(console): a file field on a FormView renders the shared upload control, not a text box - #10173
Conversation
…control `FormPage` — the renderer behind `/f/:slug` and `/forms/:name` — has a hand-rolled input switch with no `file` arm, so a `file` field fell through to the default text input. Typing a filename into a text box uploads nothing: the capability was absent on those routes, not degraded. The new arm writes no second file control. It asks ADR-0059's resolver (`resolveFormWidgetType` + `getLazyFieldWidget`, the door `ActionParamDialog` is already on) for the same widget the record form renders, so this route submits the value the other renderer submits — the `sys_file` id the upload adapter minted, or the legacy inline blob when the adapter surfaced none. Two supporting halves in the same file: - The object field's declared `multiple` / `accept` / `maxSize` now reach the widget. This app's narrowed `ObjectFieldDef` admitted none of the three, so the control would have been single-file, unfiltered and unbounded whatever the author declared — the same "nothing downstream can read a key this file's payload types do not admit" shape objectui#5627 recorded here for the conditional rules. - Submit is blocked while an upload is in flight. That is what the widget contract's `onUploadingChange` is for: the value only becomes an id once the adapter settles, so an earlier submit stores nothing and still reports success. The other field types this switch does not spell are deliberately untouched; the enumeration of them is reported on objectui#10167 for the separate decision about whether this renderer should hold a switch at all. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xr7APep6jm1Zta3KUzPzZf
`react-hooks/static-components` refuses a component value produced during render — a fresh component identity remounts its subtree and loses its state — and it was the one ESLint ERROR this branch added to `apps/console` (`eslint .` there: 1 error before, 0 after; the file's 21 warnings are unchanged from `origin/main`, same rules, same counts). Hoisting is also just correct here: the arm pins the type at `'file'`, so there was nothing per-render to resolve. `React.lazy` still defers the import until the arm first renders, so the widget does not join this route's eager bundle. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xr7APep6jm1Zta3KUzPzZf
|
changeset-claim-re-read
|
✅ 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
|
|
| pending changeset | what it asserts | verdict |
|---|---|---|
console-form-field-spec-one-declaration-5542.md |
the third FormFieldSpec in apps/console was converged away |
✅ still true — imported at :127, ⛔ not redeclared |
console-form-container-specs-one-declaration-5596.md |
FormSectionSpec / FormViewSpec converged to one declaration each |
✅ still true — both imported at :127 |
console-formpage-runtime-default-seed-5727.md |
readPrefill no longer seeds a DEFAULT_VALUE_TOKENS token or CEL envelope literally |
✅ still true — this diff adds no line in readPrefill and changes no default-seeding path |
console-formpage-visible-predicates-5594.md |
this renderer now reads the FormView visibility predicate | ✅ still true — this diff touches no visibleWhen / visibleOn read; its hunks are the switch arm, the two local interfaces, the field row, and the submit gate |
⇒ ⛔ no body corrected, because ⛔ none went false. Frontmatter and prose of all four untouched.
domain:ui seat #1 · session_01Xr7APep6jm1Zta3KUzPzZf · gate answer · read against PR head 938c2e6947 at 2026-09-21T00:20Z
Generated by Claude Code
Fixes #10167
Clause-②: no— declared on the claim, unchanged on delivery.node scripts/check-governed-queue-guard.mjs --testover this branch's three paths printsNOT GOVERNED — 3 path(s) checked against 5 governed surface(s); none matched.apps/consoleis an application, not a published contract face, and this repair pulls an outlier back to an already-declared field type rather than widening any accepted set.Seat session, in prose because footers do not survive an edit:
session_01Xr7APep6jm1Zta3KUzPzZf.Premise, re-taken on
origin/main98178b2The switch is
FieldInput'sswitch (field.type)inapps/console/src/components/FormPage.tsx. Parsed out of the switch body rather than read by eye, its arms before this branch were, in tree order — 21 of them:textarea·paragraph·long_text·number·integer·decimal·currency·email·url·password·date·time·datetime·timestamp·boolean·toggle·checkbox·select·picklist·enum·radioNo
file, so the field fell through to thedefaultarm's plain text input. The card's premise holds. The same parse answers the dispatch's assumption D: the switch body contains no map, lookup table or nested helper — every arm is a literalcase, so an arm-list reading under-reports nothing on the type axis. It under-reports on three other axes; see step 1 below.The fix — one arm, and no second file control
The
filearm asks ADR-0059's resolver (resolveFormWidgetType+getLazyFieldWidgetin@object-ui/fields) for the same widget the record form renders. That resolver's own docblock names this the door for a host rendering field widgets outside the form renderer, "so their type support can never drift behind the form surface", and@object-ui/app-shell'sActionParamDialogis already on it.Writing an
input type="file"here instead would have been a second upload pipeline: a second size guard, a second progress surface, and — the part that actually breaks — a second answer to what a submitted file VALUE is. A hand-rolled control puts a rawFileinto the form's values and the engine answersexpected string, received object, which is exactly the shape objectui#10131 measured. Through the shared widget, this route submits what the other renderer submits: thesys_fileid the upload adapter minted, or the legacy inline blob when the adapter surfaced none.The resolution is hoisted to module scope.
react-hooks/static-componentsrefuses a component value produced during render, and the arm pins the type at'file'anyway, so there is nothing per-render to resolve;React.lazystill defers the chunk until the arm first renders.Two halves the symptom does not name, and why they are not scope creep
The object's declared upload configuration now reaches the widget. This app's local
ObjectFieldDefadmitted nomultiple, noaccept, nomaxSize— all three of which@objectstack/specdeclares on a field and the server serves. Without them the control would be a single-file, unfiltered, unbounded dropzone whatever the author wrote: declared but not delivered. That is the identical shape objectui#5627 recorded in this same file for the conditional rules — "nothing downstream could read a key this file's payload types did not admit".Submit is blocked while an upload is in flight. The widget contract says outright what
onUploadingChangeis for: upload widgets fire it "so a host can block submit until a presigned upload settles". A file's value only becomes an id once the adapter resolves, so a submit that beats it stores nothing for the field and still reports success. Ablation leg C below shows the guard is load-bearing and nothing else depends on it.Step 1 — the enumeration this card asked for
The full table is in the
os-dev-reportcomment on objectui#10167. Headline, measured — population is@objectstack/spec/data's ownFieldTypeenum (49 members), the resolver column is a runtime read of the built@object-ui/fields, and the arm column is parsed from the switch:text, is correctly served by thedefaultarm; for the other 34 the default text input is the wrong control.FieldTypemembers at all (paragraph,long_text,integer,decimal,timestamp,checkbox,picklist,enum) — a private alias table maintained nowhere else.⭐ Three dispatch axes an arm list cannot see, and they are why "add the missing arms" would not close the class:
widget. The spec declareswidgetas an optional string on a field, and the shared chain reads it (sectionFields.tscarriesfd.widget;form.tsxresolvesf?.widget || f?.field?.widget || f?.type). Three widget keys —object-ref,filter-condition,recipient-picker— are reachable only that way.FormPagereads nowidgetkey anywhere, so it is blind to this axis no matter how manytypearms it gains.mapFieldTypeToFormType('select', { multiple: true })returnsfield:multiselect— a different component. This switch'sselectarm renders a single-valueselectelement, so a declared multi-value picklist silently degrades here even thoughselect"has an arm".resolveFormWidgetType('owner')returnsowner, which the registry answers with a tombstone that refuses visibly — the whole point of objectui#4814. Hereownerhas no arm and the default renders a working text box, which is precisely the silent degradation the tombstone exists to prevent.⛔ Nothing above is acted on in this pull request. Per triage it is evidence for the separate decision-box card about whether this renderer should hold a switch at all.
Reverse verification — three legs, direction predicted before each run
Run from the committed tree; each mutation is proven on disk before the run (anchor removed, marker injected) and each restore is proven by blob hash against
HEADplus an emptygit diff HEAD.filearm removed — the card's own fix revertedmultiple/accept/maxSizeonUploadingChangesignal droppedB and C are the discriminating legs: they show the two supporting halves each have their own pin, and that neither pin is carried by the arm.
Gates run here
pnpm exec vitest run apps/console/src/components/FormPage.fileField.test.tsxTests 6 passed (6)pnpm exec turbo run type-check --filter=@object-ui/console --concurrency=2pnpm exec vitest run apps/console/Test Files 106 passed (106),Tests 1187 passed (1187)eslint .inapps/consoleFormPage.tsxbase vs head: 0 errors / 21 warnings both sides, same rules, same counts; the new test file is 0 / 0node scripts/check-changeset-presence.mjsnode scripts/check-control-bytes.mjsnode scripts/check-changeset-claims.mjsnode scripts/check-new-cross-file-line-citations.mjsnode scripts/check-i18n-call-site-keys.mjsnode scripts/check-test-path-roots.mjsnode scripts/check-vi-mock-specifiers.mjsnode scripts/check-governed-queue-guard.mjs --testRepo-wide
Lint, the fourTestshards,Build & E2EandBuild Docsare CI's runs, not reproduced here.Acceptance notes
Observations recorded rather than filed — ⛔ no cards were opened from this seat.
*span beside the label, notaria-hidden.ActionParamDialoghides its own, citing objectui#3299, so that the asterisk stays out of the control's accessible name. Both renderers draw the marker; only one keeps it out of the name. Carrier: whoever next touches this file's label block./f/:slugis the anonymous route, and the console's upload adapter runs the authenticated presign/complete protocol. Whether an anonymous visitor may presign is a backend question this seat could not answer from this checkout — the storage service is not in it. The probe is a real anonymous submit of a form with afilefield against a running backend. Before this branch the question could not arise, because the control did not exist.integer,decimal, pluscurrency) all render the same bareinput type="number", while the shared resolver givescurrencyits own widget. Part of the enumeration above, not a separate finding.Generated by Claude Code