Repository navigation
fix(plugin-form): a mounted master-detail edit form advances its child-row baseline after a save - #10628
Conversation
…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
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract reviewServed-tier: ① Derived judgmentsResult mapping. Index alignment holds on every in-repo path. Server (objectstack Builder order equals rows order: the same What Failure and partial failure. No in-repo path resolves with per-op failure. In-flight edit claim. True as pinned, and incomplete. Identity is the only carrier of a created id ( Pins. 7 rows. Scratch worktree at head ( Out-of-scope finding. Real at source: ② Semver level
③ Boundary flagsCI on Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #10564
Clause-②: no
No exported symbol, type or prop changes.
@object-ui/plugin-formpublishes.only, fromindex.tsx, and the new helpers are module-private inMasterDetailForm.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.createoperation.originalbecomes the rows as read, laid over with what the batch sent them. That is the same ruleadvanceLoadedRecordapplies to the parent. A row the batch deleted leaves the baseline, and a row it did not touch keeps its snapshot.handleSavedstill empties the rows and remounts the header for the next entry.All of it is in
submitViaBatch, right afterrunBatchTransactionresolves. The pure helperchildRowsAfterSavecomputes the advance, and onesetRowStateapplies it.resultsare 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, onecreateper non-blank row that has no id, in row order. The helper pairs each such row with the nextcreateoperation, 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 aconsole.warn. The rows left unpaired stay creates, which is the behaviour before this PR. A row can never be given another row's id.originalis 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.Premise checks (the dispatch's mechanism hypotheses, measured)
H1 holds, measured on
origin/mainadeecd666through the real component, on a mounted edit form whose hostonSuccessstays on the form:create {qty: 5, po: 'po1'}. Save again with no change: the second batch carries the samecreate.L1withqty1, edited to 2 and saved (update L1 {qty: 2}), then back to 1 and saved: the second batch has no child operation, andonSuccessfires.original, and create mode'ssetRowState({})inhandleSavedclears it.setRows,applyRowEdit,addRowViaFormandcancelRowEditcarry it forward by reference.handleSavedresets only when!isEdit, and the effect's dependencies (isEdit,dataSource,schema.recordId,resolvedEntries) do not change after a save.resolvedEntriesis re-set wheneverschema.detailschanges identity. A host that re-renders the form with a freshdetailsarray afteronSuccesstherefore reloads the children by accident, and does not show the defect. The pins holddetailsstable, 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:
DataSource.batchTransactioncontract in@object-ui/typessaysresultsare index-aligned with the operations, and a create/update echoes the written record;POST /api/v1/batch(objectstackpackages/rest) pushescreated.recordfor a create and theql.updateecho for an update;emulateBatchTransactionpushes whatdataSource.create/updatereturn.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:
findper collection on every save;rows: [], so a failed post-save refetch would blank a grid over committed rows.LineItemsPaneldoes 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
submitViaBatchafterrunBatchTransactionresolves and before the handler returns. The headerObjectFormadvances the parent's baseline only oncesubmitHandlerresolves, then callsonSuccess. Both advances therefore come from the same committed batch, and the child one runs first.childRowsAfterSaveis pure and has no throwing path. A committed batch therefore cannot read as a failed save with one baseline already moved.H4: pins. They are in
MasterDetailForm.editBaselineAdvance.test.tsxand use the same harness as the existing master-detail tests: realMasterDetailForm, real line-item grid, registered fields, a data source double whose batch answers per the contract.Pins, red on base and green on head
adeecd666(A0 below)createdelete L2createand theupdate)Reverse verification (fix committed first, head
65d90fc8d)Each leg mutated the committed
MasterDetailForm.tsxand ran the pin file. The mutations went through objectstack'sscripts/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 fromHEADon an absolute path. After every leg the blob equalledHEAD(9c4d8596414e) andgit diff HEADwas empty. No rebuild was needed: the pin imports./MasterDetailFormrelatively, and vitest aliases every@object-ui/*import tosrc.adeecd666(childRowsAfterSavecount 0)setRowState({})removedThe one later source commit,
f036ff3a7, changes types only: the helper's record types moved fromanyvalues tounknownvalues, and its created-id map is keyed byobject. The final head's pin run is 7 passed, below.Checks at the final head
f036ff3a7turbo run build --filter='@object-ui/plugin-form^...' --concurrency=2(at65d90fc8d; no later commit touches a dependency)pnpm --filter @object-ui/plugin-form run type-check(tsc --noEmit && tsc -p tsconfig.test.json)--listFileson the test project lists the new pin filepnpm exec vitest run --maxWorkers=2 packages/plugin-form/pnpm exec vitest run packages/plugin-form/src/MasterDetailForm.editBaselineAdvance.test.tsxeslint --no-inline-config --format jsonon the two touched source filesMasterDetailForm.tsxhas 31 warnings with the same per-rule counts as the base filescripts/check-changeset-*.mjscheck:*scripts/check-*.mjscheck-governed-queue-guard.mjs --testover the three pathsDeclared narrowing on lint.
pnpm lintisturbo run lint, which runs each package's owneslint .. The run above covers the two touched source files; the changeset is not an eslint input.eslint.config.jssets neitherparserOptions.projectnorprojectService(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.tsxandoccSave.tsxare untouched.Acceptance notes
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 samecreate {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.advanceLoadedRecord, the child advance takes what the batch SENT as written. A value the server silently strips (reported asdroppedFields) is still taken as saved.masterDetailTx.ts, which is outside this card's file surface.Session:
https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhNGenerated by Claude Code