Repository navigation
fix(fields): an avatar pick submits its sys_file id through the UploadProvider, or is refused by name (objectui#10785) - #10811
Conversation
…dProvider, or is refused by name AvatarField read the picked file with FileReader.readAsDataURL and handed the data: URL to onChange. avatar is a file-reference type, so its stored value is the bare sys_file id. The pick now goes through useUpload() and fileValueForSubmit, the path FileField and ImageField use. A pick whose adapter surfaced no id is refused with the same "did not complete" row. Legacy data: and http(s) values still render through readFileValue, and a bare id now renders from the storage endpoint. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
…dingChange; type the pin fixtures The onUploadingChange docblock said only file and image fire it and that other widgets ignore it; AvatarField fires it now. The avatar pins use a typed FieldMetadata fixture instead of any casts. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
✅ 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 reviewServed-tier: ① Derived judgmentsParity with
Parity breaks at two hosts. This blocks, as item 1 of the verdict.
Read path.
Error UX.
Changeset
Pins (
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL
|
…hile an avatar upload is in flight AvatarField now waits on a network upload and reports it through onUploadingChange, but both hosts handed that callback to file and image alone, so a Submit or Confirm pressed mid-upload went out without the avatar and reported success. Both gates now name avatar. The avatar changeset carries its Clause-② line, names the hosts that gate, and states the media strictness switch exactly. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
|
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
|
Contract reviewServed-tier: Round 2, against the round-1 FAIL record ① Derived judgmentsRound-1 FAIL item 1 (host gates): resolved.
Pins: sound. Both drive the real
Changesets, read sentence by sentence.
Claim amendment: a correct deviation. The claim said "one PR body replacement ( ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #10785
Clause-②: yes (narrowing) — AvatarField, a public export of @object-ui/fields and the registered avatar widget, now refuses a pick whose upload adapter surfaces no id-shaped meta.fileId (the object-URL default with no UploadProvider mounted, S3/Azure-style adapters) where it used to hand every accepted pick to onChange as a data: URL; its props type is unchanged and no accept set widens. minor + BREAKING, per the objectui#7699 ruling (5856395366).
What changed
AvatarFieldno longer reads the pick withFileReader.readAsDataURLand hands thedata:URL toonChange. It now takes the pathFileFieldandImageFieldtake since objectui#7699 (PR objectui#10782):useUpload()→upload(file)→fileValueForSubmit(result, file.name). The baresys_fileid reachesonChange. A pick whose adapter surfaced no id throwsUploadIncompleteErrorinside thetry. The catch rendersuploadErrorMessage(the shared "did not complete" / "Failed to upload" row), and the field is not changed.Reading goes through
readFileValues+withRecentUploads, the reader the file and image widgets use. Thesrcit resolves is used in both the editable and the readonly face.The pick is now asynchronous, so the widget also raises
useUploadingSignal(uploading, onUploadingChange)anduseUploadingScopeHold(), as both sibling widgets do. Three kinds of host now hold their submit until the upload settles:plugin-form'suploadGateand the ambient uploading scope;FormPage(/f/:slug,/forms/:name) andActionParamDialog, whoseonUploadingChangegates nameavatarsince round 2 (see Round 2).The button shows a spinner and
fields.image.uploadingwhile the upload runs, and is disabled. The file input is reset up front, as inImageField, so a refused pick can be re-picked under the same name. No i18n key is added: every string reused already exists in all ten packs.Files:
packages/fields/src/widgets/AvatarField.tsxpackages/fields/src/widgets/AvatarField.fileId-10785.test.tsxpackages/fields/src/widgets/types.ts: one JSDoc line; see Acceptance notesapps/console/src/components/FormPage.tsxand newFormPage.avatarUpload-10785.test.tsxbeside itpackages/app-shell/src/views/ActionParamDialog.tsxand newActionParamDialog.avatarUpload-10785.test.tsxbeside it.changeset/10785-avatar-submits-file-id.mdand.changeset/10785-avatar-upload-gates.mdNot touched:
FileField,ImageField,file-value.ts,upload-error-message.tsand the upload provider.H1 — reproduction on base, both halves
Widget half, run. The real
AvatarFieldon base6cf599985d, with a one-byte PNG picked under an id-mintingUploadProvider(leg A below):The adapter's call count on base is 0: the widget never uploads.
Stock-deployment half: read, not run (no live server; objectstack
origin/main14ae40b0e1).validateRecord's value-shape arm (packages/objectql/src/validation/record-validator.ts, the branch taken whenFILE_REFERENCE_TYPES.has(t)) parses the value withshapeSchemaFor(def). Foravatar, aFILE_REFERENCE_TYPESmember, that schema isFileReferenceIdValueSchema(packages/spec/src/data/field-value.zod.ts): word characters and-, 1 to 64 long. Adata:URL fails it on:,/,;and,.invalid_type(invalid_value_shape) whenmediaStrictEffective(mediaStrict)answers true.OS_ALLOW_LAX_MEDIA_VALUES=1answers false and wins over everything. OtherwiseOS_DATA_VALUE_SHAPE_STRICT_ENABLED=1answers true. Otherwise the answer is the deployment'sadr-0104-file-referencesflag (FILE_REFERENCES_MIGRATION_ID), whichObjectQL.mediaValueShapeStrictFor→isFileReferencesMigrationVerified()reads. Two things write that flag:os migrate files-to-references --apply, andattestFreshDatastoreat creation, because the id is inCREATION_ATTESTED_MIGRATION_IDS.[value-shape] … accepted for now (ADR-0104 warn-first; run os migrate files-to-references --apply …)". The admission is also reported throughonAdmitted, the evidence that stops that deployment's flag from being recorded over it.VARCHAR(2048)). Reasoned from the ADR, not measured: a real image'sdata:URL is longer than that.The premise holds.
H2 — the seam
useUpload()(@object-ui/providers). It returns{ upload, adapter }, and with noUploadProviderabove it returns a freshcreateObjectUrlAdapter().fileValueForSubmit(result, originalName)returnsmeta.fileIdwhenisFileIdTokenaccepts it. Otherwise it throwsUploadIncompleteError.AvatarFieldcalls it exactly whereImageField's picker does: inside thetry, before anything is remembered or handed over.meta. The pick is refused with the same rowFileFieldrenders. The pin renders both widgets without a provider, picks the same file, and asserts the two rows' text is equal. There is no silentdata:fallback, and nothing reachesonChange.uploadResultView(result)is kept inrecent, keyed by the new id.withRecentUploadsoverlays it on the bare id the host hands back, so the image shows the adapter's ownresult.url(forcreateObjectStackUploadAdapter, its storage URL) until the next read expands the value. Pinned: after the host re-renders with the id, the avatar is drawn fromhttps://cdn.example/me.png.H3 — legacy read
The reader is
readFileValue(viareadFileValues), unchanged:data:URL renders as itself;/api/v1/storage/files/ID(fileUrlFromId).Each is pinned in the editable and in the readonly face. On base, a bare id was drawn as
src="f0e1…", a relative URL. That is red on base (leg A), so the id read is part of the fix, not a control. Thedata:and http(s) reads are the controls: green in every leg.Pins and ablation (round 1)
The file is
AvatarField.fileId-10785.test.tsx. It drives the real widget under a realUploadProviderwith a spy adapter; the no-provider arm stubsURL.createObjectURL.window.Imageis replaced by theLoadedImagestand-inuserCell.readGate-10535uses, so Radix'sAvatarImagedraws itsimgunder happy-dom. Each refusal arm asserts that the transport ran once, thatonChangewas never called, and that nothing handed over carriesdata:orblob:.All three legs ran at
4605e9852c, under the verify lock, with--reporter=verbose:AvatarField.tsxchecked out at base6cf599985d(disk blob9024feb22a== base blob; markersreadAsDataURL1,fileValueForSubmit0); tests at headreadAsDataURL→onChangeput back ahead of the upload, viaablation-replace.mjs(anchorconst result = await upload(file);1 → 0; blobdfe12bc2cc→c181826f64); everything else at headgit diff HEADis empty, and the disk blob equals the HEAD blobdfe12bc2cc. The ablation marker count is 0 afterwards.The file is unchanged in round 2, and it is green in every round-2 leg below.
Round 2 — the host gates (contract review
5857847604, item 1)Measured on the round-1 head
4605e9852c, before the fix. A/f/:slugFormPage with anavatarfield rendered the realAvatarFieldunder anUploadProviderwhose adapter held its promise. With the upload confirmed in flight (the adapter's pending list at length 1), a throwaway probe (not committed) read the Submit button and clicked it:The record went out without the avatar, and the form reported success: the objectui#10167 class. The same held upload in
ActionParamDialogleft Confirm enabled (Received element is not disabled).The fix.
FormPage.tsx:isUploadWidgetanswers true for'avatar'. Its docblock ("The widgets that emit upload-in-progress …") and theuploadingstate's docblock ("Only the upload widgets (file,image,avatar) …") name it.ActionParamDialog.tsx: theisUploadWidgetgate names'avatar', and so does theuploadingstate's comment.serializeParamValuesis not touched: an avatar value is already a bare id string.@object-ui/fieldsexports no upload-widget set. It exportsuseUploadingScope/UploadingScopeProvider, the aggregation, not a key list.FILE_REFERENCE_TYPES(spec) names field types, not widget keys:video/audioresolve to thefilewidget. So the two literals gain'avatar', and no export is added.Measured after the fix, head
03eae19e77. The same held upload: Submit reads "Uploading…" and is disabled, nothing is POSTed, and once the upload settles Submit is enabled and the payload carriesphoto: "f0e1d2c3b4a5968778695a4b3c2d1e0f".ActionParamDialog: Confirm is disabled and readsactionDialog.uploading, a click resolves nothing, and after the upload settles Confirm resolves{ photo: "f0e1…" }.Pins. Both drive the REAL
AvatarFieldthrough the host's own resolver, under a realUploadProviderwhose adapter holds its promise. Nothing stubs the widget, so they also pin the widget'sonUploadingChangeclaim the review found unpinned:apps/console/src/components/FormPage.avatarUpload-10785.test.tsxpackages/app-shell/src/views/ActionParamDialog.avatarUpload-10785.test.tsxLegs at head
03eae19e77, one verify-lock run, over the two host pins and the round-1 fields pin:FormPage.tsxandActionParamDialog.tsxchecked out at4605e9852c(disk blobse6e7fa6395/4ed9206c60== their round-1 blobs;=== 'avatar'markers 0 / 0); pins at head'avatar'removed fromFormPage'sisUploadWidget, viaablation-replace.mjs(anchor 1 → 0; blob5aa1fd3f92→9d146e1dc8)Restores, both proven. Leg R1:
git diff HEAD0 bytes, and both disk blobs equal HEAD (5aa1fd3f92,c091611940). Leg B: the tool's restore gives blob == HEAD5aa1fd3f92and an emptygit diff HEAD, and the marker count is 0 afterwards.Gates
03eae19e77.pnpm exec vitest run --maxWorkers=2 apps/console/src/components/FormPage apps/console/src/__tests__/formPageRequiredMarker-10178.test.tsx packages/app-shell/src/views/ActionParamDialogat03eae19e77(every FormPage and ActionParamDialog test file, the two new pins included): 33 files, 363 passed (VERDICT command-exit 0).pnpm exec vitest run --maxWorkers=2 packages/fields/ packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsxat02987baad6: 216 files passed, 1 skipped; 3528 tests passed, 7 skipped. No fields source changed since then except one JSDoc line.turbo run build --filter='@object-ui/console^...' --concurrency=2under the verify lock: 34/34 tasks (11 cached).03eae19e77:pnpm --filter @object-ui/app-shell run type-check&&pnpm --filter @object-ui/console run type-check, one&&chain,VERDICT command-exit 0;--listFilesshows each pin in its program once: the ActionParamDialog pin in app-shell'stsconfig.test.json, the FormPage pin in the console'stsconfig.json.03eae19e77:check-changeset-presence: 7 source files of 3 released packages, 2 changesets;check-changeset-fixed,check-changeset-no-major,check-changeset-overwrite,check:pending-changeset-literals;check:changeset-claims(report-only): it names 6 pending changesets that citeFormPage.tsxorActionParamDialog.tsx. All were read; none speaks to the upload gate, and none is made false.03eae19e77:check:control-bytes,check:new-line-citations(0 new),check:test-path-roots,check:vi-mock-specifiers,check:phantom-deps,check:esm-specifiers,check:unreferenced-sources,check:i18n-keys.eslint --format jsonover the 7 touched TS/TSX files: 0 errors, 35 warnings. The two host files carry the same warning count as at the round-1 head (FormPage 20, ActionParamDialog 12, read by linting the round-1 blob through--stdin), and the three pin files carry 0. This is a targeted run, not a proven narrowing. The per-packageeslint .is CI's.Changeset
.changeset/10785-avatar-submits-file-id.md:'@object-ui/fields': minorwith a BREAKING banner, as the objectui#7699 ruling (5856395366) graded the same change for the file and image widgets; nevermajor. Round 2 changes:Clause-②: yes (narrowing)line, as the 7699 changeset does;mediaStrictEffectiveexactly: the flag orOS_DATA_VALUE_SHAPE_STRICT_ENABLED=1makes it strict, andOS_ALLOW_LAX_MEDIA_VALUES=1wins over both;FormPageandActionParamDialog..changeset/10785-avatar-upload-gates.md:'@object-ui/app-shell': patchand'@object-ui/console': patch.apps/consoleis not private: itspackage.jsonhas noprivatefield,@object-ui/consoleis in.changeset/config.json'sfixedgroup, and earlier FormPage fixes such as10167-formpage-file-field-arm.mddeclare itpatch.Serial
git merge-tree --write-treeof head03eae19e77againstorigin/main67d4ed9083: clean (treef5e1c91472). None of the touched paths moved onmainsince base6cf599985d. (Refreshed by the seat at landing, from the round-2 contract review.)Acceptance notes
types.ts'sonUploadingChangedocblock said "Upload widgets (file/image) fire this … Other widgets ignore it".AvatarFieldfires it now, so the line namesavatar.FormPage.tsxandActionParamDialog.tsxwere added to the claim's surface by the seat in round 2.@object-ui/runnermount noUploadProvider, so an avatar pick there is now refused, as the file and image picks already are since objectui#7699 (accepted there). The console mountsUploadProviderwith the ObjectStack adapter aboveConsoleShell.InlineFieldInput(plugin-detail). It passesonUploadingChangeto none of the three upload widgets and mounts no scope. It is unchanged, and at parity with file and image.console.errorand do nothing visible, and they do not use the translatedfile-size-guardthe sibling widgets use.validateRecordtreats''as missing (isMissing) — read, not run.uploadErrorMessageis shared and pinned under objectui#7699.SignatureFieldstill writes a base64 PNG, butsignatureis not aFILE_REFERENCE_TYPESmember, so that is not this contract.Generated by Claude Code