Skip to content

fix(plugin-form): a mounted master-detail edit form advances its child-row baseline after a save - #10628

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-10564-master-detail-child-baseline
Sep 25, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-10564-master-detail-child-baseline

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #10564

Clause-②: no

No exported symbol, type or prop changes. @object-ui/plugin-form publishes . only, from index.tsx, and the new helpers are module-private in MasterDetailForm.tsx. What moves is what the NEXT save of a still-mounted master-detail edit form sends.

What shipped

After a master-detail edit batch COMMITS, the child rows' baseline advances from that batch. This is the child-row form of advanceLoadedRecord, the parent-record advance objectui#10546 added.

  • Ids. Each row the batch created takes the id the batch echoed for its create operation.
  • Written values. Each collection's original becomes the rows as read, laid over with what the batch sent them. That is the same rule advanceLoadedRecord applies to the parent. A row the batch deleted leaves the baseline, and a row it did not touch keeps its snapshot.
  • Failure. A batch that rejects advances nothing, so the retry carries every operation again.
  • Create mode is untouched: handleSaved still empties the rows and remounts the header for the next entry.

All of it is in submitViaBatch, right after runBatchTransaction resolves. The pure helper childRowsAfterSave computes the advance, and one setRowState applies it.

  • Pairing ops to rows. The batch's results are aligned with the OPERATIONS, not with the rows. The edit builder puts the parent at index 0, then for each collection, in the order it was handed them, one create per non-blank row that has no id, in row order. The helper pairs each such row with the next create operation, using the builder's own exported predicates (idOf, isBlankRow). It checks every pair before trusting it: the operation's object must be the collection's child object, and every field the operation writes, other than the parent link, must hold the row's value (isSameStoredValue). A pair that fails the check stops the pairing, with a console.warn. The rows left unpaired stay creates, which is the behaviour before this PR. A row can never be given another row's id.
  • Live rows. A created row takes its id by identity with the row object the batch was built from. The advance lands only while the collection's original is still the array the batch diffed against. A reload that replaced the row state while the save was in flight has already set a newer one.
  • Edits during the save. A row the user edited while its save was in flight is a new object, so it takes no id. Its created record is in the new baseline, though, so the next save deletes that record and creates the row as it now stands. It does not duplicate it. There is a pin for this.

