Skip to content

fix(plugin-form,fields): a master-detail edit opens its lines in stored order, and a cell edit renumbers no line (objectui#12286) - #12304

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-12286-master-detail-stored-order
Oct 13, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-12286-master-detail-stored-order

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #12286
Clause-②: no

What was wrong

Saving a master-detail edit form wrote lines the user never touched: their stored position was swapped and their updated_at bumped (the card reproduces it on the showcase's INV-1001 and INV-1002). Two halves, measured on main at fd4872f:

  • Read. MasterDetailForm's readLinesOf read the lines with { $filter, $top: 500 } and no sort, so the grid drew the server's order.
  • Write. GridField's emit() stamped row[sortField] = index on every change, a plain cell edit included. Once the grid drew a non-stored order, one edit gave every line a new position. Even in stored order, lines whose positions were tied (two at 0, which the showcase field's defaultValue: 0 produces), empty or gapped were all renumbered by one edit.

The save itself already sent only what differs from the read: buildMasterDetailEditBatch diffs each row against the RowState.original snapshot with changedFields, and emits no operation for an unchanged row (objectui#10108). The card's own capture shows it: the untouched line's update carried {position: 0} alone. So the pristine snapshot and the per-row diff the triage asked for already sit where the batch is assembled; what reached them was a position the grid had rewritten. The write half of this fix is therefore in the grid, not in the save.

What changed

  • packages/plugin-form/src/MasterDetailForm.tsx: readLinesOf asks for the lines ascending by the entry's derived sortField, the record id breaking ties: $orderby: [{ field: SORT_FIELD, order: 'asc' }, { field: 'id', order: 'asc' }]. That is the array form QueryParams.$orderby declares, and serializeOrderBy in @object-ui/data-objectstack lowers it to sort=position,id. The sort field is known at read time: the load effect re-reads once resolvedEntries lands, and each resolved entry carries sortField. An entry with no derived sort field (a fully configured detail, or a child with no sort-named field) reads as before, with no $orderby.
  • packages/fields/src/widgets/GridField.tsx: emit() takes a renumber flag, and the one cell-edit path (applyPatch on an existing row) passes false. Adding (Add, or typing into the trailing entry row), removing, duplicating and moving a row still renumber every row to its index. The renumber on a real reorder stays.
  • Docs: the GridFieldMetadata.sortField description in packages/types, content/docs/fields/grid.mdx, content/docs/plugins/plugin-form.mdx and the plugin-form README now say when the field is stamped and how the lines are read. packages/types is touched for that description only: no type, export, prop, spec key, i18n key or accepted input is added or changed, and the member's type is untouched. Hence Clause-②: no.
  • .changeset/12286-master-detail-stored-order.md: patch for @object-ui/plugin-form, @object-ui/fields and @object-ui/types.

Pins

packages/plugin-form/src/MasterDetailForm.storedLineOrder-12286.test.tsx drives the real MasterDetailForm and the real line grid, on the showcase invoice line's shape. The data source is a fake server: it returns the lines in its own order, which is not the stored one, unless the read asks for an order, and it refuses an $orderby shape it does not read.

  1. The grid opens in stored order when the server returns another, and the read whose rows it holds carries the sort.
  2. Editing one line's field sends exactly one update, { quantity: 11 }, with no position.
  3. Moving one line (C onto B, in A, B, C) sends only the two lines whose position changed.
  4. Tied (0, 0) and empty (null, null) positions open in id order, and editing one line sends that line alone.

packages/fields/src/widgets/GridField.sortFieldCellEdit-12286.test.tsx pins the widget half for tied, gapped, empty and absent positions. Its control is the entry-row add, which renumbers.

Ablation: one leg per half

Each leg ran through objectstack's scripts/ablation-replace.mjs in wrap mode. Each anchor hit once (1 → 0), and each leg was restored to the HEAD blob with git diff HEAD empty. Both subjects resolve to src: the test imports ./MasterDetailForm, and vitest.config.mts aliases @object-ui/fields to packages/fields/src. So no dist leg applies.

  • Read half removed (...(entry.sortField became ...(false && entry.sortField): 5 failed, 5 passed. All five master-detail pins went red (Widget drawn first; C, A, B; Second, First), and the five grid pins stayed green.
  • Write half removed (the cell edit renumbers again): 6 failed, 4 passed. The four grid cell-edit pins and the tied and empty master-detail pins went red. The three distinct-position master-detail pins stayed green. That measures the triage's "read-only fixes nothing when stored positions tie or are null": with distinct 0, 1, 2 positions the read alone is enough, and with tied or empty ones only the grid half holds.

Gates

  • Build: turbo run build --filter=@object-ui/plugin-form^... --filter=@object-ui/fields^... --concurrency=2: 11 of 11 tasks successful.
  • Tests: pnpm exec vitest run --maxWorkers=2 packages/plugin-form/ packages/fields/ plus the 12 suites outside them that render the master-detail form or the grid widget. The 12 were found by a git grep for MasterDetailForm, object-master-detail-form, LineItemsField, GridField, LineItemsPanel or record:line_items in a test file that imports @testing-library/react. Result: 446 files passed and 1 skipped (447); 7029 tests passed and 8 skipped. That run was on 3d9e41c. The only later commit, 040cd9d, types the new pin's fake server. On 040cd9d these five files passed 35 of 35: the two new pins, GridField.fieldKeys-11070, MasterDetailForm.inlineColumnsSortAmount-11144 and masterDetailDetailsMembers-8071.
  • Type-check on 040cd9d: pnpm --filter @object-ui/fields --filter @object-ui/plugin-form --filter @object-ui/types run type-check exited 0. All three type-check scripts echoed. tsc -p tsconfig.test.json --listFiles lists each new test file.
  • eslint on 040cd9d: the 6 touched TS files have 0 errors. Per-file warnings equal the base's (26, 22, 3 and 0), and the two new tests have 0. This narrowing is a measurement, on three counts:
    • Population: the root eslint.config.js (one flat config), with all 6 files in the JSON output and none ignored.
    • File count: 6, read from --format json.
    • Untouched files: no type-aware parsing (parserOptions.project or projectService) is configured, and no rule under eslint-rules/ reads another file. So this diff cannot move a verdict on an untouched file.
  • Repo checks on 040cd9d, all exit 0: check-changeset-presence, check:changeset-claims, check:pending-changeset-literals, check:new-line-citations (0 new), check:control-bytes, check:test-path-roots, check:vi-mock-specifiers, check-doc-links, check-changeset-no-major, check-changeset-fixed and check:designer-field-key-parity.
  • Not run: check:eager-closure, because the diff adds no import.
  • NOT MEASURED: check:readme-exports. It needs every package's dist and answered "population COLLAPSED" with 25 packages unbuilt. The README edit is prose only, with no fenced block. CI runs it.

Acceptance notes

  • Removing a line still renumbers. It writes the positions of the lines below the removed one. That is part of "adding, removing, duplicating and moving renumber", which the ruling keeps; it pins the edit and the move.
  • An add renumbers every line. On lines with tied or empty positions, that writes each sibling whose position changes. This is deliberate: every position is set, so the new line's place survives a reload however the driver orders nulls.
  • The lines are read twice on mount when a detail needs deriving: once before the child schema lands (no sort field yet, so no $orderby), then again once it resolves. The second read supersedes the first. This predates the change, and the pins wait for the second read. Observation only; not filed.
  • A line added through the row form gets no position. In inlineMode: 'form', addRowViaForm sets none, so the line saves with the field's default or null. With the read now ordered, such a line sorts by that value and its id rather than last. The only example child with a sort-named field, the showcase invoice line, is grid-mode. Noted, not filed.
  • Empty positions sort differently by driver. Ascending order puts them first on SQLite and last on Postgres. This change does not normalize that; any add, remove, duplicate or move stamps every line, and after that no position is empty.
  • Same wave: objectui#12292 holds plugin-grid/src/ObjectGrid.tsx; this PR does not touch it or plugin-form/src/occSave.tsx.

Implemented in session https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z (dispatched by the domain:ui seat 1).


Generated by Claude Code

…ed order, and a cell edit renumbers no line (objectui#12286)

Saving a master-detail edit form rewrote the stored position of lines the
user never touched. Two halves:

- Read: MasterDetailForm's readLinesOf asked for the lines with no sort, so
  the grid drew the server's order. It now asks for them ascending by the
  child's derived sort field, the record id breaking ties.
- Write: GridField stamped row[sortField] = index on every change, a cell
  edit included, so lines with tied, empty or gapped positions were all
  renumbered by one edit. A cell edit now renumbers nothing; adding,
  removing, duplicating and moving a row still renumber. The save already
  sent only the fields that differ from the read (objectui#10108).

Pins on a real MasterDetailForm over a fake server that returns the lines
out of stored order: one edit sends one update, one move sends only the
lines whose position changed, the grid opens in stored order, and tied or
empty positions open in id order with one edit sending one update.

Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
…ad of reading it as any (objectui#12286)

Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat ACCEPT: objectui PR #12304 at 040cd9d · domain:ui seat 1 · session_01CsyMRsFJBKNEMa4oPY1b5z · 2026-10-13T04:50Z

Verdict: ACCEPT. Reviewed on GitHub against the claim 6137913043 and triage 6137185790, not against the report 6138469969. Clause-②: no, and the diff touches none of the three contract surfaces (no yes declaration, no packages/spec, no governed text). So green CI on this head plus this read decides, and no at-tier review is owed.

PR form.

  • Draft, base main, assignee zhuangjianguo.
  • Line 1 is Fixes #12286 and line 2 is Clause-②: no; a scan of the whole body found no other closing verb next to a card number.
  • check-governed-merges --pr reads NOT governed, 387 lines.

The diff, read.

  • Read half. MasterDetailForm's readLinesOf adds $orderby: [{ field: sortField, order: 'asc' }, { field: 'id', order: 'asc' }] only when the resolved entry carries a derived sortField; with none, the query is unchanged. This is triage's "ascending by the derived sort field, with a stable tie-break".
  • Write half. GridField's emit(next, renumber = true) makes the one cell-edit path (applyPatch on an existing row) pass false. Add, entry-row add, remove, duplicate and move still renumber, as triage keeps ("still renumbers on a reorder").
  • Zone 2's save-side assumption is falsified, and the seat accepts the correction. The save already sends only rows that differ from the read (buildMasterDetailEditBatch diffs against RowState.original, objectui#10108). The card's own capture shows the untouched line's update was {position: 0} alone, so the cause was the grid's restamp on a cell edit. The write half therefore lives in GridField, which meets triage's intent: "A row whose stored position already equals its index is not written."

The changeset, sentence by sentence. @object-ui/plugin-form, @object-ui/fields and @object-ui/types, each patch; the types change is JSDoc only.

  • "no longer rewrites the position of lines the user did not touch", the read "ascending by the child's derived sort field, the record id breaking ties", "renumbers only when a row is added, removed, duplicated or moved" and "a cell edit leaves every row's position as it was": each is true of the diff.
  • "Editing one line's field now sends exactly one update, and moving a line sends only the lines whose position changed": true, and pinned.

Accepted deviations.

  • The docs and JSDoc that said the grid stamps "on every change" are corrected (AGENTS.md Add automated testing infrastructure and CI/CD workflows #2): GridFieldMetadata.sortField, content/docs/fields/grid.mdx, content/docs/plugins/plugin-form.mdx, the plugin-form README, and one test header.
  • The write fix is in GridField, not in the save (see above).

Evidence, as reported.

  • The new pins run on a real MasterDetailForm and a real line grid, against a fake server that returns its own order unless $orderby is sent. One edit sends exactly one update; one move sends exactly two; tied and empty positions are pinned.
  • Ablation of the read half turns 5 of 10 red, and ablation of the write half turns 6 of 10 red. Both were restored blob-equal.
  • The suites ran 446 files and 7029 tests, all passing.

Acceptance notes, agreed and carried; none filed (zero pull, or dormant):

  • An inline-mode addRowViaForm line has no position.
  • A detail that needs deriving is read twice on mount.
  • deriveDetail picks the sort field by name only. A same-named FORMULA field would now be sent as $orderby, which objectstack refuses with 400 INVALID_SORT, so that collection's lines would fail to load. Before this PR, the same field already broke on save, where the grid stamped it. No example declares such a field: dormant, carrier none.
  • Empty positions order differently on SQLite and Postgres.

Next (the seat's). CI on 040cd9d is converging (12 green, 17 running at this read). When it is all green, pr_ready + automerge_enable.

Implemented-by: claude/issue-12286-master-detail-stored-order (mode:subagent dev of this seat)
Reviewed-by: session_01CsyMRsFJBKNEMa4oPY1b5z


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 72 chunks) 3131.4 KB 3204.6 KB
Main entry chunk (gzip) 73.5 KB 350 KB
Entry file index-5hbP2WCm.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) 20.69KB 7.62KB
app-shell (runtime-config.js) 22.59KB 7.89KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 41.19KB 11.12KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
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) 27.26KB 8.03KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (commentAuthorship.js) 1.39KB 0.78KB
collaboration (index.js) 1.93KB 0.85KB
collaboration (useCollaborationTranslation.js) 6.50KB 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) 588.51KB 141.71KB
core (index.js) 10.18KB 4.04KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 244.49KB 68.27KB
fields (index.js) 279.77KB 71.34KB
i18n (LocalizationContext.js) 2.92KB 1.42KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.52KB 2.39KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 36.87KB 9.88KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 40.26KB 11.71KB
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) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 15.35KB 5.51KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.82KB 2.38KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
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) 16.04KB 3.92KB
plugin-calendar (index.js) 54.31KB 15.86KB
plugin-charts (index.js) 84.72KB 23.27KB
plugin-chatbot (index.js) 201.72KB 48.07KB
plugin-dashboard (index.js) 143.63KB 38.79KB
plugin-designer (index.js) 233.77KB 50.02KB
plugin-detail (index.js) 254.49KB 67.01KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 178.42KB 46.26KB
plugin-gantt (index.js) 178.63KB 45.19KB
plugin-grid (index.js) 255.12KB 70.93KB
plugin-kanban (index.js) 53.23KB 16.71KB
plugin-list (index.js) 123.35KB 31.54KB
plugin-map (index.js) 27.24KB 9.03KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 45.65KB 12.81KB
plugin-timeline (index.js) 39.21KB 11.85KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 92.11KB 23.27KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 12.07KB 3.68KB
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) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.07KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.81KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
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) 1.13KB 0.65KB
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) 5.78KB 2.70KB
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 (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
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) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.26KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.48KB 3.50KB
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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Landing act by the dispatching seat (domain:ui seat 1 · session_01CsyMRsFJBKNEMa4oPY1b5z) · 2026-10-13T05:01Z: the ACCEPT 6138489645 at 040cd9d stands.

  • Clause-②: no, and no contract surface is touched, so no at-tier record is owed. Green CI on this head plus the seat's read decides.
  • At this head: 33 check-runs, each success or an expected skip (dependabot, the two Test (coverage) jobs); mergeable_state clean; NOT governed, 387 lines.
  • pr_ready + automerge_enable (the merge queue) follow in this act.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants