Repository navigation
fix(plugin-form,fields): a record form refuses to save while an upload is in flight (objectui#10166) - #10172
Conversation
…d is in flight (objectui#10166) A `file`/`image` value only becomes its fileId once the presigned upload settles. A record form had no notion of upload state at all, so a Save pressed during that window wrote the record WITHOUT the attachment and reported success — no error, no warning, and the record looked saved. `onUploadingChange` already carried the signal and could not reach this surface: it is per-widget, and a record form hands a `fields` array to the `form` node renderer and never touches a widget. `@object-ui/fields` now publishes the aggregation beside it — `useUploadingScope` + `UploadingScopeProvider`, both fed from the one `useUploadingSignal` call every upload widget already makes, with an unmount release so a collapsing section cannot wedge Save shut. `ObjectForm`, `ModalForm` and `DrawerForm` mount that scope and, while an upload is in flight, refuse the submit, label Save "Uploading…" and render the reason (`form.uploadInFlight`, new in all ten packs). The two footer-owning hosts disable Save as well. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xr7APep6jm1Zta3KUzPzZf
…n the affordance (objectui#10166)
Both rows now assert the stored value before any label or notice, so an
ablation of the gate reds them on the record that reached the adapter —
`[{ name: undefined, attachment: undefined }]` where `[]` was expected — rather
than on a missing status line. Each row closes on the invariant that no
weakening can satisfy on the defect: exactly one record across both gestures,
carrying the file the user picked.
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
|
…-flight upload (objectui#10166) The first round wired three of eight hosts. A user on a TabbedForm still lost the attachment silently, so the card's p1 survived on most of the surface it describes. All eight submit owners are now gated: ObjectForm, ModalForm, DrawerForm, SplitForm, TabbedForm, WizardForm, MasterDetailForm, and EmbeddableForm through the ObjectForm it hosts. One pin each — every row asserts the stored value, and an ablation of the gate reds all eight on the record that reached the adapter. Nesting now CHAINS instead of shadowing: an inner scope gates its own Save AND reports itself to the scope above. MasterDetailForm forces that direction — its Save persists parent and children in one batch while its rows are edited by nested ObjectForms, so a scope that shadowed would have left the outer Save blind to a child's upload while looking gated, which is worse than no gate. WizardForm is gated on its final commit only; step navigation writes nothing and Next is deliberately untouched. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xr7APep6jm1Zta3KUzPzZf
…10166) The title said "but never step navigation" while the row clicked Next before any upload started, so that half was prose, not an assertion. Narrowed to the final commit, with the reason the other half is not worth pinning written where the next reader will meet it: leaving a step unmounts its widgets, so an upload in flight is released by the unmount and its value never reaches the record — a loss that predates this card. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xr7APep6jm1Zta3KUzPzZf
✅ 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
|
🛑 Contract review ABSENT — the at-tier subagent could not start. PR stays draft, out of the queue, carriers hung.
What happenedThis PR declares It terminated before reviewing anything — an API quota refusal at that tier (HTTP 429, Why this seat is not reviewing it insteadThe charter forecloses exactly this shortcut, and the reason is worth stating rather than just citing: the quota-exhaustion downgrade exists for dispatch, not for review — 「⛔ 契约复核 ⛔ 不适用额度耗尽豁免降档:豁免对象是派发,复核 ⛔ 不随派发档位免除」. And where the subagent cannot start: 「起不来即无复核,标签原样、队列外等档」. State, deliberately unchanged
What unblocks it — two routes, and only two
⛔ There is no third route, and ⛔ waiting is not a failure state: 「队列外等待是安全态」.
Generated by Claude Code |
Review handed to another agent by the maintainer — this seat stands down from re-dispatching it
Provenance of the instruction — maintainer, in the
⇒ the clause-② contract review for this PR is the maintainer's to arrange. ⛔ This seat does not re-dispatch it, ⛔ does not self-review, and ⛔ does not treat this instruction as the review itself. State, unchanged and deliberately so
For whoever reviews it — what is already established, and what is deliberately notEstablished and re-derived by this seat against the tree, ⛔ not taken from the dev's report: 8 of 8 submit owners in ⛔ Not established, and the review's to judge: whether ⭐ That last item is the dev's own disclosure, not a suspicion raised here: two rows passed under ablation — one because an action bar deferred the submit past a 50 ms window, one because a public-form min-fill-time gate refused every submission a test could issue — and both were 「green on green」 that would have shipped as coverage. They were strengthened and all eight now red on the stored value. ⛔ That is a claim to verify, not a premise.
Generated by Claude Code |
Contract reviewServed-tier: Isolated at-tier reviewer, arranged by the maintainer (「10172 我会让其他agent审核」) and adopted by the director seat; reviewed 2026-09-21T02:06Z. Merge-base ① Derived judgments(1) Premise on the merge base — table A–D re-derived at
(2) The accept-set change declared under (3) Unmount-releases-slot and no-provider inertness: pins found, run, ablated, restored.
(4) Package tests, typecheck, lint, gates — exit codes.
(5) Merge faithfulness. (6) The 7 pending changesets the re-read bot names (comment 5753725111), each read at the head.
(7) CI on the head. ② Semver levelThe changeset declares ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Provenance — director seat, summon #25 (
Generated by Claude Code |
🔴 Dequeued at 02:47:59Z by
|
| probe | measured |
|---|---|
git diff 591b37e0a1 eab765880f -- packages/plugin-form packages/fields packages/i18n |
0 lines |
| CONTROL — the same two commits, whole tree | 19 files |
fieldSecurityPayload.test.tsx blob on both |
b81ad26110dd = b81ad26110dd |
DrawerForm.tsx blob on both |
f79627f0a2cc = f79627f0a2cc |
Test (shard 1…8/8) on this head 591b37e0a1 |
8 of 8 green |
| the file alone, ×6 on this tree | 6 of 6 green (12 tests each) |
vitest run packages/plugin-form/ on this tree |
107 files / 1041 tests green |
The three commits main gained since this PR's merge-base 98178b206 touch plugin-detail, core/date-display.ts, app-shell and apps/console — nothing under packages/plugin-form, packages/fields or packages/i18n. So the subject code is byte-identical between the run that was green eight times over and the run that went red once, and the only thing the merge changed for this file is which files shared its shard and how loaded that worker was.
⛔ That is a reason to call the pin order-dependent, not a reason to call the failure noise. Filed as objectui#10190: DrawerForm clears loading at :354 in the same commit where :281 sets it for the first record read, so the drawer paints an EDITABLE, value-less form while findOne is in flight, and setFormData(data || {}) at :322 replaces whatever was typed. The file's own comment at :285-289 states that invariant — for a record SWAP, not for the first load. That card owns the repair and the hardened helper; ⛔ neither belongs on this head.
handleSubmit closure) and asks its taker to reproduce before repairing.
What I am doing now: re-queueing, once. Per this lane's rule that is the single re-run this failure is allowed — it passed earlier on this exact head, on every shard. ⛔ If it reds again the failure is real and this PR stops until it is root-caused; ⛔ no second re-run, ⛔ no skip, ⛔ no empty commit.
domain:ui seat #1 · session_01Xr7APep6jm1Zta3KUzPzZf · CI triage · readings taken 2026-09-21T03:02Z
Generated by Claude Code
Fixes #10166
Clause-②: yes
What was wrong
A
file/imagevalue only becomes its fileId once the presigned upload settles.@object-ui/plugin-formhad no notion of upload state at all, so a Save pressed during that window wrote the record without the attachment — and reported success. The user picked the file, saw it listed, pressed Save; no error, no warning, and the record looked saved.The premise, re-derived on
origin/main98178b2onUploadingChangepackages/app-shell/src/views/ActionParamDialog.tsxis the only non-test, non-CHANGELOG sitepackages/fields/src/widgets/useUploadingSignal.ts,FileField.tsx,ImageField.tsx,toHostProps.ts,types.tsuploadingmatches 0 files underpackages/plugin-form/**git grep -niw uploading -- packages/plugin-formexits 1 with a positive control (onChange) matching in the same treeplugin-formD, measured.
ModalFormandDrawerFormDO own their Save: each renders its own sticky footer button.ObjectForm's flat and sectioned paths do not — they hand atype: 'form'node toSchemaRenderer, and that button is rendered by@object-ui/components' form renderer, which exposes no per-button disable. Every one of the five hosts owns its ownhandleSubmit, so the refusal is in scope for all of them; only thedisabledattribute on the flat path is not.The route, and why not a prop
onUploadingChangeis per-widget. A record form hands afieldsarray to theformnode renderer and never touches a widget, and its upload controls can sit inside a section, a tab or a line-items subform — there is no point in that chain where a host can attach a callback. So@object-ui/fieldsnow publishes the aggregation beside the prop:useUploadingScope()— the host's "is anything below me uploading", plusUploadingScopeProvider.useUploadingSignalfeeds both sinks from the one call every upload widget already makes, so the prop and the scope cannot disagree. It is exported too, for widgets authored outside this repo.ActionParamDialogincluded — is unaffected: the context read answers null and the reporting hook is inert.Every submit owner in this package mounts the scope around its form body and, while an upload is in flight: refuses the submit (which is also the keyboard-submit guard), labels Save Uploading…, and renders the reason as a sentence —
form.uploadInFlight, new in all ten locale packs. Eight hosts, one mechanism:ObjectForm,ModalForm,DrawerForm,SplitForm,TabbedForm,WizardForm,MasterDetailForm, andEmbeddableFormthrough theObjectFormit hosts. The four that own their Save button —ModalForm,DrawerForm,MasterDetailForm, andWizardForm's final step — disable it as well. Nesting CHAINS rather than shadows: an inner scope gates its own Save AND reports itself to the scope above, a directionMasterDetailFormforces, since its Save persists parent and children in one batch while its rows are edited by nestedObjectForms.WizardFormis gated on its final commit only; step navigation writes nothing.Evidence
The pin is a behavioural DIFFERENTIAL, and an ablation proves it.
packages/plugin-form/src/uploadInFlightSave.test.tsxissues the same save gesture at two timings and asserts the stored value at each — the value first, before any affordance, so the row fails on the WRITE and not on a missing label. Only the upload transport is faked; the stub widget drives the realuseUploadingSignal.Ablation (one anchored on-disk replacement in
uploadGate.tsx,uploading: scope.anyUploadingbecomesuploading: false, verified by blob hash and restored withgit checkout HEAD --, restore verified by an emptygit diff HEAD):That is the defect verbatim: a record reaching the adapter mid-upload with no attachment. All EIGHT rows red under ablation, every one of them on the stored value — the three
it.eachhosts share one identical AssertionError block in vitest's output. All green restored.pnpm exec vitest run packages/plugin-form/+ the two fields scope files591b37e0a)pnpm exec vitest run packages/fields/pnpm exec vitest run packages/i18n/+ActionParamDialog*.test.tsxonUploadingChangeconsumer is unchanged)turbo run type-check --filter=@object-ui/plugin-form --filter=@object-ui/fields --filter=@object-ui/i18nturbo run lint(same three)no-explicit-any)pnpm check:i18n-keys·check:i18n-drift·check:i18n-dead-keys·check:i18n-designer-paritypnpm check:control-bytes·check:changeset-claims·check:component-surface-paritypnpm check:unreferenced-sources·check:new-line-citations·check:test-path-roots·check:phantom-depspnpm check:readme-exports·check:eager-closure·check:eager-locale-catalogues·check:sdui-registration-pinspnpm build; they are CI's, which builds firstnode scripts/check-governed-queue-guard.mjs --test(20 paths)Repo-wide
pnpm lint,Build & E2EandBuild Docsare CI's runs, not measured here.Declared scope deviations
The claim's file surface is
packages/plugin-form/src/andpackages/fields/src/widgets/. Two files outside it were necessary and are named rather than hidden:packages/fields/src/index.tsx— the barrel.@object-ui/fieldshas a single.export, so a symbolplugin-formmust import has to be re-exported there. Twoexportlines plus a comment; nothing existing changed.packages/i18n/src/locales/*.ts— ten files, one added line each. A localised reason is not optional here, andcheck:i18n-call-site-keysrequires everyt()key to exist in theenpack whileall-locales-key-parityrequires all ten to carry it. One key,form.uploadInFlight.packages/app-shell/src/views/ActionParamDialog.tsxwas read as the reference implementation and not touched — it is held by objectui#10130.Acceptance notes
Named gaps, deliberate, not oversights:
ObjectForm,SplitFormandTabbedFormSaves are notdisabledwhile an upload is in flight — they are refused, relabelled and explained. Those buttons belong to theformnode renderer in@object-ui/components, which readsisSubmitting || disabledand nothing else; the only lever reachable from here is the node-leveldisabled, which would also grey out every field and Cancel, trapping the user in a form they cannot leave. Ruled: ship the refusal; thesubmitDisabledkey is its own card.WizardFormis gated on its final commit only.Nextis untouched, and leaving a step unmounts its widgets — so an upload in flight is released by the unmount and its value never reaches the record. That loss predates this change and is not addressed by it.@object-ui/fields' README andcontent/docs/guide/*do not mention the new exports (AGENTS.md Add automated testing infrastructure and CI/CD workflows #2). Carrier: no longer "whoever wires the remaining hosts" — that work is done here. It needs an owner.Generated by Claude Code