Premise checks (the dispatch's mechanism hypotheses, measured)

  • H1 holds, measured on origin/main adeecd666 through the real component, on a mounted edit form whose host onSuccess stays on the form:

    • (a) type into the ghost row and Save: the batch carries create {qty: 5, po: 'po1'}. Save again with no change: the second batch carries the same create.
    • (b) a stored child L1 with qty 1, edited to 2 and saved (update L1 {qty: 2}), then back to 1 and saved: the second batch has no child operation, and onSuccess fires.
    • A third result of the same stale baseline turned up: a row deleted by the first save is deleted AGAIN by the second.
    • Reading, confirmed: the children-fetch effect sets original, and create mode's setRowState({}) in handleSaved clears it. setRows, applyRowEdit, addRowViaForm and cancelRowEdit carry it forward by reference. handleSaved resets only when !isEdit, and the effect's dependencies (isEdit, dataSource, schema.recordId, resolvedEntries) do not change after a save.
    • A caveat: resolvedEntries is re-set whenever schema.details changes identity. A host that re-renders the form with a fresh details array after onSuccess therefore reloads the children by accident, and does not show the defect. The pins hold details stable, which is the case the card names.
  • H2: shape (ii), mapping the batch results onto the rows, chosen by measurement. What the batch returns on this path:

    • the DataSource.batchTransaction contract in @object-ui/types says results are index-aligned with the operations, and a create/update echoes the written record;
    • the server's POST /api/v1/batch (objectstack packages/rest) pushes created.record for a create and the ql.update echo for an update;
    • emulateBatchTransaction pushes what dataSource.create / update return.

    So the ids of created rows are carried, and the written values are the operations themselves. (ii) needs no guess, only the op-to-row pairing above, which is checked. (i), refetching the children, was not taken:

    • it costs a find per collection on every save;
    • unless the save also waits for the refetch, it leaves a window in which a second Save still re-creates the rows;
    • it overwrites any row edited while the refetch is in flight;
    • the effect's existing failure arm sets rows: [], so a failed post-save refetch would blank a grid over committed rows.

    LineItemsPanel does take (i) (await load() after its save). That component has its own load function, with a failure arm that keeps the rows.

  • H3: region order. The child advance runs in submitViaBatch after runBatchTransaction resolves and before the handler returns. The header ObjectForm advances the parent's baseline only once submitHandler resolves, then calls onSuccess. Both advances therefore come from the same committed batch, and the child one runs first.

    • A batch that rejects (atomic refusal, or an emulated batch that failed partway) throws before either advance.
    • childRowsAfterSave is pure and has no throwing path. A committed batch therefore cannot read as a failed save with one baseline already moved.
    • Pinned both ways: a refused atomic batch, and an emulated batch whose parent write landed and whose child create failed.
  • H4: pins. They are in MasterDetailForm.editBaselineAdvance.test.tsx and use the same harness as the existing master-detail tests: real MasterDetailForm, real line-item grid, registered fields, a data source double whose batch answers per the contract.

Pins, red on base and green on head

row base adeecd666 (A0 below) head
a row the first save created is not created again, and a later edit updates it by its new id red: the second batch re-sends the create green
a cell changed and then changed back to its first-read value is written, not dropped red: the second batch has no child operation green
a created row edited while its save was in flight is not duplicated red: the next save re-creates it and leaves the first record green
a row the first save deleted is not deleted again red: the second batch re-sends delete L2 green
a refused batch: the retry sends the same parent and child operations red at its last leg (after the successful retry, a third save re-sends the create and the update) green
a partially applied save advances neither green (by construction: base never advances) green; red under A2
create mode: a successful create still empties the lines and the header (control) green green; red under A3

Reverse verification (fix committed first, head 65d90fc8d)

Each leg mutated the committed MasterDetailForm.tsx and ran the pin file. The mutations went through objectstack's scripts/ablation-replace.mjs (the anchor must hit exactly once, with before/after counts and blob hashes read from disk), except A0, which swapped in the whole base blob and checked its hash. Each was restored from HEAD on an absolute path. After every leg the blob equalled HEAD (9c4d8596414e) and git diff HEAD was empty. No rebuild was needed: the pin imports ./MasterDetailForm relatively, and vitest aliases every @object-ui/* import to src.

leg mutation result red
A0 the whole file at base adeecd666 (childRowsAfterSave count 0) 5 failed / 2 passed all five defect rows
A2 the child baseline also advances when the batch REJECTS 2 failed / 5 passed both failed-save rows
A3 create mode's setRowState({}) removed 1 failed / 6 passed the create control
A5 created rows never take the echoed id 3 failed / 4 passed the created-row, in-flight and refused-batch rows
A6 an update's written values never reach the baseline 2 failed / 5 passed the revert row and the refused-batch row

The one later source commit, f036ff3a7, changes types only: the helper's record types moved from any values to unknown values, and its created-id map is keyed by object. The final head's pin run is 7 passed, below.

Checks at the final head f036ff3a7

check command result
dependency closure build turbo run build --filter='@object-ui/plugin-form^...' --concurrency=2 (at 65d90fc8d; no later commit touches a dependency) exit 0
type-check pnpm --filter @object-ui/plugin-form run type-check (tsc --noEmit && tsc -p tsconfig.test.json) exit 0; --listFiles on the test project lists the new pin file
package tests pnpm exec vitest run --maxWorkers=2 packages/plugin-form/ 121 files, 1275 passed, 1 skipped, exit 0
pin file pnpm exec vitest run packages/plugin-form/src/MasterDetailForm.editBaselineAdvance.test.tsx 7 passed
lint, touched files eslint --no-inline-config --format json on the two touched source files 2 files, 0 errors. MasterDetailForm.tsx has 31 warnings with the same per-rule counts as the base file
changeset presence / no-major / fixed / overwrite / claims scripts/check-changeset-*.mjs exit 0 all; presence: 2 source files of 1 released package, 1 changeset
pending changeset literals, line citations, control bytes, shell escape residue check:* exit 0 all; 0 new citations
test path roots, vi-mock x3, unreferenced sources, handler-key reads, metadata write doors, type-check coverage, lint coverage scripts/check-*.mjs exit 0 all
governed surface check-governed-queue-guard.mjs --test over the three paths NOT GOVERNED

Declared narrowing on lint. pnpm lint is turbo run lint, which runs each package's own eslint .. The run above covers the two touched source files; the changeset is not an eslint input. eslint.config.js sets neither parserOptions.project nor projectService (0 hits), so linting is not type-aware, and this diff cannot change the verdict on a file it does not touch.

Doc gates. No README, docs page or skill file changed, so no doc gate reads this diff.

File surface

packages/plugin-form/src/MasterDetailForm.tsx, the new pin file beside it, and .changeset/10564-master-detail-child-baseline.md ('@object-ui/plugin-form': patch). masterDetailTx.ts, ObjectForm.tsx and occSave.tsx are untouched.

Acceptance notes

  • Out-of-scope finding, handed to the seat (measured with a probe that was not committed). The Save button's safety-net timer in handleSave (saveGuardTimer, 1500 ms) releases the save guard while a slower batch is still in flight. At this head, the probe did this: the batch was held open, Save was disabled while in flight, and it was enabled again after 1.7 s. A second click sent a second batch carrying the same create {qty: 5, po: 'po1'} before the first one landed. That writes two records. It is a different mechanism from this card's baseline and needs a design choice, so it is not fixed here.
  • Written values follow the parent's rule. Like advanceLoadedRecord, the child advance takes what the batch SENT as written. A value the server silently strips (reported as droppedFields) is still taken as saved.
  • Not pinned: the pairing check's refusal path. The builder cannot be made to emit a mismatched order without editing masterDetailTx.ts, which is outside this card's file surface.
  • Docs. The package README section "What an edit save writes" says that after a successful save the form treats the fields it wrote as saved. That now also holds for master-detail child rows. The README is outside the file surface and was not edited.
  • The pending changeset from objectui#10546 says a form that stays open compares its next save with the record as it now is. That sentence is about the simple, modal and drawer forms and the master-detail parent operation. With this PR it holds for the child rows too, so no pending changeset needed reconciling.

Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN


Generated by Claude Code

…d-row baseline after a save

After a committed edit batch, the rows it created take the ids the server
echoed and each collection's `original` takes on what the batch wrote, laid
over the rows as read (the child-row form of `advanceLoadedRecord`). A second
save from the same mounted form therefore no longer re-creates a row, re-sends
a delete, or drops a cell changed back to its first-read value. A batch that
rejects advances neither the child rows nor the parent.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3050.4 KB 3104.5 KB
Main entry chunk (gzip) 147.9 KB 350 KB
Entry file index-DOh7-CTA.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.57KB 6.15KB
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) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.17KB 10.58KB
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.39KB 1.21KB
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.52KB 3.45KB
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.13KB 7.95KB
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.99KB 130.83KB
core (index.js) 9.22KB 3.71KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 223.91KB 62.28KB
fields (index.js) 259.05KB 65.69KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
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.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.40KB 12.91KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.28KB 11.09KB
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) 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.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.01KB 3.93KB
plugin-calendar (index.js) 51.40KB 14.61KB
plugin-charts (index.js) 74.94KB 20.89KB
plugin-chatbot (index.js) 198.36KB 47.20KB
plugin-dashboard (index.js) 133.50KB 35.37KB
plugin-designer (index.js) 216.25KB 44.39KB
plugin-detail (index.js) 232.96KB 61.65KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 150.03KB 38.50KB
plugin-gantt (index.js) 169.62KB 41.91KB
plugin-grid (index.js) 215.37KB 58.92KB
plugin-kanban (index.js) 48.26KB 15.04KB
plugin-list (index.js) 114.60KB 28.31KB
plugin-map (index.js) 22.42KB 7.38KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.55KB 11.99KB
plugin-timeline (index.js) 30.67KB 8.95KB
plugin-tree (index.js) 10.52KB 3.69KB
plugin-view (index.js) 87.84KB 21.96KB
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) 116.21KB 38.14KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.03KB 1.86KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.78KB 2.09KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.78KB 2.56KB
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) 18.27KB 6.20KB
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) 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) 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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f036ff3a77c2374db87a44cca16dc0aae56d4d11

