Skip to content

Commit 2dd4d3f

Browse files
feat(plugin-form): an edit save writes only the fields that changed (#10546)
Fixes #10156 Clause-②: no (conditional on the stop above) The stop did not fire. No exported type, prop or signature changed. `submitHandler` keeps its declared type, and `@object-ui/plugin-form` publishes `.` only, from `index.tsx`, which re-exports neither `sanitize` nor `masterDetailTx`. After `pnpm --filter @object-ui/plugin-form run build` on this branch, `grep -c` over `dist/index.d.ts` prints `0` for each of `dirtyEditPayload`, `snapshotLoadedRecord`, `advanceLoadedRecord`, `LoadedRecordSnapshot`, `EditSaveTarget`, `isSameStoredValue`, `changedFields` and `sanitizeFormData`. The positive control, `ObjectForm`, prints `6`. ⚠️ One published behaviour does move: in edit mode, a host `submitHandler` now receives the payload the form would have written (see "What shipped"). The seat confirmed `Clause-②: no` in comment `5829229738` on objectui#10156, and ruled that the published JSDoc describing the seam is corrected in this PR. Round 2 below does that. ## What shipped An edit form now diffs its sanitized payload against the record it read with `findOne`, and writes only the fields that differ. - **Where the loaded record lives.** `SimpleObjectForm` (in `ObjectForm.tsx`), `ModalForm` and `DrawerForm` each hold the record in a ref, set by their own `findOne` and read only by the save path. The ref is tagged with the object and record it was read for. A save for any other record therefore finds no baseline and sends everything. `initialData` / `formData` are left alone, because they seed the form and supply the OCC token. No exported type or prop was needed. - **One place, both write routes.** In `SimpleObjectForm`, the diff runs once, before the route is chosen. It covers the host-owned `submitHandler` seam, which is how a master-detail form builds its parent operation, and the plain OCC-guarded `update`. `ModalForm` and `DrawerForm` compute it in the same `writePayload` their create path already used. - **One comparison.** `isSameStoredValue` and `changedFields` moved from `masterDetailTx.ts` into `sanitize.ts`, and both callers import that copy. The function bodies are byte-identical to the originals: a `diff` of each body, extracted from the base blob and from this branch, is empty. The master-detail child rows and the edit form use the same rule. - **Empty diff.** A save with nothing changed still sends the full sanitized payload, as it did before. It stays a real request, with the same OCC guard and a real server record for `onSuccess`. Skipping the request instead would report success for a save no server saw. That is the one outcome a wrong baseline must never produce. - **Baseline advance.** After a successful save, the snapshot takes on the fields just written. A form that stays mounted therefore diffs its next save against the record as it now stands. Without this, changing a field back to its first-read value would compare clean and be dropped, while the server kept the first save's value. The row `a second save from the same mounted form diffs against what the first save wrote` pins it. - **Untouched:** `occSave.tsx`; the OCC token (`baseRecord` is still `initialData` / `formData`); the 409 dialog; the create path; and the `sections` branch of `ObjectForm.tsx`. The save handler is shared by the flat and sectioned layouts, so a sectioned simple form also gets the diff. No line of the `sections` branch changed. A one-field edit, on a record that also carries a numeric string, a null, an ownership column and `updated_at`. The "full payload" line is the same pin under ablation A1 below, which puts the pre-branch full payload back: full payload update("deal", "d1", {"amount":10.5,"name":"Mine v2","qty":"5","remark":null,"stage":"open"}, {"ifMatch":"2026-09-25 00:00:00.000"}) this branch update("deal", "d1", {"name":"Mine v2"}, {"ifMatch":"2026-09-25 00:00:00.000"}) ## Round 2 (patch): the published `submitHandler` doc says what an edit-mode handler receives The seat asked for this in comment `5829229738` on objectui#10156. It is one commit, `13ac7751e`, fast-forward, and JSDoc plus changeset only: 20 lines added, 0 removed, no type moves. - `packages/types/src/objectql.ts`: the `ObjectFormSchema.submitHandler` JSDoc. Its first paragraph is unchanged, so "hands the collected values" stays true for create. A new paragraph says that in `edit` mode, for a record the form read itself, the simple, `modal` and `drawer` layouts hand over what the form would have written: the fields that differ from that read, or the full sanitized payload when nothing changed. It also says a field whose sameness cannot be settled counts as changed, and that the `tabbed`, `wizard` and `split` layouts, and a simple form the mobile `stepper` routes through the wizard, still hand over every collected value. The `BulkActionDef` icon example that draft PR objectui#8941 edits is untouched. - `ModalForm.tsx` and `DrawerForm.tsx`: their exported `ModalFormSchema` / `DrawerFormSchema` carry their own copy of the same "hands the collected values" sentence, under a comment that says the copy is declared from the canonical key so the two "can never drift apart". Each gains the same edit-mode clause. ⚠️ The round-2 instruction said "no other source change this round". These two lines are JSDoc only, in files already on this claim's surface. They were published text that round 1 made incomplete in the same way as the canonical sentence. The deviation is named in the report. - `.changeset/10156-edit-form-writes-only-changed-fields.md`: declares `@object-ui/types` as a patch, and one bullet names the correction. - The zod mirror (`packages/types/src/zod/objectql.zod.ts`, held by objectui#9618) carries no `submitHandler` at all: 0 hits in the zod directory, against 2 files there that carry `onSuccess` as the control. There was nothing to report or to leave alone. - A search for source-text pins quoting the old sentence found none outside the edited JSDoc blocks. The control is the same search run with nothing excluded, which hits the source line. The two pending changesets that paraphrase "documented as handing the collected values" remain true: that first paragraph is unchanged. ## The comparison rule A field counts as clean only when every case below says "same". Anything this cannot settle is SENT. | pair | same? | |:--|:--| | identical values (`===`) | yes | | `null` / `undefined` | yes, the one blank a round-trip interchanges | | `null` / `''`, `undefined` / `''` | no | | `1` / `'1'` (number vs numeric string) | no | | lookup id / expanded lookup object | no | | two Dates with the same finite time | yes | | a Date / a date string; two date strings in different formats | no | | objects or arrays with identical JSON | yes | | reordered keys or elements | no | | `NaN` / `NaN` | no | Each row is pinned both ways round in `the one comparison — every pair the rule states`. ## Premise checks (the dispatch's mechanism assumptions, measured) - **A1 — holds, with one correction.** The record is read in `SimpleObjectForm`'s fetch effect, and in `ModalForm` / `DrawerForm`'s fetch effects, and was already kept in component state (`initialData` / `formData`). A ref beside it was enough. Correction to the dispatch: the child-row comparison lived in `masterDetailTx.ts`, not in `sanitize.ts`. It now lives in `sanitize.ts`. - **A2 — holds.** There is one rule, stated above. It is the rule objectui#10108's child rows already used, moved and not rewritten. - **A3 — holds.** `ifMatch` is still the `updated_at` the form read. **Overwrite** resends the dirty fields only, pinned in `"Overwrite" after a 409 re-sends the changed fields only, re-keyed to the server version`. - **A4 — the full payload, as today.** Before this branch, a save with no changes sent a `PATCH` carrying every sanitized field and reported the server's answer. It still does. Pinned for the plain edit and for the master-detail parent operation. - **A5 — holds.** A field the form moved itself differs from the read record, so it is sent. Pinned with a cascade clear: the user moves `region`, the form empties `tier`, and `tier: null` is on the payload. - **A6 — holds.** Create is untouched. The control row posts a seeded value the user never changed, and `update` and `findOne` are never called. ## Pins (`editDirtyPayload-10156.test.tsx`, real components, real `sanitizeFormData`) - plain edit, modal edit and drawer edit: changing one field sends exactly that field, with `ifMatch`; - master-detail parent operation: `ops[0]` is exactly `{ref: 'PO-2'}`; - type drift is sent. Real widgets produce it: retyping `5` into a number input whose stored value is the string `'5'`, and typing into then emptying a textarea whose stored value is `null`. A unit row covers the lookup id against the expanded object; - a cascade clear is sent; - **Overwrite** after a 409 sends dirty fields only; - a save with nothing changed sends the full sanitized payload (plain and master-detail); - a second save from a still-mounted form; - create is unchanged (control). ## Reverse verification (head `cb00385aa`) The fix was committed first. Each mutation went through `ablation-replace.mjs`: the anchor had to hit exactly once, the blob change was verified on disk, and the file was trap-restored from `HEAD` on an absolute path. After every leg, the `sanitize.ts` blob equals `HEAD` (`9c115af88c2a`) and `git diff HEAD` is empty. No rebuild was needed, because every consumer imports `./sanitize` relatively inside the package. | ablation | pin file result | what went red | what stayed green | |:--|:--|:--|:--| | A1 remove the dirty filter (`dirtyEditPayload` returns the payload) | 8 failed / 25 passed | every exact "only that field" row (plain, modal, drawer, master-detail parent, Overwrite, second save) | every type-drift row, the cascade row, both empty-diff rows, create | | A2 comparison forced to "all clean" | 17 failed / 16 passed | the 9 "different" rule rows and the same exact rows as A1 | **type-drift and cascade rows** | | A3 all clean AND empty-diff fallback removed | 24 failed / 9 passed | all type-drift rows, the cascade row, both empty-diff rows, everything above | the "same" rule rows, create | | A4 loosened equality (`String(a ?? '') === String(b ?? '')`) | 9 failed / 24 passed | all three component type-drift rows, the unit drift row, 5 rule rows | every "only that field" row, the cascade row | | A5 no baseline advance | 2 failed / 31 passed | the second-save row and its unit row | the rest | | A6 `sanitizeFormData` returns its input (over the three retriaged files) | 14 failed / 15 passed | every retriaged filter row (below) | the new `an edit writes the edited field alone — never the FLS-refused one` rows | ⚠️ **The dispatch expected A2 to turn the type-drift and cascade pins red. It did not.** Under an "all clean" comparison the diff comes out empty, and the empty-diff rule then resends the full payload, so nothing can be dropped. That is the fallback working as a second line of defence. A3 removes the fallback as well, and they go red. A4 shows that the comparison's strictness is load-bearing on its own terms: loosen it, and a drifted field is dropped the moment another field is dirty. ## Fixtures retriaged, and why A row that asserts a column ABSENT after a real edit now stays green with its filter deleted, because the dirty diff alone keeps an unchanged column off the wire. The new FLS edit rows show exactly this: they stayed green under A6. So each row whose job is the sanitizer, the roster or the field-level filter now submits with **nothing changed**. That is the one edit route whose payload is the filter's whole output, and A6 turns every one of them red. - `ObjectForm.test.tsx` `strips computed and server-managed fields from the edit-mode update payload`: now submits unchanged, and its control is the stored `name` / `budget`. - `systemManagedPayload.test.tsx` `sends no server-owned column, from a form that declares none of them`: same move. This row was still passing, but vacuously. - `fieldSecurityPayload.test.tsx`: the filter row and the CONTROL row submit unchanged in all three containers. A new row per container pins that an edit writes the edited field alone. The file header says why. - `cascadePruneWire-10291.test.tsx`, two EDIT rows: the negative-control select is now asserted ABSENT from the wire body, not present with its stored value. A wrongful null would still show, because it differs from the stored `'gold'`. - `drawerFirstLoadWindow-10190.test.tsx` CONTROL: now `toEqual({title: 'typed'})`. That is stronger than before: `note` is absent only because the form still holds the value the record landed with. ## Checks at the round-2 head (`13ac7751e`) | check | command | result | |:--|:--|:--| | dependency closure build | `turbo run build` over the plugin-form dependency closure, `--concurrency=2` (includes `@object-ui/types`) | 11/11 tasks, exit 0 | | `@object-ui/types` type-check | `pnpm --filter @object-ui/types run type-check` (`tsc --noEmit`, `tsconfig.examples.json`, `tsconfig.test.json`) | exit 0 | | `@object-ui/types` suites, including every suite that reads `objectql.ts` text | `pnpm exec vitest run packages/types/` | **233 files, 5184 passed**, exit 0 | | package tests | `pnpm exec vitest run packages/plugin-form/` | **119 files, 1235 passed, 1 skipped**, exit 0 | | plugin-form type-check | `pnpm --filter @object-ui/plugin-form run type-check` | exit 0 | | lint, touched files | `eslint` on `objectql.ts`, `ModalForm.tsx`, `DrawerForm.tsx` | 0 errors | | changeset presence / no-major / fixed / overwrite / pending literals | `check-changeset-*`, `check:pending-changeset-literals` | exit 0 all. Presence: 12 source files of 2 released packages, 1 changeset declared | | changeset claims | `check:changeset-claims` | exit 0 (report-only). Self-contradiction reading: both bodies clean. It now also flags the pending changesets that name `objectql.ts`. Those paragraphs concern other faces or other `ObjectFormSchema` keys, and none describes the `submitHandler` doc | | line citations, control bytes, doc expression carriage, handler-key reads | `check:*` | exit 0 all, 0 new citations | | governed surface | `check-governed-queue-guard.mjs --test` over the round-2 paths | NOT GOVERNED | Round 2 changed no executable line. So the round-1 reverse verification below, measured at `cb00385aa`, still describes this code. ## Checks on the round-1 head (`cb00385aa`) | check | command | result | |:--|:--|:--| | dependency closure build | `turbo run build --filter='@object-ui/plugin-form^...' --concurrency=2` | 11/11 tasks, exit 0 | | package build | `pnpm --filter @object-ui/plugin-form run build` | exit 0 | | package tests | `pnpm exec vitest run packages/plugin-form/` | **119 files, 1235 passed, 1 skipped**, exit 0 | | suites outside the package that name `sanitizeFormData`, `ObjectForm`, `ModalForm`, `DrawerForm`, `saveWithOcc`, `occSave` or `masterDetailTx` (99 files, enumerated with `git grep -l`) | `pnpm exec vitest run` over them in two batches | 40 files / 1169 passed and 59 files / 1359 passed, exit 0 both. Run at `e111bfd68`; everything after it touches only the changeset, README and docs page | | 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 all six touched test files | | lint | `eslint .` in `packages/plugin-form` (its own `lint` script), `--format json` | 153 files, **0 errors**, 1000 warnings. Per-file rule counts in the four edited source files match the base, except that 4 `no-explicit-any` moved with the comparison from `masterDetailTx.ts` to `sanitize.ts` | | changeset presence / no-major / fixed / overwrite | `check-changeset-*` | exit 0 all. Overwrite reports the intended body correction of objectui#10120's changeset | | changeset claims | `check:changeset-claims` | exit 0 (report-only). The three flagged paragraphs (objectui#10108, #6237, #8738) were read and are still true | | pending changeset literals, line citations, control bytes | `check:*` | exit 0, 0 new citations | | test path roots, vi-mock ×3, unreferenced sources, handler-key reads, metadata write doors, type-check coverage, lint coverage | `check:*` / `scripts/check-*` | exit 0 all | | doc links, doc fences, doc example ids, doc component types, doc expression carriage, shell escape residue | `scripts/check-*` | exit 0 all | | governed surface | `check-governed-queue-guard.mjs --test` over the changed paths | NOT GOVERNED | **NOT MEASURED:** `check:readme-exports`, which answered exit 1 with "population collapsed", and `check:doc-snippets`, which answered exit 2 PRECONDITION NOT MET. Both need packages outside this closure built, and neither answer is a verdict. Both read fenced code blocks, and this branch's README and docs-page additions contain 0 fences (`git diff` over the two files, counting added lines that open a fence). Remote CI on `13ac7751e`: 43 check-runs completed, 40 success and 3 skipped by design (`dependabot`, `Test (coverage)` and the coverage-shard matrix), 0 red. **Declared narrowing on lint.** `pnpm lint` is `turbo run lint`, i.e. each package's own `eslint .`. The run above is that command for the one package this branch changes, over its whole tree. `eslint.config.js` sets neither `parserOptions.project` nor `projectService`, so type-aware linting is not enabled, and a change inside this package cannot move the verdict on a file it does not touch. The `.changeset`, README and docs-page files are not eslint inputs. ## File surface Round 2 added `packages/types/src/objectql.ts` (the `submitHandler` JSDoc only) through the seat's amendment, plus the JSDoc copies on `ModalFormSchema` / `DrawerFormSchema` (see Round 2). In round 1, the claim named `sanitize.ts`, `ObjectForm.tsx`, `ModalForm.tsx`, `DrawerForm.tsx`, the tests beside them and one changeset. This branch also touches: - `masterDetailTx.ts`: the comparison's old home. It loses its private copy and imports the shared one; the function bodies are byte-identical; - `packages/plugin-form/README.md` and `content/docs/plugins/plugin-form.mdx`: the docs AGENTS.md #2 requires; - `.changeset/10120-form-omits-fls-denied-fields.md`: body only, frontmatter byte-identical. Its sentence "`ObjectForm` and `ModalForm` produce the same payloads they did before" would be false beside this change in the same release. It now says both already withheld the refused field and still do. ## Acceptance notes - Out-of-scope findings, **handed to the seat, which files them at ACCEPT** (measured with probes that were not committed): - The `tabbed`, `split` and `wizard` edit arms never call `sanitizeFormData`. Their edit `PATCH` carries the whole record: `id`, `owner_id`, `created_by`, `updated_at` and a `formula` column, on this branch and on `main` alike. This is objectui#10108's 403 and the "unknown field" rejection, on the arms that fix did not reach. The same arms also skip the field-level filter. A simple form with `mobile.stepper` routes through the wizard arm too. The docs and changeset name all of these as not covered. - A master-detail EDIT form that stays mounted after a save sends stale child rows on the next save. It re-creates every row the first save created, so the second batch carries the same `create` again. It also drops a child cell changed back to its first-read value, so the second batch carries no child operation and still reports success. The row state's `original` never advances, and created rows never learn their ids. This is the child-row twin of the baseline advance above. The fix shape (refetch the children, or map the batch results) is not pinned, so it is not fixed in place here. - Observations, not findings: - A second save from a still-mounted edit form still sends the `updated_at` first read as `ifMatch`, so against a real server it would 409 on the user's own first save. OCC was out of scope and unchanged; this was not measured against a server. - `MasterDetailForm`'s fallback echo `{...parentData, id}` is used only when the batch returns no `results`. It now carries the changed parent fields, not all of them. - Two cross-file line addresses into `objectql.ts` were already false before this PR, and are left as they are: the `groupBy` sentence in the pending changeset for the kanban group-by control, and the `KanbanConditionalFormattingRule` comment in the one-authority-per-exported-name script test. At `cb00385aa`, the changeset's address lands on a map-config comment and the script test's on a `View type` comment. Round 2's inserted lines sit before both, but neither was true to begin with. - The `sections` branch of `ObjectForm.tsx`, objectui#10475 and objectui#10476 were not touched. Session: `https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C` --- _Generated by [Claude Code](https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3261e64 commit 2dd4d3f

16 files changed

Lines changed: 890 additions & 92 deletions

‎.changeset/10120-form-omits-fls-denied-fields.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ The verdict that answers 「may this caller edit this field」 already existed a
1313

1414
**What changed, in observable terms.**
1515

16-
- The field-level verdict now arrives at the ONE outbound filter as a predicate (`sanitizeFormData`'s `canEdit`), instead of as a strip loop written out after each container's call. ⚠️ `DrawerForm` previously sent every displayed field regardless of the caller's field permissions; it no longer does. `ObjectForm` and `ModalForm` produce the same payloads they did before — their loops were correct, they were just copies.
16+
- The field-level verdict now arrives at the ONE outbound filter as a predicate (`sanitizeFormData`'s `canEdit`), instead of as a strip loop written out after each container's call. ⚠️ `DrawerForm` previously sent every displayed field regardless of the caller's field permissions; it no longer does. `ObjectForm` and `ModalForm` already withheld the refused field, and still do — their loops were correct, they were just copies.
1717
- The render pass is likewise one function for all three containers. ⚠️ `DrawerForm` previously drew a field the caller may read but not edit as a live input; it now draws it read-only and disabled, exactly as the other two already did. A field the caller may not READ is dropped, also as before.
1818
- Both halves stay fail-open with no `PermissionProvider` / `MePermissionsProvider` mounted, unchanged: a standalone form, a designer preview and a guest surface have no resolvable principal, and the server still enforces.
1919
- A lookup's selected chip no longer offers its remove ✕ when the field is disabled. ⚠️ This is how BOTH refusals reach the widget — a field the object declares `readonly` is folded into `disabled` by the form's section builder, and a field the permission set refuses is marked disabled by the pass above — so a reporter could previously clear a master-detail parent the server would then refuse to unset. The trigger and the browse button were already disabled; the chip's ✕ was the one control the gate had missed. The chips themselves stay: the value is readable, only the affordance goes.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@object-ui/plugin-form': patch
3+
'@object-ui/types': patch
4+
---
5+
6+
An edit form now writes only the fields that changed (objectui#10156).
7+
8+
**Clause-②: no.** No exported symbol, type or prop changes. `@object-ui/plugin-form` publishes `.` only, from `index.tsx`, and `index.tsx` re-exports neither `sanitize` nor `masterDetailTx`. After a build, `dist/index.d.ts` names none of the new helpers. The change is in what the client sends, and ⚠️ in what a host `submitHandler` receives in edit mode (see below).
9+
10+
**Before.** A master-detail child row already sent only the cells that differed from its loaded snapshot (objectui#10108). The other two edit payloads did not. The plain record edit `PATCH` and the parent operation of a master-detail batch sent every sanitized field on every save, including fields the user never touched. With the concurrency guard, a `409` followed by **Overwrite** therefore rewrote every field, not only the ones this user changed.
11+
12+
**What changed, in observable terms.**
13+
14+
- In edit mode, `ObjectForm`, `ModalForm` and `DrawerForm` compare their save with the record they read through `findOne`, and write only the fields that differ. The parent operation of a master-detail batch follows, because its header is a simple `ObjectForm`.
15+
- There is one comparison, shared with the master-detail child rows. It sends anything it cannot prove unchanged. `null` and `undefined` count as the same value. `null` and `''` are different. So are `5` and `'5'`, a lookup id and its expanded object, a `Date` and a date string, and two objects whose keys come in a different order. A field the form changed by itself after the read, such as a cascade clear, is sent.
16+
- A save with nothing changed still sends the full sanitized payload. It stays a real request, with the same concurrency guard and a real server record for `onSuccess`.
17+
- A form that did not read the record itself still sends every field. That covers a create, a record supplied as `initialData`, and inline `customFields`.
18+
- After a successful save, the form counts the fields it just wrote as saved. A form that stays open compares its next save with the record as it is now. Changing a field back to its first-read value is therefore still sent.
19+
- The concurrency guard is unchanged. The update still carries `ifMatch` = the `updated_at` the form read, and a `409` still offers **Keep editing** or **Overwrite**. **Overwrite** now resends only the changed fields.
20+
- ⚠️ A host `submitHandler` on an edit form receives the payload the form would have written. That is the changed fields, or the full sanitized payload when nothing changed. A host that needs the whole record must read it itself. In this repository, only `MasterDetailForm` passes a `submitHandler` to an edit form. Its header form receives the changed fields. Its row editor has no `recordId`, so it still receives every value.
21+
- The JSDoc of `ObjectFormSchema.submitHandler` in `@object-ui/types`, and its copies on `ModalFormSchema` and `DrawerFormSchema`, now say what an edit-mode handler receives.
22+
23+
**Not covered.** The `tabbed`, `wizard` and `split` variants have save paths of their own and still send every value they hold. That includes a master-detail header laid out `tabbed`, and a simple form whose mobile `stepper` option shows it one step at a time through the wizard.

‎content/docs/plugins/plugin-form.mdx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,20 @@ The components are on the package's export surface — `ObjectForm`,
413413
(`TabbedFormSchema`, `WizardFormSchema`, `ModalFormSchema`, …). There is no
414414
aggregate map among them.
415415

416+
## What an edit save writes
417+
418+
An `object-form` in `mode: 'edit'` reads its record with `findOne`, and its save
419+
writes only the fields that differ from that read. That covers the simple form,
420+
the `modal` and `drawer` variants, and the parent operation of a master-detail
421+
form. The `tabbed`, `wizard` and `split` variants still send every value the form
422+
holds, and so does a simple form whose mobile `stepper` option routes it through
423+
the wizard. A field whose sameness cannot be proven is sent: `5` and `'5'`, `null` and
424+
`''`, and a lookup id and its expanded object all count as different. A save with
425+
nothing changed still sends the full payload, as it always has. The `ifMatch`
426+
concurrency guard is unchanged, and its **Overwrite** choice now resends only the
427+
changed fields. A host `submitHandler` receives the same payload in edit mode.
428+
The package README has the full rule, under "What an edit save writes".
429+
416430
## Examples
417431

418432
### Form with Validation

‎packages/plugin-form/README.md‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,57 @@ returning, which reported a save that never happened and, through
869869
(objectui#6300). A declared `submitHandler` is consulted first, so a host that
870870
said it owns the write is never bypassed for want of an adapter it never needed.
871871

872+
## What an edit save writes
873+
874+
An `object-form` in `mode: 'edit'` with a `recordId` reads the record with
875+
`dataSource.findOne`, and its save writes **only the fields that differ from
876+
that read** (objectui#10156). The simple form, the `modal` and the `drawer`
877+
variants do this, and so does the parent operation of a master-detail form,
878+
whose header is a simple form. A master-detail child row already worked this
879+
way (objectui#10108), and all of them use the same comparison. The `tabbed`,
880+
`wizard` and `split` variants are not covered. That includes a master-detail
881+
header laid out `tabbed`, and a simple form whose mobile `stepper` option shows it
882+
one step at a time through the wizard. They still send every value the form
883+
holds.
884+
885+
The comparison sends every field it cannot prove unchanged, because a field
886+
wrongly judged unchanged would lose the user's edit while the server still
887+
answers 200:
888+
889+
- `null` and `undefined` count as the same value. `''` is not a blank here, so
890+
`null` and `''` are different.
891+
- A number and a numeric string are different (`5` and `'5'`).
892+
- A lookup id and the expanded lookup object are different.
893+
- A `Date` and a date string are different. So are two date strings written in
894+
different formats.
895+
- Objects and arrays are equal only when they serialize identically.
896+
Reordering their keys or elements makes them different.
897+
898+
A field the form changed by itself after the read is a change, so it is sent.
899+
That covers a cascade clear or a value cleared when its field was hidden.
900+
901+
Some saves still send the full payload:
902+
903+
- **A save with nothing changed.** It sends every field, as it always has.
904+
That keeps it a real request, with the same concurrency guard and a real
905+
server record for `onSuccess`.
906+
- **A form with no record read of its own.** This includes a create, a record
907+
given as `initialData` or through inline `customFields`, and a save made
908+
while a new record is still loading. Each of these sends every field.
909+
910+
After a successful save, the form treats the fields it just wrote as saved. A
911+
form that stays open therefore compares its next save with the record as it
912+
stands now, not as it was first read.
913+
914+
The concurrency guard is unchanged. The update still carries
915+
`ifMatch` = the `updated_at` the form read, and a `409` still offers
916+
**Keep editing** or **Overwrite**. **Overwrite** now resends only the changed
917+
fields, so it no longer rewrites fields this user never touched.
918+
919+
⚠️ A host `submitHandler` gets the same payload in edit mode. Normally that is
920+
the changed fields; after a save with nothing changed, it is the full payload.
921+
A host that needs the whole record must read it itself.
922+
872923
## Integration with Data Sources
873924

874925
**The adapter is not a schema key.** A schema is a serialisable document; a live

‎packages/plugin-form/src/DrawerForm.tsx‎

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,13 @@ import {
5050
CONTAINER_GRID_COLS,
5151
} from './autoLayout';
5252
import { deriveFieldGroupSections, projectSectionDivider, resolveSectionCollapse } from './fieldGroups';
53-
import { sanitizeFormData } from './sanitize';
53+
import {
54+
sanitizeFormData,
55+
dirtyEditPayload,
56+
snapshotLoadedRecord,
57+
advanceLoadedRecord,
58+
type LoadedRecordSnapshot,
59+
} from './sanitize';
5460
import { applyFieldPermissions, fieldWriteGate } from './fieldWriteGate';
5561
import { seedCreateValues, omitServerResolvedDefaults } from './schemaDefaults';
5662
import { resolveInitialRecord } from './initialRecord';
@@ -158,6 +164,10 @@ export interface DrawerFormSchema {
158164
* When supplied, the form validates and hands the collected values
159165
* to this handler INSTEAD of calling `dataSource.create` /
160166
* `dataSource.update`; the returned record is passed on to `onSuccess`.
167+
* In `edit` mode, for a record this form read itself, it hands over what it
168+
* would have written: the fields that differ from that read, or the full
169+
* sanitized payload when nothing changed (objectui#10156; the whole rule is
170+
* on `ObjectFormSchema['submitHandler']`).
161171
*
162172
* `MasterDetailForm` supplies it to route the parent AND its child
163173
* collections through one atomic `batchTransaction` (#2679 / ADR-0034
@@ -273,6 +283,12 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
273283
// `initialData`/`initialValues` are objects callers commonly rebuild every
274284
// render, and flashing the loading state for those would thrash.
275285
const loadedRecordIdRef = useRef<string | number | undefined>(undefined);
286+
// The record itself as read — the baseline an edit save diffs against, so
287+
// only the fields that changed are written (objectui#10156). Kept apart from
288+
// `formData`, which seeds the form and supplies the OCC token: advancing it
289+
// after a save would reseed the one and move the other. Set by the `findOne`
290+
// below and nowhere else, so a caller-supplied record is never a baseline.
291+
const loadedRecordRef = useRef<LoadedRecordSnapshot | null>(null);
276292

277293
// Fetch initial data
278294
useEffect(() => {
@@ -288,6 +304,8 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
288304
let cancelled = false;
289305
const fetchData = async () => {
290306
if (schema.mode === 'create' || !schema.recordId) {
307+
// Seeded from something other than a read: no baseline to diff against.
308+
loadedRecordRef.current = null;
291309
// Declared static defaults are this form's opening values (#4047) —
292310
// see `schemaDefaults` for the create-only boundary and for why
293311
// runtime defaults are left to the server.
@@ -297,6 +315,7 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
297315
}
298316

299317
if (!dataSource) {
318+
loadedRecordRef.current = null;
300319
setFormData(resolveInitialRecord(schema));
301320
setLoading(false);
302321
return;
@@ -316,6 +335,7 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
316335
const data = await dataSource.findOne(schema.objectName, schema.recordId);
317336
if (cancelled) return;
318337
loadedRecordIdRef.current = schema.recordId;
338+
loadedRecordRef.current = snapshotLoadedRecord(schema, data);
319339
setFormData(data || {});
320340
} catch (err) {
321341
if (cancelled) return;
@@ -471,11 +491,14 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
471491
// Omit the fields the producer owns (#4069) — see
472492
// `omitServerResolvedDefaults` for why an empty key is not the same as
473493
// no key at insert time. Create only: on an edit form a cleared column is
474-
// a real removal. Computed ONCE so every persistence route below — the
475-
// host-owned seam included — writes the identical payload.
494+
// a real removal. An EDIT writes only the fields that differ from the
495+
// record this form read (objectui#10156; `dirtyEditPayload` holds the
496+
// rule, and sends whatever it cannot settle). Computed ONCE so every
497+
// persistence route below — the host-owned seam included — writes the
498+
// identical payload.
476499
const writePayload = schema.mode === 'create'
477500
? omitServerResolvedDefaults(payload, objectSchema)
478-
: payload;
501+
: dirtyEditPayload(payload, loadedRecordRef.current, schema);
479502

480503
if (schema.submitHandler) {
481504
// The host owns persistence (e.g. MasterDetailForm batching the parent
@@ -498,12 +521,15 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
498521
dataSource,
499522
objectName: schema.objectName,
500523
recordId: schema.recordId,
501-
payload,
524+
payload: writePayload,
502525
baseRecord: formData,
503526
});
504527
if (outcome.status === 'cancelled') return;
505528
result = outcome.result;
506529
}
530+
// The write landed: a save from this still-open drawer diffs against the
531+
// record as it now stands, not as first read.
532+
loadedRecordRef.current = advanceLoadedRecord(loadedRecordRef.current, schema, writePayload);
507533
if (schema.onSuccess) {
508534
await schema.onSuccess(result);
509535
}

‎packages/plugin-form/src/ModalForm.tsx‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,13 @@ import {
5050
CONTAINER_GRID_COLS,
5151
} from './autoLayout';
5252
import { deriveFieldGroupSections, projectSectionDivider, resolveSectionCollapse } from './fieldGroups';
53-
import { sanitizeFormData } from './sanitize';
53+
import {
54+
sanitizeFormData,
55+
dirtyEditPayload,
56+
snapshotLoadedRecord,
57+
advanceLoadedRecord,
58+
type LoadedRecordSnapshot,
59+
} from './sanitize';
5460
import { applyFieldPermissions, fieldWriteGate } from './fieldWriteGate';
5561
import { seedCreateValues, omitServerResolvedDefaults } from './schemaDefaults';
5662
import { resolveInitialRecord } from './initialRecord';
@@ -176,6 +182,10 @@ export interface ModalFormSchema {
176182
* When supplied, the form validates and hands the collected values
177183
* to this handler INSTEAD of calling `dataSource.create` /
178184
* `dataSource.update`; the returned record is passed on to `onSuccess`.
185+
* In `edit` mode, for a record this form read itself, it hands over what it
186+
* would have written: the fields that differ from that read, or the full
187+
* sanitized payload when nothing changed (objectui#10156; the whole rule is
188+
* on `ObjectFormSchema['submitHandler']`).
179189
*
180190
* `MasterDetailForm` supplies it to route the parent AND its child
181191
* collections through one atomic `batchTransaction` (#2679 / ADR-0034
@@ -349,6 +359,12 @@ export const ModalForm: React.FC<ModalFormProps> = ({
349359
// `initialData`/`initialValues` are objects callers commonly rebuild every
350360
// render, and flashing the loading state for those would thrash.
351361
const loadedRecordIdRef = useRef<string | number | undefined>(undefined);
362+
// The record itself as read — the baseline an edit save diffs against, so
363+
// only the fields that changed are written (objectui#10156). Kept apart from
364+
// `formData`, which seeds the form and supplies the OCC token: advancing it
365+
// after a save would reseed the one and move the other. Set by the `findOne`
366+
// below and nowhere else, so a caller-supplied record is never a baseline.
367+
const loadedRecordRef = useRef<LoadedRecordSnapshot | null>(null);
352368

353369
// Fetch initial data
354370
useEffect(() => {
@@ -364,6 +380,8 @@ export const ModalForm: React.FC<ModalFormProps> = ({
364380
let cancelled = false;
365381
const fetchData = async () => {
366382
if (schema.mode === 'create' || !schema.recordId) {
383+
// Seeded from something other than a read: no baseline to diff against.
384+
loadedRecordRef.current = null;
367385
// No persisted record to show, so the object's declared static
368386
// `defaultValue`s are the form's opening values (#4047) — caller-
369387
// supplied initial values still win. See `schemaDefaults` for why
@@ -375,6 +393,7 @@ export const ModalForm: React.FC<ModalFormProps> = ({
375393
}
376394

377395
if (!dataSource) {
396+
loadedRecordRef.current = null;
378397
setFormData(resolveInitialRecord(schema));
379398
setLoading(false);
380399
return;
@@ -394,6 +413,7 @@ export const ModalForm: React.FC<ModalFormProps> = ({
394413
const data = await dataSource.findOne(schema.objectName, schema.recordId);
395414
if (cancelled) return;
396415
loadedRecordIdRef.current = schema.recordId;
416+
loadedRecordRef.current = snapshotLoadedRecord(schema, data);
397417
setFormData(data || {});
398418
} catch (err) {
399419
if (cancelled) return;
@@ -501,12 +521,14 @@ export const ModalForm: React.FC<ModalFormProps> = ({
501521
// Omit the fields the producer owns (#4069) — see
502522
// `omitServerResolvedDefaults` for why an empty key is not the same as
503523
// no key at insert time. Create only: on an edit form a cleared column is
504-
// a real removal. Computed ONCE (after the FLS strip above) so every
505-
// persistence route below — the host-owned seam included — writes the
506-
// identical payload.
524+
// a real removal. An EDIT writes only the fields that differ from the
525+
// record this form read (objectui#10156; `dirtyEditPayload` holds the
526+
// rule, and sends whatever it cannot settle). Computed ONCE (after the
527+
// FLS strip above) so every persistence route below — the host-owned
528+
// seam included — writes the identical payload.
507529
const writePayload = schema.mode === 'create'
508530
? omitServerResolvedDefaults(payload, objectSchema)
509-
: payload;
531+
: dirtyEditPayload(payload, loadedRecordRef.current, schema);
510532

511533
if (schema.submitHandler) {
512534
// The host owns persistence (e.g. MasterDetailForm batching the parent
@@ -529,12 +551,15 @@ export const ModalForm: React.FC<ModalFormProps> = ({
529551
dataSource,
530552
objectName: schema.objectName,
531553
recordId: schema.recordId,
532-
payload,
554+
payload: writePayload,
533555
baseRecord: formData,
534556
});
535557
if (outcome.status === 'cancelled') return;
536558
result = outcome.result;
537559
}
560+
// The write landed: a save from this still-open modal diffs against the
561+
// record as it now stands, not as first read.
562+
loadedRecordRef.current = advanceLoadedRecord(loadedRecordRef.current, schema, writePayload);
538563
if (schema.onSuccess) {
539564
await schema.onSuccess(result);
540565
}

0 commit comments

Comments
 (0)