Skip to content

fix(plugin-form,fields): a record form refuses to save while an upload is in flight (objectui#10166) - #10172

Merged
os-elon-musk merged 4 commits into
mainfrom
claude/issue-10166-record-form-save-during-upload
Sep 21, 2026
Merged

os-elon-musk merged 4 commits into
mainfrom
claude/issue-10166-record-form-save-during-upload

Conversation

@os-elon-musk

@os-elon-musk os-elon-musk commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #10166

Clause-②: yes

What was wrong

A file / image value only becomes its fileId once the presigned upload settles. @object-ui/plugin-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. 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/main 98178b2

reading claim measured here
A exactly one host passes onUploadingChange holds — packages/app-shell/src/views/ActionParamDialog.tsx is the only non-test, non-CHANGELOG site
B the signal machinery is in packages/fields/src/widgets/ holds — useUploadingSignal.ts, FileField.tsx, ImageField.tsx, toHostProps.ts, types.ts
C uploading matches 0 files under packages/plugin-form/** holds — git grep -niw uploading -- packages/plugin-form exits 1 with a positive control (onChange) matching in the same tree
D the record form's Save may not be owned by plugin-form partly falsified, and it matters — see below

D, measured. ModalForm and DrawerForm DO own their Save: each renders its own sticky footer button. ObjectForm's flat and sectioned paths do not — they hand a type: 'form' node to SchemaRenderer, 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 own handleSubmit, so the refusal is in scope for all of them; only the disabled attribute on the flat path is not.

The route, and why not a prop

onUploadingChange is per-widget. A record form hands a fields array to the form node 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/fields now publishes the aggregation beside the prop:

  • useUploadingScope() — the host's "is anything below me uploading", plus UploadingScopeProvider.
  • useUploadingSignal feeds 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.
  • A widget that unmounts mid-upload releases its slot — a collapsing section, a switched tab or a deleted subform row must not be able to wedge Save shut for the rest of the session. That failure would be worse than the defect, and invisible.
  • A host that mounts no provider — every host before this change, ActionParamDialog included — 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, and EmbeddableForm through the ObjectForm it hosts. The four that own their Save button — ModalForm, DrawerForm, MasterDetailForm, and WizardForm'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 direction MasterDetailForm forces, since its Save persists parent and children in one batch while its rows are edited by nested ObjectForms. WizardForm is 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.tsx issues 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 real useUploadingSignal.

Ablation (one anchored on-disk replacement in uploadGate.tsx, uploading: scope.anyUploading becomes uploading: false, verified by blob hash and restored with git checkout HEAD --, restore verified by an empty git diff HEAD):

AssertionError: expected [ { name: undefined, …(1) } ] to deeply equal []
- Expected
+ Received
+     "attachment": undefined,

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.each hosts share one identical AssertionError block in vitest's output. All green restored.

command exit
pnpm exec vitest run packages/plugin-form/ + the two fields scope files 0 — 109 files, 1053 passed, 1 skipped (run at head 591b37e0a)
pnpm exec vitest run packages/fields/ 0 — 175 files, 2976 passed
pnpm exec vitest run packages/i18n/ + ActionParamDialog*.test.tsx 0 — 71 files, 1199 passed (the existing onUploadingChange consumer is unchanged)
turbo run type-check --filter=@object-ui/plugin-form --filter=@object-ui/fields --filter=@object-ui/i18n 0
turbo run lint (same three) 0 — 0 errors (warnings are the tree-wide pre-existing no-explicit-any)
pnpm check:i18n-keys · check:i18n-drift · check:i18n-dead-keys · check:i18n-designer-parity 0
pnpm check:control-bytes · check:changeset-claims · check:component-surface-parity 0
pnpm check:unreferenced-sources · check:new-line-citations · check:test-path-roots · check:phantom-deps 0
pnpm check:readme-exports · check:eager-closure · check:eager-locale-catalogues · check:sdui-registration-pins NOT MEASURED — each prints its own "population collapsed" / "broken gauge" refusal without a full pnpm build; they are CI's, which builds first
node scripts/check-governed-queue-guard.mjs --test (20 paths) 0 — NOT GOVERNED

Repo-wide pnpm lint, Build & E2E and Build Docs are CI's runs, not measured here.

Declared scope deviations

The claim's file surface is packages/plugin-form/src/ and packages/fields/src/widgets/. Two files outside it were necessary and are named rather than hidden:

  1. packages/fields/src/index.tsx — the barrel. @object-ui/fields has a single . export, so a symbol plugin-form must import has to be re-exported there. Two export lines plus a comment; nothing existing changed.
  2. packages/i18n/src/locales/*.ts — ten files, one added line each. A localised reason is not optional here, and check:i18n-call-site-keys requires every t() key to exist in the en pack while all-locales-key-parity requires all ten to carry it. One key, form.uploadInFlight.

packages/app-shell/src/views/ActionParamDialog.tsx was read as the reference implementation and not touched — it is held by objectui#10130.

Acceptance notes

Named gaps, deliberate, not oversights:

  • The flat ObjectForm, SplitForm and TabbedForm Saves are not disabled while an upload is in flight — they are refused, relabelled and explained. Those buttons belong to the form node renderer in @object-ui/components, which reads isSubmitting || disabled and nothing else; the only lever reachable from here is the node-level disabled, which would also grey out every field and Cancel, trapping the user in a form they cannot leave. Ruled: ship the refusal; the submitDisabled key is its own card.
  • WizardForm is gated on its final commit only. Next is 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.
  • noted, not filed: @object-ui/fields' README and content/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

…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
@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 7 pending changeset(s) describe a file this change touches

Their bodies publish verbatim into the CHANGELOG at the next release, so this is a request to re-read them against your diff — addressed here because you are the one seat that can answer it without re-deriving anything.

⛔ Nothing here blocks, and nothing here is a verdict on your change. This gate exits 0, is not a required context, and judges name resolution, never meaning: it asked whether a pending body names a file you touched. "Is this sentence still true?" is the one question it will not answer, and the one you are being asked to answer.

.changeset/6237-wizard-step-config-split.md

  • names WizardForm.tsx → packages/plugin-form/src/WizardForm.tsx — edited by this change

    WizardStepConfig is now declared independently in WizardForm.tsx, which is simply what SplitFormSectionConfig, ModalFormSectionConfig and DrawerFormSectionConfig already do: each layout owns its group shape, documents className / gridClassName in its own terms, and declares visibleWhen only where its renderer honours it. The derivation flips from subtractive to additive — a key is authorable on a wizard step only if someone writes it there.

.changeset/6625-retire-fieldmeta-decimals.md

  • names fields/src/index.tsx → packages/fields/src/index.tsx — edited by this change

    buildFieldMeta computed decimals: overrides.decimals ?? meta?.decimals ?? meta?.scale on every call and the value reached nothing. Re-measured on this branch's base (efdc6c62): zero .decimals member reads across @object-ui/fields, @object-ui/i18n, @object-ui/components, @object-ui/core and plugin-dashboard itself — the only non-comment occurrence was the write being removed here. The positive control in the same query shape fires: .scale member reads hit NumberField.tsx, GridField.tsx and fields/src/index.tsx. So the zero is a finding, not a broken query. The overrides.decimals ?? head of that chain had already lost its only feeder when objectui#6425's ruling removed the authored read from ObjectDataTable.enrich(); RecordDetailDrawer, the only other buildFieldMeta caller, passes no overrides at all. Both halves retire together, so the key leaves in one move.

.changeset/6661-app-launcher-nav-menu-renderers.md

  • names en.ts → packages/i18n/src/locales/en.ts — edited by this change

    Three new strings — the launcher's and the menu's accessible names, and the menu's empty state — are declared under console.nav in en.ts and its nine sibling packs. An inline defaultValue alone is not a fix: it renders English at one call site and leaves the string untranslatable everywhere (objectui#3517).

.changeset/6694-dashboard-lookup-reference-meta.md

  • names packages/fields/src/index.tsx → packages/fields/src/index.tsx — edited by this change

    ⚠️ The copy set is three keys where ObjectGrid's RELATIONAL_META_KEYS is nine, and the difference is measured per key, not preferred. The grid's cells are EDITABLE, so its extra keys drive the inline picker's query (LookupField / UserField read id_field, description_field, lookup_filters, lookupFilters); these two widgets are read-only and their render path ends at a cell renderer. packages/fields/src/index.tsx reads exactly reference_to, reference and display_field off a cell's field prop; titleFormat is never read off a field meta at all (its readers take it off the object schema, which arrives here through useRefObjectSchema(reference_to)), and reference_to_field has zero member reads anywhere in the repo. Copying the other six would mint six members written on every call and read by nothing — precisely what objectui#6625 (decimals) and objectui#6597 (referenceTo) retired from this same file.

.changeset/6837-reference-to-arm-deletion.md

  • names fields/src/index.tsx → packages/fields/src/index.tsx — edited by this change

    Three readers were deliberately left alone. LookupCellRenderer (fields/src/index.tsx), LookupField and UserField read FieldMetadata — ObjectUI's OWN contract, whose LookupFieldMetadata declares reference_to and never declares reference. They are fed by the emitters above and by published example schemas (examples/schema-catalog/src/schemas/fields-lookup/*.json), so narrowing them would break in-repo producers, and plugin-grid's relationalMetaCopySet.derivation.test.ts re-derives its read set from exactly those three sources — where reference_to is recorded with verdict adapter-stamped. DetailViewFieldSchema is likewise untouched.

.changeset/7166-retire-inert-fieldmeta-copies.md

  • names packages/fields/src/index.tsx → packages/fields/src/index.tsx — edited by this change

    applyRelationalMeta writes the copy set onto the fieldMeta that generateColumns hands to ANGLE-BRACKETS(CellRenderer) as the field prop — six JSX passes across the three column-building paths, and nowhere else. For a relational column that resolves to LookupCellRenderer, which reads exactly reference_to, reference, display_field, displayField, reference_field and options; a user column resolves to UserCellRenderer, which destructures { value } and reads no field meta at all. Measured by receiver rather than by count: packages/fields/src/index.tsx, the file holding every cell renderer, contains zero occurrences of the three retired keys, against a control of 22 occurrences of the display_field / displayField / reference_to spellings the cell does read.

.changeset/8738-object-form-fields-description.md

  • names ObjectForm.tsx → packages/plugin-form/src/ObjectForm.tsx — edited by this change

    The registration declared { name: 'fields', type: 'array' } with no description, so an author had nowhere to read that this key's members are bare field names — a different vocabulary from sections[].fields, which also accepts the spec FormFieldSchema object (identity key field, e.g. { field: 'note', colSpan: 2 }). Moving one of those objects to the top-level fields resolves to no name and is skipped by SimpleObjectForm (ObjectForm.tsx) and by buildFlatFields (flatFields.ts, shared by the drawer/modal presentations). Behaviour is unchanged by this entry; it only adds the description text an author would need to avoid the drop before writing it.

Read the paragraph, not the line: both false halves of the objectui#8617 claim sat in one paragraph, and correcting either alone would have left it asserting the same wrong thing.

If a claim did go false, correct the body. That is precedented and prose-only, frontmatter untouched; check-changeset-overwrite.mjs will report the correction as its own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended shape — one gate asks for the read, the other records the write.

Not covered, stated so nobody reads this as more: a born-false claim that spells no line address at all (objectui#9495 coordinated one by ORDINAL — "a grep finds that member first" — and deciding that means reading what the sentence means), a claim spelled as a symbol or a package rather than a backticked file name, and a file named ambiguously.

Angle-bracketed names in the quoted prose above are rewritten as ANGLE-BRACKETS(name): GitHub deletes tag-shaped fragments from a stored body, and a quote that silently loses the identifier it is about is worse than a visible repair.

Compared the checked-out tree with 2d7fff3b8 (merge-base with origin/main): 23 file(s) changed outside .changeset/, read against 1251 pending declaration(s) that publish a body (1816 pending in total). · run

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3033.3 KB 3104.5 KB
Main entry chunk (gzip) 147.0 KB 350 KB
Entry file index-BDkLqmL3.js —
Status PASS —

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.68KB 6.20KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 546.12KB 130.78KB
core (index.js) 8.94KB 3.59KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 221.99KB 61.72KB
fields (index.js) 252.61KB 63.70KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.02KB 11.00KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 51.44KB 14.62KB
plugin-charts (index.js) 71.73KB 20.08KB
plugin-chatbot (index.js) 198.20KB 47.14KB
plugin-dashboard (index.js) 132.96KB 35.17KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 255.19KB 66.48KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 141.86KB 36.36KB
plugin-gantt (index.js) 167.99KB 41.37KB
plugin-grid (index.js) 213.40KB 58.19KB
plugin-kanban (index.js) 48.71KB 15.17KB
plugin-list (index.js) 113.53KB 27.99KB
plugin-map (index.js) 21.74KB 7.07KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.49KB 11.97KB
plugin-timeline (index.js) 30.68KB 8.95KB
plugin-tree (index.js) 10.58KB 3.72KB
plugin-view (index.js) 85.18KB 21.05KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 109.04KB 36.08KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.38KB 1.98KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.74KB 2.54KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 15.71KB 5.30KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 17.15KB 6.32KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

…-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
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3033.7 KB 3104.5 KB
Main entry chunk (gzip) 146.9 KB 350 KB
Entry file index-CrGnte9o.js —
Status PASS —

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.68KB 6.20KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 546.12KB 130.78KB
core (index.js) 8.94KB 3.59KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 221.99KB 61.72KB
fields (index.js) 252.81KB 63.78KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.02KB 11.00KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 51.44KB 14.62KB
plugin-charts (index.js) 71.73KB 20.08KB
plugin-chatbot (index.js) 198.20KB 47.14KB
plugin-dashboard (index.js) 132.96KB 35.17KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 255.19KB 66.48KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 143.13KB 36.57KB
plugin-gantt (index.js) 167.99KB 41.37KB
plugin-grid (index.js) 213.40KB 58.19KB
plugin-kanban (index.js) 48.71KB 15.17KB
plugin-list (index.js) 113.53KB 27.99KB
plugin-map (index.js) 21.74KB 7.07KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.49KB 11.97KB
plugin-timeline (index.js) 30.68KB 8.95KB
plugin-tree (index.js) 10.58KB 3.72KB
plugin-view (index.js) 85.18KB 21.05KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 109.04KB 36.08KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.38KB 1.98KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.74KB 2.54KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 15.71KB 5.30KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 17.15KB 6.32KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

🛑 Contract review ABSENT — the at-tier subagent could not start. PR stays draft, out of the queue, carriers hung.

domain:ui seat #1, session_01Xr7APep6jm1Zta3KUzPzZf, 2026-09-21T00:56Z. ⛔ This is not a review record and carries no verdict. It exists so the state is legible rather than silent.

What happened

This PR declares Clause-②: yes, so the enqueue gate is explicit: 「双肢命中任一 ⇒ 无达档条款②复核 PASS 在案 ⛔ 禁止入队」. This seat does not serve at CONTRACT_REVIEW_TIER, so 「未达档 ⛔ 不自审」 applies and the review was dispatched to an isolated at-tier subagent bound to head 591b37e0a15a60092d78cc041684d7f4a7c5ce43.

It terminated before reviewing anything — an API quota refusal at that tier (HTTP 429, rate_limit). ⇒ there is no verdict of any kind: ⛔ not a PASS, ⛔ not a FAIL, ⛔ not a partial reading to build on.

Why this seat is not reviewing it instead

The 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: 「起不来即无复核,标签原样、队列外等档」.

⚠️ So the tempting move — this PR is fully green (40 success / 3 skipped / 0 red on this head), the diff has been read, and an in-seat opinion could be written in minutes — is precisely the one that is refused. A review is a tier reading, ⛔ not a confidence level, and 「⛔ 自述档位与传参皆非读数」.

State, deliberately unchanged

PR draft — ⛔ not flipped to ready
queue ⛔ not enqueued, ⛔ no auto-merge armed
needs:contract-review hung on both carriers (this PR and card objectui#10166) — ⛔ not stripped, because 「清标即落地」 and nothing has landed
card state / assignee untouched
CI green on 591b37e0a1; ⭐ a green head that the review withholds is not done

What unblocks it — two routes, and only two

  1. The at-tier review runs. This seat will re-dispatch it on the same head when that tier is reachable again. ⭐ That is a first review that never happened, ⛔ not a re-roll: 「同 head 再起子代理须引前次作废因,⛔ 不重起求 PASS」 — the prior void reason is a quota refusal with no verdict, cited here, and this note is what a later attempt cites.
  2. The maintainer reviews it personally. 「唯一旁路是维护者亲审,逐次为准」 — per instance, ⛔ not a standing exemption.

⛔ There is no third route, and ⛔ waiting is not a failure state: 「队列外等待是安全态」.

domain:ui seat #1 · session_01Xr7APep6jm1Zta3KUzPzZf · review-absent record · bound to head 591b37e0a1, taken 2026-09-21T00:56Z


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Review handed to another agent by the maintainer — this seat stands down from re-dispatching it

domain:ui seat #1, session_01Xr7APep6jm1Zta3KUzPzZf, 2026-09-21T01:27Z. Supersedes the unblock plan in the preceding record (5754..., the review-absent note): route ① there said this seat would re-dispatch the at-tier review when that tier was reachable. ⛔ It will not.

Provenance of the instruction — maintainer, in the /pm-dispatch session driving this seat, 2026-09-21T01:27Z, verbatim:

并发保持3 10172 我会让其他agent审核

⇒ 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

PR draft · CI green on 591b37e0a1 (40 success / 3 skipped / 0 red)
queue ⛔ not enqueued, ⛔ no auto-merge
needs:contract-review hung on both carriers — the reviewing agent strips them on PASS or FAIL, per 「清标即落地」; ⛔ this seat will not strip them on a verdict it did not render
card objectui#10166 pm:dispatched, assignee unchanged — the claim stays with this seat until the PR lands

For whoever reviews it — what is already established, and what is deliberately not

Established and re-derived by this seat against the tree, ⛔ not taken from the dev's report: 8 of 8 submit owners in packages/plugin-form/ are gated (ObjectForm, ModalForm, DrawerForm, SplitForm, TabbedForm, WizardForm, MasterDetailForm, and EmbeddableForm transitively through the ObjectForm it hosts), and the pin file carries 8 rows — 5 literal plus 3 through one it.each. ⚠️ A grep for it( at line start reads 5 and under-reports; that instrument error is this seat's, recorded so the next reader does not repeat it.

⛔ Not established, and the review's to judge: whether Clause-②: yes is complete (a yes that under-declares is as wrong as a false no); whether the round-2 mechanism change — nesting now CHAINS an inner upload scope to the scope above, where round 1 had the inner scope shadow it — can under- or over-report; whether the published useUploadingScope / UploadingScopeProvider shape is coherent for a consumer outside this repo; and whether the two test rows that were vacuous on the first ablation pass are now failing for the right reason rather than a new wrong one.

⭐ 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.

⚠️ The PR body was corrected by this seat, not the dev: three blocks had gone stale across the two rounds, one of them asserting 「Five submit owners in this package are not wired」 — false as of this head, and it publishes verbatim into the CHANGELOG. The dev declined to patch its own body and flagged the conflict with the dispatch instead of silently choosing, which its standing contract requires; that was correct and the edit was owed to the seat.

domain:ui seat #1 · session_01Xr7APep6jm1Zta3KUzPzZf · stand-down record · bound to head 591b37e0a1, taken 2026-09-21T01:27Z


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 591b37e0a15a60092d78cc041684d7f4a7c5ce43

Isolated at-tier reviewer, arranged by the maintainer (「10172 我会让其他agent审核」) and adopted by the director seat; reviewed 2026-09-21T02:06Z. Merge-base 98178b2064d2cd12541e86cf7dfcf94e139b61a1 (= the PR's base sha); origin/main tip during the review 7725c10a06cff6014e3381fcc558dbca2309b43d; detached worktree /home/user/objectui-review-10172 pinned at the head (pnpm install --frozen-lockfile exit 0), removed at the end. Premises read in order: card objectui#10166 body + its 4 comments (triage, claim, two os-dev-reports — ⛔ no Ruling: comment exists on the card, so the diff is judged against the card body and the triage grounds), then the PR body, /files (2 pages), the 5 issue comments (0 review comments, 0 reviews), then AGENTS.md for which checks to run. Heavy suites went through /home/user/objectstack/scripts/pm/os-verify-lock.sh (three holds, each VERDICT command-exit 0); light gates ran directly in the worktree.

① Derived judgments

(1) Premise on the merge base — table A–D re-derived at 98178b2 and at the head, with my own greps.

  • A git grep -nw onUploadingChange excluding CHANGELOG / *.test.* / dist: at both trees exactly ONE host passes it — packages/app-shell/src/views/ActionParamDialog.tsx (the onUploadingChange: prop object that feeds its setUploading map); the other hits are the fields machinery, ADR-0059, the skills guide and changeset 7008, plus the new comments at the head. The JSX spelling onUploadingChange= matches nothing at either tree (exit 1). The head adds no second passer; ActionParamDialog.tsx is not in the diff.
  • B at the merge base the signal machinery is packages/fields/src/widgets/{useUploadingSignal.ts, FileField.tsx, ImageField.tsx, toHostProps.ts, types.ts} (+ a docblock in FieldEditWidget.tsx). The head adds uploadingScope.tsx beside it and makes useUploadingSignal call useUploadingScopeReport after the prop effect — one producer, two sinks.
  • C git grep -niw uploading -- packages/plugin-form at the merge base: exit 1, 0 files, with the positive control onChange matching 8 files in the same tree. At the head: 8 non-test files + the pin (exit 0); control 9 files. So the record-form package had no notion of upload state, and now does.
  • D at the merge base the only type="submit" / submit buttons owned inside packages/plugin-form/src are ModalForm, DrawerForm, WizardForm (Next + final) and MasterDetailForm's md-form-submit; ObjectForm (both arms), SplitForm, TabbedForm hand a type: 'form' node to SchemaRenderer, whose button lives in @object-ui/components (packages/components/src/renderers/form/form.tsx: disabled={isSubmitting || disabled} and nothing else). Submit owners (a handleSubmit/handleSave of their own): ObjectForm, ModalForm, DrawerForm, SplitForm, TabbedForm, MasterDetailForm, EmbeddableForm, plus WizardForm's handleNext = 8. The PR's 「D partly falsified」 reading and the round-2 「8/8」 reading both hold. RIGHT.

(2) The accept-set change declared under Clause-②: yes, enumerated from the diff.
Behaviour narrowings (a submit refused while an upload is in flight): ObjectForm (SimpleObjectForm.handleSubmit, flat AND sectioned arms both wrapped in the provider, submitLabel swapped, notice rendered — the schema.onSuccess inline-collector arm sits below the gate, so it is covered); ModalForm.handleSubmit (+ footer Save disabled, label, notice); DrawerForm.handleSubmit (same); SplitForm.handleSubmit and TabbedForm.handleSubmit (label + notice; the button is the components renderer's and stays enabled); WizardForm.handleNext on isLastStep only (final button disabled, label, notice; Next untouched); MasterDetailForm.handleSave (action-bar Save disabled, label, notice; nested ObjectForm scopes CHAIN up to it); EmbeddableForm — no code change, its handleSubmit is the inner ObjectForm's onSuccess, which the inner gate refuses first (pinned). The line-items subform (LineItemsField) owns no Save; its file cells report into whichever host scope is above. No other host is narrowed; ActionParamDialog mounts no provider and is untouched.
Published-surface additions: @object-ui/fields barrel (packages/fields/src/index.tsx, 17 added lines, all export/comment, nothing existing changed): useUploadingScope, UploadingScopeProvider, useUploadingSignal (values) and export type { UploadingScope } — FOUR published names; the PR body and the dev report say 「three exported symbols」, so the type is an under-declaration by one type-only name. useUploadingScopeReport stays module-internal (not in the barrel) — the minimal shape. @object-ui/plugin-form: ⛔ no new export (uploadGate.tsx is absent from packages/plugin-form/src/index.tsx, grep exit 2) and no new prop or type member on any host. @object-ui/i18n: one new key form.uploadInFlight in all ten packs. No package.json, exports map or .d.ts is in the diff.
Against the card: the body asks for a DIFFERENCE pin on the stored value — delivered (uploadInFlightSave.test.tsx, both legs assert the adapter's created array first). Triage asks that Save be disabled in flight AND that the chain say why — delivered as disable + 「Uploading…」 label + role="status" sentence 「Wait for the upload to finish before saving.」 on the four hosts that own their button, and as the same label + sentence + toast.error(reason) on an attempted submit on the three whose button belongs to @object-ui/components (node-level disabled would grey out every field and Cancel — a worse trap; a per-button submitDisabled on FormSchema is outside this claim's file surface). The remedy is actionable (wait; the label and sentence both say so, and the button's label reverts when the upload settles). Minimal: the aggregation is the only route that does not thread a callback through the form node renderer. RIGHT, with the fourth published name (UploadingScope, type-only, covered by the fields minor) named as an under-declaration.

(3) Unmount-releases-slot and no-provider inertness: pins found, run, ablated, restored.
Pins: packages/fields/src/widgets/uploadingScope.test.tsx rows 「releases a widget that UNMOUNTS mid-upload」, 「releases the outer scope when a whole inner scope unmounts mid-upload」 and 「is inert for a widget with no provider above it」. Baseline in my worktree: that file 8 rows green, useUploadingSignal.test.tsx 4 rows green, uploadInFlightSave.test.tsx 8 rows green (5 it + 3 via one it.each), inside the 109-file run below. Ablations, each an anchored edit in MY worktree (anchor count 1 before / 0 after), restored with git checkout HEAD -- and proved by git diff HEAD = 0 lines:

  • Leg 0 (the dev's own ablation re-run): uploading: scope.anyUploading → uploading: false in uploadGate.tsx — exit 1, 8 failed / 0 passed, every row on the stored-value assertion (AssertionError: expected [ { name: undefined, …(1) } ] to deeply equal [], + "attachment": undefined) at six distinct sites including the MasterDetailForm row (400 ms settle) and the EmbeddableForm row (minFillTime: 0) — the two rows the dev disclosed as formerly vacuous now fail on the write, for the right reason.
  • Leg 1 (unmount release): the widget's cleanup report(id, false) in useUploadingScopeReport commented out — exit 1, 1 failed / 15 passed (16), the red row is exactly 「releases a widget that UNMOUNTS mid-upload」.
  • Leg 2 (inertness): sinkRef.current?.report → sinkRef.current!.report (context used unconditionally) — exit 1, 1 failed / 17 passed (18) over the scope file, the pin file and ActionParamDialog.uploading.test.tsx; the red row is exactly 「is inert for a widget with no provider above it」 (TypeError: Cannot read properties of null (reading 'report')). ⚠️ The ActionParamDialog.uploading file stayed green under this leg because its stub widget calls onUploadingChange directly and never enters useUploadingSignal — so that file is evidence the host is untouched, ⛔ not evidence of inertness; the scope row is.
  • Leg 3 (extra, the chained release): the inner scope's cleanup report(scopeId, false) commented out — exit 1, 1 failed / 15 passed (16), red row exactly 「releases the outer scope when a whole inner scope unmounts mid-upload」.
    After the last leg: git status --short 0 paths, git diff HEAD 0 lines, HEAD 591b37e0a1. RIGHT.

(4) Package tests, typecheck, lint, gates — exit codes.

  • pnpm exec vitest run packages/plugin-form/ + the two fields scope files: exit 0 — 109 files, 1053 passed, 1 skipped.
  • pnpm exec vitest run packages/fields/: exit 0 — 175 files, 2976 passed.
  • pnpm exec vitest run packages/app-shell/ (whole package, the existing onUploadingChange host): exit 0 — 744 files, 7350 passed, 1 skipped; ActionParamDialog.test.tsx + ActionParamDialog.uploading.test.tsx re-run explicitly with --reporter=verbose: exit 0 — 2 files, 54 passed, both upload-guard rows green (the existing consumer is unaffected).
  • pnpm exec vitest run packages/i18n/: exit 0 — 69 files, 1145 passed.
  • turbo run type-check --filter fields / plugin-form / app-shell / i18n (pulls ^build of their deps): exit 0 — 33 tasks successful, 33 total; worktree 0 dirty paths before and after.
  • turbo run lint (same four): exit 0 — 0 errors in every package (plugin-form 920 warnings, fields 1067, app-shell 3054, i18n 33, root 32 — all warning-level, pre-existing classes). eslint over the 23 touched .ts/.tsx: exit 0, 0 errors, 325 warnings (202 no-explicit-any, 96 react-refresh/only-export-components, 13 exhaustive-deps, 1 no-unused-vars — tree-wide pre-existing classes; the new non-test files carry 6, three of them 「Cannot access refs during render」 on the ref-during-render pattern useUploadingSignal.ts already had at the merge base).
  • Gates AGENTS.md names or CI runs: check-changeset-presence 0 (23 source files of 3 released packages, 1 changeset), check-changeset-no-major 0 (= CI 「Changeset Bump Policy」), check:changeset-claims 0, check:new-line-citations 0 (0 new cross-file citations), check:i18n-keys / i18n-drift / i18n-dead-keys / i18n-designer-parity 0/0/0/0, check:control-bytes 0, check:component-surface-parity 0, check:phantom-deps 0, check:unreferenced-sources 0, check:test-path-roots 0, check:vi-mock-specifiers 0, check:pending-changeset-literals 0, check:self-import 0.
  • Published-surface / API-report gate: this repo has no api-extractor or *.api.md; the README gate check:readme-exports needs a full build and is NOT MEASURED locally — CI's 「README Export Check」 is success on the head (it checks README imports name real exports, ⛔ not that new exports are documented; see ③).
    RIGHT (readme-exports read from CI, named as such).

(5) Merge faithfulness. git diff origin/main...591b37e0a1 --name-only = 24 paths, byte-identical to the PR /files list (diff exit 0); --stat 24 files, +1113 / −56 = the PR's additions/deletions. .changeset/ between merge base and head: exactly one A (10166-record-form-save-during-upload.md), no M, no D — no changeset present at the merge base was modified or deleted. Its frontmatter names all three published packages whose src/ moved (plugin-form, fields, i18n); app-shell and components have no source change and need none. 4 commits on the branch, all on this card. RIGHT.

(6) The 7 pending changesets the re-read bot names (comment 5753725111), each read at the head.

  • 6237-wizard-step-config-split: claims WizardStepConfig is declared independently in WizardForm.tsx with no *When key — export interface WizardStepConfig is still there, 0 When members; this diff adds only the gate. STILL TRUE.
  • 6625-retire-fieldmeta-decimals: names fields/src/index.tsx only as the .scale positive control — .decimals reads in that file 0 at both trees; the barrel append changes none of it. STILL TRUE.
  • 6661-app-launcher-nav-menu-renderers: names en.ts for the console.nav strings — still present (launcherLabel under nav:); the added line here is under form. STILL TRUE.
  • 6694-dashboard-lookup-reference-meta: claims packages/fields/src/index.tsx reads exactly reference_to, reference, display_field off a cell's field — the read site is unchanged (1 at both trees); the 17 added lines are exports. STILL TRUE.
  • 6837-reference-to-arm-deletion: claims LookupCellRenderer in fields/src/index.tsx was left alone — 12 occurrences at both trees, no changed line among them. STILL TRUE.
  • 7166-retire-inert-fieldmeta-copies: claims zero descriptionField|lookupColumns|lookupFilters in packages/fields/src/index.tsx against a control of the display_field|displayField|reference_to spellings — 0 retired at both trees; the control reads 26 at BOTH trees where the body says 22, i.e. that figure was already stale at the merge base and ⛔ not moved by this diff (a 完善设计器的每一个细节 #9-shaped count, not this PR's to fix). STILL TRUE as to anything this diff touches.
  • 8738-object-form-fields-description: claims an object member in top-level fields is skipped by SimpleObjectForm (ObjectForm.tsx) and buildFlatFields — both read sites present with the route-1 warn; this diff touches neither. STILL TRUE.
    None of the seven goes false. RIGHT.

(7) CI on the head. GET …/commits/591b37e0…/check-runs (paginated, 43 unique): 40 success / 3 skipped / 0 failure / 0 pending; skipped = Test (coverage), Test (coverage shard …), dependabot. Ruleset readable (GET /rules/branches/main): required contexts Lint, Type Check, Build & E2E, Build Docs, Changeset Declaration, Test — all six success; merge queue SQUASH, strict_required_status_checks_policy: true. RIGHT.

② Semver level

The changeset declares @object-ui/plugin-form: minor, @object-ui/fields: minor, @object-ui/i18n: minor. fields: three new public values plus one public type — additive → minor is the semver floor and is right. i18n: a new key in all ten packs — additive → minor, right. plugin-form: no new export, but a shipped behaviour narrowing on published forms (a submit that succeeded is now refused) — AGENTS.md 版本号策略 marks even breaking changes minor (「objectui 自身的破坏性变更也标 minor」) and bans major mechanically (check-changeset-no-major exit 0, CI Changeset Bump Policy success), so minor is right per the repo's convention and patch would have under-stated a narrowing. The fixed group in .changeset/config.json collapses all three to one group bump in any case. Levels right, per package and per convention.

③ Boundary flags

  • Dev OQ1 (round 1) — flat/sectioned Save not disabled, wants submitDisabled on FormSchema: CONFIRMED by my read of packages/components/src/renderers/form/form.tsx (disabled={isSubmitting || disabled}, node-level only). Not blocking: the data-integrity half is proved on all 8 hosts by leg 0, the affordance is label + sentence + reason-on-refusal; the follow-up belongs in @object-ui/components, outside this claim's surface.
  • Dev OQ2 — the remaining five hosts: moot, 8/8 wired in round 2 (5 it + 3 it.each = 8 rows; 8 red under leg 0).
  • OOS class a — WizardForm: leaving a step mid-upload loses the attachment: CONSISTENT with the source (currentSection = schema.sections[currentStep]; the step form renders only that section's fields, so Next unmounts the widgets and the unmount release clears the gate). NOT MEASURED at runtime; predates this PR; not blocking; needs its own card.
  • OOS class c — the form renderer forwards unknown field keys (stripRegisteredFieldProps + ...fieldProps spreads in form.tsx): CONFIRMED; unused by this PR; not blocking.
  • OOS 「noted, not filed」 — README / content/docs/guide silent on the new exports: CONFIRMED (packages/fields/README.md 0 mentions, content/docs 0). ⚠️ AGENTS.md Add automated testing infrastructure and CI/CD workflows #2 reads 「Not done until docs reflect the code」. This is a docs gap on a published surface, not an accept-set / export / behaviour deviation, so it is outside this review's blocking set — but it has no carrier: the adopting seat should take the README paragraph on this PR before enqueue or name the owner. Named, not blocking.
  • OOS portal-rooted subforms: no createRoot / createPortal in fields or plugin-form src — hypothetical, not blocking.
  • File surface: declared packages/plugin-form/src/ + packages/fields/src/widgets/. Outside it the diff touches packages/fields/src/index.tsx (barrel, additive), ten packages/i18n/src/locales/*.ts (one line each) and .changeset/ — all three declared in both dev reports and the PR body, none hidden. ActionParamDialog.tsx (read-only under objectui#10130) is not in the diff.
  • Under-declaration: export type { UploadingScope } is a fourth published name the prose does not count; type-only, covered by the fields minor; not blocking.
  • Lint: three warning-level 「Cannot access refs during render」 hits on the new ref-during-render lines mirror the pre-existing pattern in useUploadingSignal.ts; lint exit 0; noted.

Implemented-by: claude/issue-10166-record-form-save-during-upload
Reviewed-by: session_012GcsUbuqFGBibkEDMRC1eE

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Provenance — director seat, summon #25 (session_012GcsUbuqFGBibkEDMRC1eE), 2026-09-21T02:07Z, adopting the review the maintainer arranged (「10172 我会让其他agent审核」)

  • Contract review of record: 5754473349, Served-tier: CONTRACT_REVIEW_TIER, judged head 591b37e0a15a60092d78cc041684d7f4a7c5ce43 (the current head), VERDICT: PASS — seven ① judgments RIGHT; the reviewer's transcript re-read by this seat: 237 of 237 requests stamped at the tier constant, zero fallbacks. Independence pair Implemented-by: claude/issue-10166-record-form-save-during-upload / Reviewed-by: session_012GcsUbuqFGBibkEDMRC1eE (the dev round was the domain:ui seat's mode:subagent; the reviewer was an isolated subagent whose verdict this seat adopts verbatim).
  • What the reviewer re-derived first-hand: the premise table A–D at the merge base and the head; eight of eight submit owners gated; four ablations in its own worktree (gate forced open → 8/8 pin rows red on the stored value; unmount release removed → 1 red; context read unconditionally → 1 red; inner-scope release removed → 1 red), each restored to a clean tree; package tests for plugin-form, fields, app-shell (the one existing onUploadingChange host unaffected) and i18n, turbo type-check and lint, the repo's changeset / i18n / surface gates all exit 0; diff file set = the PR's 24 files; the seven pending changesets the bot named all still true; three minor changesets judged right per AGENTS.md; CI on the head 40 success / 3 skipped / 0 failure, all six required contexts green.
  • needs:contract-review stripped in this stroke from this PR and from objectui#10166 (label-write, read back); PM_SWEEP_REPO=objectstack-ai/objectui check-clause2-carriers --pair 10172 exit 0.
  • Not blocking, for the domain:ui seat's own disposal (⛔ not a re-hang): the new public names (useUploadingScope, UploadingScopeProvider, useUploadingSignal, type UploadingScope) are undocumented in README / content docs — a docs-only follow-up after landing, ⛔ not a push onto this head (a moved head re-hangs the carriers); the flat ObjectForm / SplitForm / TabbedForm Save button is refused-not-disabled because the components renderer exposes no per-button disable — a submitDisabled follow-up belongs to @object-ui/components; WizardForm step-leave losing an in-flight attachment predates this PR and is its own card if the seat judges it reachable today.
  • Landing: non-governed diff; the domain:ui seat lands it (ready + queue) under its own concurrency — this seat only clears the gate.

Generated by Claude Code

@os-elon-musk
os-elon-musk marked this pull request as ready for review September 21, 2026 02:34
@os-elon-musk
os-elon-musk added this pull request to the merge queue Sep 21, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

🔴 Dequeued at 02:47:59Z by Test (shard 6/8) — measured as not this PR's, card filed, one re-queue spent

domain:ui seat #1, session_01Xr7APep6jm1Zta3KUzPzZf, 2026-09-21T03:02Z. Head unmoved at 591b37e0a1 — ⛔ nothing has been pushed here, so the Clause-② review on record stays on its own head.

The failure, on queue branch gh-readonly-queue/main/pr-10172-0d379f571, commit eab765880f (run 35554774685, job 106196150575):

FAIL  dom  packages/plugin-form/src/fieldSecurityPayload.test.tsx
  > 'DrawerForm' > omits the FLS-refused field from the PATCH while the edited field and every un-denied one still go
AssertionError: expected { indicator_name: 'Revenue', …(3) } to match object { actual_value: 5000 }
-   "actual_value": 5000,   +   "actual_value": 4000

4000 is RECORD.actual_value — the value the test's own findOne returns. The payload carried the record, not the edit.

Why it is not this diff, by measurement rather than by assertion:

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.

⚠️ Stated as a reading, not a measurement: I did not reproduce the red locally. The mechanism above is consistent with every number in the table, and objectui#10190 records the competing reading (a stale 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

@os-elon-musk
os-elon-musk added this pull request to the merge queue Sep 21, 2026
Merged via the queue into main with commit e686f4d Sep 21, 2026
45 checks passed
@os-elon-musk
os-elon-musk deleted the claude/issue-10166-record-form-save-during-upload branch September 21, 2026 03:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

3 participants