① Derived judgments

Result mapping. Index alignment holds on every in-repo path. Server (objectstack origin/main 949e99b, packages/rest/src/rest-server.ts 13886–14127): one loop over ops.entries() inside ql.transaction, out.push(created?.record) for create, out.push(updated) for update, the ql.delete result for delete, answered as { results, droppedFields? }; any op failure throws to handleRouteError (non-2xx), and the client's fetch throws on !res.ok with httpStatus (packages/client/src/index.ts 7409; unwrapResponse alone does not throw), so a refused batch never resolves. emulateBatchTransaction (packages/core/src/adapters/batchTransaction.ts): results.push per op in order, throws after compensation. The other implementers (ApiDataSource 320, ValueDataSource 1232, runner/mockDataSource 50) delegate to the emulation; data-objectstack 4105 returns the SDK payload or falls back to the emulation on 404/405/501 when the capability was not declared. git grep -ln "batchTransaction(" on refs/review/main minus tests and docs: 7 files, no other implementer. Create echo id key: the server record's id (its own $ref resolver reads ref.id ?? ref._id); the helper's idOf reads id ?? _id ?? recordId, a superset. Absent, short or differently shaped results: echoes is [] or idOf(echo) is nullish, so the row stays a create (pre-fix behaviour) while updates and deletes still advance; nothing throws.

Builder order equals rows order: the same rows array reference is captured once into editDetails and handed to both the builder and the helper; toCreate is withFk filtered by falsy idOf, in row order; the isBlankRow skip is identical on both sides (the FK key is exempt); collections are consumed in the same order; a deleted row is absent from rows so never pairs; a grid reorder before save reorders both identically. Two gaps: (a) predicate divergence, builder falsy !idOf(r) versus helper idOf(row) != null, so a row carrying id: '' or 0 is a create for the builder and "has an id" for the helper; the cursor shifts by one and isBuiltFrom refuses unless the neighbour is field-identical, in which case the neighbour takes the shifted row's id and the second echo is orphaned. No component route produces such a row (GridField.blankRow nulls columns, duplicate strips the three id keys at 826, the drawer editor's values pass sanitizeFormData whose SERVER_OWNED_FIELD_NAMES holds id and _id), so it is latent and is the one way the unpinned refusal path is reachable in principle. (b) The echo is never checked against the op, only the op against the row, so a third-party DataSource that violates index alignment hands a row a wrong id undetected; contract-backed, and every in-repo implementer complies. Short of (a), no row can take another row's id.

What original becomes. Sent values laid over the read row, id from the echo. That is the parent's rule exactly: ObjectForm.tsx 1177 calls advanceLoadedRecord(loadedRecordRef.current, schema, writePayload) with writePayload, not result (sanitize.ts 325: { ...loaded, ...written }). Matches. Divergence from server truth (defaults, computed, trigger-changed, readonly-stripped values) is inherited from that rule; the PR body's Acceptance notes state the droppedFields case; the changeset states only "the same rule the parent record already followed (objectui#10156)", which is true. The echoed record was available on this path and unused: a fidelity opportunity, not a defect.

Failure and partial failure. No in-repo path resolves with per-op failure. runBatchTransaction rejects, submitViaBatch throws before childRowsAfterSave, ObjectForm catches before line 1177, so neither baseline moves; the parent advance sits after await schema.submitHandler(writePayload) in the same try, same result. childRowsAfterSave has no throwing path (Array.isArray guard, Map, spreads). One split remains: when a reload replaced original during the flight, the child advance is skipped by the cur.original !== s.diffedAgainst identity check while the parent still advances; the reload's own find then owns the child baseline. Narrow pre-existing race, not introduced here.

In-flight edit claim. True as pinned, and incomplete. Identity is the only carrier of a created id (createdIds keyed by row object, applied to cur.rows by Map.get). Without a sort field, GridField.applyPatch (packages/fields/src/widgets/GridField.tsx 743) re-creates only the edited row and the ghost path (740) appends, so only the edited line loses its id. With a sort field, emit (718) maps EVERY row to a new object on ANY grid change, and deriveMasterDetail.ts 476 auto-derives one from any child field named position, sort_order, sequence, line_no, line_number or sort (line 53), line_no being the ordinary line-item column. The grid is not disabled while saving (LineItemsField props at head 599–627 pass no disabled). So on such a child, one keystroke anywhere in the grid during a slow save leaves every line that save created id-less, and the next save deletes and re-creates all of them silently (nextCreate equals creates.length, so no warn). Delete-then-create also churns the server id of a line the user only edited, where an update by the echoed id would keep it. Not the card's defect and strictly better than base, which duplicated, but the changeset sentence names one edited line where the reachable behaviour is every created line of that save.

Pins. 7 rows. Scratch worktree at head (git worktree add --detach, pnpm install --offline --frozen-lockfile --ignore-scripts exit 0, no build): pnpm exec vitest run packages/plugin-form/src/MasterDetailForm.editBaselineAdvance.test.tsx gave 7 passed. A0 (file swapped for base blob 0a156d797, childRowsAfterSave count 0): 5 failed / 2 passed, on the re-sent create (expected [ { object: 'po_line', …(2) } ] to deeply equal []), the missing revert op (expected [] to deeply equal [ { …(3) } ]), the re-sent delete L2, the in-flight row and the refused-batch third leg, the right reasons. A5 (createdIds.set(row, id); anchor 1 to 0): 3 failed / 4 passed. A6 (written.set(op.id, op.data ?? {}); anchor 1 to 0): 2 failed / 5 passed. All three match the PR body; the blob was restored to c3676368a after each, git status clean, worktree removed, main checkout untouched. The partially-applied row and the create-mode control are green on base by construction, as declared. The refusal path is unreachable from a component route (see (a)).

Out-of-scope finding. Real at source: handleSave arms saveGuardTimer.current with a 1500 ms setTimeout that calls releaseSave (head 1253, base 1061), and releaseSave clears savingRef, so after 1.5 s a second click passes the savingRef.current guard and calls requestSubmit() again while the first batch is in flight, building a second batch from the same un-advanced rowStateRef. A re-entrancy guard defect, a separate class from the stale baseline. This PR neither worsens nor fixes it: both batches carry the create; whichever resolves first advances the baseline by identity, the other's advance is skipped by the diffedAgainst check, so the second record is written and then orphaned from the baseline (never re-created, unseen until a reload), where base went on duplicating on every later save.

② Semver level

patch and Clause-②: no hold. packages/plugin-form/package.json exports is . only (dist/index.d.ts, dist/index.js, dist/index.umd.cjs); git diff --stat from merge-base to head on src/index.tsx, src/masterDetailTx.ts and package.json is empty; idOf and isBlankRow were already exported from masterDetailTx.ts; childRowsAfterSave, isBuiltFrom, SavedDetailInput and SavedChildRows are module-private; the only touched type text is the JSDoc on the non-exported RowState. 3 files, +572/−17. Changeset sentence by sentence: the three "no longer" results, true (A0). Ids from the batch and the baseline taking what was written, true, with the caveat stated in code but not in the changeset that an echo without an id leaves its row a create. "Same rule the parent record already followed (objectui#10156)", true (writePayload on both). "A save that fails advances nothing", true on every in-repo path. The in-flight sentence, true for the line it names, incomplete for a sort-field child. "Create mode is unchanged", true: editDetails is null in create so no advance runs, handleSaved still does setRowState({}) and bumps formKey on !isEdit, control pin green. The pending 10156-edit-form-writes-only-changed-fields.md sentence about a form that stays open now also holds for the child rows, as the PR body says; PR objectui#10546 is on main (advanceLoadedRecord at ObjectForm.tsx 1177), so region order is met.

③ Boundary flags

CI on f036ff3a7: 43 check runs, 40 success, 3 skipped (dependabot, two coverage placeholders), 0 failed, 0 in progress at the final poll; Type Check, Lint and Test shards 1 to 8 all green. git merge-tree --write-tree refs/review/main refs/review/pr-10628 (main ac2d6f136, merge-base adeecd666): exit 0, no conflict; 7 commits on main since the base, none under packages/plugin-form/. Open PRs: 14 including this one; none touches MasterDetailForm.tsx. plugin-form is touched by #10626 (ObjectForm.tsx, SplitForm, TabbedForm, WizardForm, writePayload.ts, README, docs), #10620 (ObjectForm.tsx) and #8941 (package.json dep bump only); #5400 is the changesets release PR (1732 files, no MasterDetailForm hit on the first 200). No PR-body claim read false: the H2 server claim (created.record and the ql.update echo) and the LineItemsPanel await load() claim (line 399) both hold. Nothing should stop landing. Follow-ups worth filing, not blockers: the sort-field in-flight churn (widen the changeset sentence, fall back to positional matching when identity fails, or disable the grid while saving), and the 1500 ms guard release the dev already handed to the seat.

Implemented-by: claude/issue-10564-master-detail-child-baseline
Reviewed-by: session_014mXUNuFomfj24w7s1pZzhN

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 25, 2026 13:00
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit cc07476 Sep 25, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-10564-master-detail-child-baseline branch September 25, 2026 13:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants