Skip to content

EPBDS-17018: a useForm table row starts with its values, and a submit sends every row with its edits - #251

Merged
AlexSamBY merged 9 commits into
masterfrom
claude/use-form-table-rows
Oct 27, 2026
Merged

AlexSamBY merged 9 commits into
masterfrom
claude/use-form-table-rows

Conversation

@AlexSamBY

@AlexSamBY AlexSamBY commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

A table row that a useForm declaration gives a form of its own now starts with its values, and a submit sends every row with its edits. The merge a showIf reads is made once per change of the forms instead of once per node, which takes the square out of a mount of such rows.

The defect

A row's form started from the row's values at the root of its own values, while its fields are named by the row's place (dataKind.items[1].kind, mapper.tsx) and read them there. Measured on master (8e76778), React 18.3:

  • every field of such a row started empty;
  • a submit after an edit of the second of three rows sent the first two, the second without its edit: the live merge (getLiveMergedDataKindArray) capped the rows at the row form's array, which reaches only as far as its own row, and the document's copy, merged after the row form's, took the edit back;
  • a notWithinRange check in such a row read neither end of a period the user had not typed there, so typing a start date that overlaps another row showed nothing;
  • new rows a host handed over showed empty too.

The published 0.34.3 bundle does the first two as well, measured under React 18: this is no regression of 0.35.0. EPBDS-17018 is the defect's ticket, and this covers its acceptance criteria. #241 measured the first two, and EPBDS-13465 mentioned them as backlog. No known host config uses useForm rows.

The fix

  • engine/Data.tsx: a useForm row's form starts from the row's values placed at its place, with final-form's setIn, so the containers are the ones final-form writes the row's fields into (dataKind.phases.0.dataKind.lines[1] makes phases an array). A table's add row (draftRow) starts from what its meta declares, as before, and so does a row that holds no object (see the review below).
  • engine/formData.ts: rowPlaceOf(meta) names a row form's place, for both merges. Both merges take from a row form the values of the fields it changed, and nothing else: each field whose value is not the one the form started with (changedInside) is read in the form's values by its name and written at that name over the documents' copy of the row, as final-form writes a field's value (writeField, with final-form's setIn). The form's start is its initialValues, moved field by field to what the forms holding the document from its root hold of its values after a submit (restartWrittenRows, kept on the engine's side, for the initialValues the form had): the document holds them from then on, so an edit set back after a submit is a change. The rest of the row stays as the documents have it, a field the row's cells only show included, which another form may have edited. A value the form no longer holds takes that value away, and each object it leaves empty but the row, as final-form does. A field that holds an array, as a table's does (FieldArray), is written whole. The live merge counts a row form in no cap, so the rows after an edited row stay, and leaves out a row past the documents' rows (one just removed). It takes the fields of a row with a form of its own inside one of its rows too, such as a useForm line of a table in a row's expanded content: a submit writes only the top arrays back, as the live merge gives them. Both write the rows shallowest first, so a row with a form of its own inside another such row writes after it. A row form's values hold a sparse array up to its index, which nothing copies.
  • engine/rules.tsx: submit calls restartWrittenRows as it writes, before final-form validates: a submit that validation stops has written all the same. A start moves only to what the forms holding the document from its root hold, so a submit that a row's own document makes, from a button in the row's cells or a field there with autoSubmit, which writes into the row's form, moves none.
  • modules/form/batchedForm.ts: a form names every field it has registered (fieldNamesOf), the ones that have gone included: final-form keeps the value of a field a showIf hides, and the document's form keeps its copy, so the merges keep the edit, as they did when they took a row form's whole values. react-final-form's probe in a field's first render gives no name: the field registers again from its effect.

Found in review

The first version took a row form's whole row in both merges. Its form starts from the whole row, so the row as it started came back over an edit another form had made of it. Each scenario below was confirmed by a test, and none was in 0.35.0:

  • A field in the row's expanded content, which the document's form holds: getFormData, a submit and a showIf read the row's value as it started, and so did upload and Apply, which read what getFormData reads.
  • A table in the row's expanded content: an edit of its rows was lost in the same places, and a showIf in one of them read the row as it started.
  • A period table there: the cross-row check compared the edited row with the copy in the row's form and found no overlap.
  • An empty (null) row: the Data view is handed the document's data for it (mapper.tsx), which the form put at the row. getFormData and a submit had a copy of the whole document as the row, and a field of the row showed a value the document holds under its name.

The merges then took what a row form changed of the row it started from (changesOf, replaced since: see the third review), and a row that holds no object is not placed: its form starts as it did in 0.35.0.

A second review, at 03f9bfd, found three more, each confirmed by a test. Two are fixed here, and the third is in Not in this change:

  • A value cleared in a row's cell: final-form drops a value set to undefined, as a field with normalize: 'date' sets it for an empty date, and changesOf saw no change in a value no longer held. The document's copy showed the date again in getFormData, a submit and a showIf. Such a value is now the value of a field the form no longer holds, which the merges take away.
  • A Data view rendered without a document instance, as a cell of a table's extraItems, a tooltip's children, an input's icon and a values renderer render it, threw as it read the instance's data. 0.35.0 rendered it. It reads that only for a useForm row now.

A third review, at 274593f, found three more in changesOf, each confirmed by a test for useForm rows, which rows sharing the document's form passed. So the merges no longer compare a row form with the row it started from: they take the values of its fields (see The fix).

  • A field named length: changesOf marked an array made shorter by a step named length, so an edit of such a field never reached getFormData, a showIf or a submit. 0.35.0 had it in the first two.
  • A nested field in a cell, address.city, beside address.zip in the expanded content, where the row had no address: the row form's address, which the row did not start with, was written whole, and the zip was gone from getFormData, where 0.35.0 had both, and from a submit.
  • A nested date cleared in a cell, period.start, beside period.end in the expanded content: final-form drops the emptied period from the row's form, changesOf gave all of period as removed, and the end went with it.

A fourth review, at 11424bc, found two more, each confirmed by a test:

  • A field the row's cells show, which another form of the document holds too, such as the row's expanded content's: the row form wrote the value it started with, never edited, over that form's edit, in getFormData, a showIf and a submit, and the submit wrote it back into the expanded content's input. The same for a read-only field in the cells, and for one a showIf there had shown and hidden. Rows sharing the document's form had it right, and so did 0.35.0. A row form now gives only the fields whose value is not the one it started with (changedInside): the third review's cases stay fixed, and so does the edit of a field a showIf hid.
  • A submit writes only the top arrays back, as the live merge gives them, and that merge left out a row with a form of its own inside another table's row: the edit of a useForm line in a phase's expanded content reached getFormData, and not a submit, whether the phases had forms of their own or not. Measured the same on master. The follow-up's session found it.

A fifth review, at 5ffe189, found one more, confirmed by a test: an edit of a useForm row set back after a submit. The submit writes the merged rows into the document's form, in place, before final-form validates, so the document holds the row's edit from then on, and the row's form, measuring from its initialValues, took the value set back for no change: the document's copy brought the edit back in getFormData, a showIf and the next submit. The same for a useForm line in a phase's expanded content, and after a submit that validation stopped (measured: nothing was sent, and the edit came back). Rows sharing the document's form had it right; 0.35.0 sent no edit of such a row at all. The submit now moves the form's start to what it wrote (see The fix), which leaves the form's own state, pristine and touched, alone.

A sixth review, at a4de519, found one more, confirmed by a test: a submit made in a useForm row's cells, by a Save button there or by a field there with autoSubmit {delay: 0}. It submits the row's own document, whose submit writes the merged table into the row's form, not the document's, and the rows' starts moved to what that form held: every edited row's edit became its start and gave nothing, while the document's form held none of them. getFormData and the document's submit dropped the edits of every row, though the cells showed them. Rows sharing the document's form had it right. A start now moves only to what the forms holding the document from its root hold, whichever form submitted; what a row's own submit writes into its form is left as it was.

The cost

Every node whose showIf reads the form decides by every form of its document merged (getRawFormsData), and each node merged them as it decided. The first version of the fix also wrote every row form's whole row over the document's copy in every merge, and a mount of 200 useForm rows took about a third longer in jsdom (650 to 870 ms). The profile put almost all of the mount in getRawFormsData, which a mount ran 4n times.

So the merge is now kept per document tree while what the page's forms hold stays the same (engine/keptRawFormsData.ts):

  • formsStorage counts each form that registers, goes or registers again (formsChangeCount). A form that new initial values reset registers again with the meta of the render that brought them, which no values tell.
  • createBatchedForm wraps subscribe: the first values subscriber told of new values counts them before it hears of them. The tree's tells (A showIf that reads a field of a useForm block follows it #249) are too late for this: the form layer subscribes for them as the form commits, after the nodes under it, and a node in a List that follows its own form decided by the previous merge (showif-follows-form.test.js caught it). The count starts at the form's values when it is made, so react-final-form's subscription in its first render counts nothing.
  • The submit action writes the live rows into the form's values in place, which final-form tells nobody of: it counts that change itself.
  • A name that comes to a row's form, as when a showIf in its cells shows a field, can change what a merge writes but no form's values. createBatchedForm counts names on their own (fieldNamesCount). Where names came, the kept merge is checked against their values in the row forms (holdsFields): a name whose value its form did not change writes nothing, and one it changed must hold what the merge holds, or the forms are merged again. Counted as a change of what the forms hold, it merged once more at every mount, which the budgets caught: the mount's second merge comes as its nodes commit, before their fields register. The live merge compares each row form's names with those it merged by.

Counted with jest.mock around getRawFormsData, master against this branch, React 18.3:

master branch
mount, 20 / 40 / 80 useForm rows: merges 80 / 160 / 320 2 / 2 / 2
the same: forms merged 1260 / 4920 / 19440 21 / 41 / 81
mount, 20 / 40 / 80 rows sharing the form: merges 60 / 120 / 240 2 / 2 / 2
keystroke in those rows: merges, each a copy of the table 20 / 40 / 80 1 / 1 / 1

A mount of 200 useForm rows took about 660 ms on master and about 150 ms on this branch, in jsdom (median of 5 after 2 warm-ups, three interleaved rounds, Node 24.14); with the review's fix, about 140 ms, measured later in the same sitting. Measured again on another day, with three interleaved rounds each: master 676 to 801 ms, 274593f 160 to 180 ms, 11424bc 162 to 179 ms; later that day, 11424bc 145 to 151 ms and 5ffe189 145 to 148 ms. The table's counts were measured again at 11424bc, at 5ffe189, at a4de519 and at 248f0ad, and are the same.

What a showIf decides is unchanged, and so is how often: a useForm row's cell still decides once more at mount than it renders (80 on React 18.3, 60 on the others), now a lookup.

Tests

  • rules.row-form-values.test.js: the fields start with the row's values, a submit after an edit sends every row with the edit, a host's new rows show and a showIf reads them, and a period overlap with another row shows. All four fail on master.
  • rules.row-form-values.test.js, the review's scenarios: an edit in the row's expanded content, in a table there and in a period table there, and an empty row. All eight fail on c95fbd5. On master all pass but one, which edits the row's own field as well.
  • rules.row-form-values.test.js, the second review's: a date cleared in a row's cell, and a useForm block in an extraItems cell, which fail on 03f9bfd; a text emptied and a toggle turned off, and an edit in a null and in a {} row, which pass there.
  • rules.row-form-values.test.js, the third review's, each for useForm rows and for rows sharing the document's form: a field named length, a nested field beside one the expanded content adds, and a nested date cleared beside a field the expanded content holds, which fail on 274593f with useForm rows; and the edit of a field a showIf in the cells then hides, which fails when the merges read only the fields registered at the time, as a mutation showed.
  • rules.row-form-values.test.js, the fourth review's: an edit the expanded content makes of a field the cells show, in an input and in a read-only one, and of one the cells showed and hid, for both kinds of rows, which fail on 11424bc with useForm rows; the edits of a phase and of a useForm line in its expanded content, for phases with forms of their own and without, which fail on 11424bc and on master.
  • rules.row-form-values.test.js, the fifth review's, for both kinds of rows: an edit set back after a submit, which getFormData, a showIf and the next submit read, and after a submit that validation stopped; a useForm line's set back after a submit, in a phase's expanded content. They fail on 5ffe189 with useForm rows and lines.
  • rules.row-form-values.test.js, the sixth review's, for both kinds of rows: a submit made by a Save button in a row's cells, after edits of two rows, keeps them in getFormData and the document's submit; an edit set back after it reads as set back; a field with autoSubmit in the cells keeps the rows' edits in getFormData. The first and the last fail on a4de519 with useForm rows.
  • formData.test.js, restartWrittenRows: no start moves where the forms that hold the document from its root do not hold the row's values, or where there is no such form; what the document's form holds of a row's changes becomes its start, so a change set back is one more, and the check of a kept merge reads it; a field the document's form does not hold keeps its start; a value taken away there becomes a start with none; new initial values start the form anew; the live merge merges again once a start moved.
  • formData.test.js: what the merges take from a row form, the values of the fields it changed: over a document another form edited, a field it shows and leaves alone left out, a value no longer held taken away with an object it leaves empty but the row, a field named length, a field written into an object another form gave a field of its own, a hidden field's value kept, a field the document's copy has no room for left out, a field that holds an array taken as the rows, a row form inside another written after it; the live merge's row branches, a row with a form of its own inside one of its rows among them, and its cache, which merges again once a row form has a field it had not, or starts from other values; rowPlaceOf; holdsFields, which holds for a name the form did not change.
  • keptRawFormsData.test.js: one merge while the forms hold the same; a new one after new values, after a registration with no values told, per tree. A field that comes to a row's form with no new values keeps the merge where the merge holds its value, and where the form did not change it; merges again where the form changed it to another value than the merge holds, at once inside a bracket too; one that comes to a document's form keeps it.
  • batchedForm.test.js: new values are counted once, before the first subscriber hears of them; nothing is counted for other subscribers or unchanged values. A form names every field registered from an effect, in order, the ones that went included, and none for react-final-form's probe; the same list until a name comes; a name counted as no change of what the forms hold; a copy's names are the form's.
  • formRegistry.test.js: the storage counts set, delete and clear.
  • kept-show-if-budget.test.js pins the merges (mount 2, keystroke 1, on React 16.14, 17.0.2, 18.3 and 19.3); later-mount-budget.test.js lowers two counts that fell (1330 to 1126, 3230 to 2826, 822 to 820; 21322 to 21320 on React 16). No budget number rises.

Not in this change

  • Removing a row with removeData still loses what was typed in the rows after it: the removal reads only the document's form (removeDataKindRow), and the rows below move up and start again from the document's copy. Measured the same on master.
  • Edits in the cells of a useForm row are reset when an Add or a Remove rebuilds the document's data so that the row's data changes: a line added to or removed from a table in the row's expanded content, or an Add after an edit in the row's expanded content. pushDataKindRow and removeDataKindRow write a clone of the document form's values, which hold none of a row form's edits, and WithForm resets the row's form to its new initial values, as it does for a host's new data. 0.35.0 resets these forms the same way, their fields empty from the start. Rows sharing the document's form keep their edits. Found in the second review.

A separate PR will close both: it writes the row forms' changes into the document's form before a row is added or removed, and moves their start as a submit does (restartWrittenRows). The changelog says what an Add or a Remove still loses.

  • A table in a row's cells binds its rows' fields outside the row: they are named dataKind.lines[0].sku, not by the row's place. Measured on master: in rows sharing the document's form its inputs are empty, and its removeData throws; in a useForm row its inputs showed the row's lines, which the defect put at the root of the row's form, but an edit or a removal there never reached getFormData or a submit. With this change its inputs are empty in a useForm row too, and a removal there clears the whole table on screen, where master removed one line; the data changes in neither. The array such a table registers (FieldArray) is named by the row's place, and the merges take it whole, as any field that holds an array.

Gates

On Node 24.14.0, at 248f0ad, each exits 0.

  • lint:js, lint:css, typecheck, typecheck:contract, docs:views:check, docs:props:check, docs:phrases:check, css:fixture:check, build-css:check, sync-version -- --check;
  • npm test and test:react16, test:react17, test:react19 (254 suites, 3429 tests each; one suite skipped on 16 and 17, as designed), and test:coverage with every threshold held (formData.ts 100% of statements and functions and 99.45% of branches, keptRawFormsData.ts 100%);
  • build-lib, test:css:built, test:types:consumer, test:pack:budget, test:pack:consumer, test:pack:peers, test:env-flags, build;
  • CI=1 test:e2e and CI=1 test:e2e:react19 (77 each), test:pack:browser and test:pack:browser:peers.

The first run of test:react16 lost two jest workers to a SIGSEGV (nodejs/node#62393) in validateMeta.contract.test.js and InputNumber.test.js, with no test failing; run again, it exited 0. No budget number rises: kept-show-if-budget.test.js and later-mount-budget.test.js pass unchanged. The jest legs ran with <rootDir>/\.claude/ ignored as well, for a stray, gitignored probe in this checkout, and without the review's scratch tests, which are not committed. Those ran on their own on React 16.14, 17.0.2, 18.3 and 19.3: 119 of 122 (251g 14 of 14) pass on each, and the three that fail are the Add and Remove cases of Not in this change. Every hit of both caches was compared with a fresh merge through a temporary check, not committed, across the whole suite, the review's scratch tests included, on React 16.14, 17.0.2, 18.3 and 19.3: none differed. Not run here: test:e2e:engines, test:perf and audit:bundled, which CI runs as advisory or weekly jobs; this change adds no dependency.

🤖 Generated with Claude Code

…y row with its edits

A table row that `useForm` gives a form of its own started its form from the row's values at the root of its
values, while its fields read them at the row's place: every field started empty, and a submit after an edit
sent the rows up to the edited one, without the edit. The form now starts from the row's values at its place
(`engine/Data.tsx`), and the live merge of a data kind takes each row form's row over the document's copy, with
no cap (`getLiveMergedDataKindArray`).

Every row form now writes its row over the document's copy in every merge, which made the existing cost of a
`showIf` that reads the form, one merge per decision, a third higher. The merge is now kept per document tree
while what the page's forms hold stays the same (`engine/keptRawFormsData.ts`): `formsStorage` counts each
registration, and a batched form counts new values before the first of its subscribers hears of them.
@AlexSamBY AlexSamBY changed the title A useForm table row starts with its values, and a submit sends every row with its edits EPBDS-17018: a useForm table row starts with its values, and a submit sends every row with its edits Oct 26, 2026
@AlexSamBY AlexSamBY closed this Oct 26, 2026
@AlexSamBY
AlexSamBY deleted the claude/use-form-table-rows branch October 26, 2026 14:41
@AlexSamBY
AlexSamBY restored the claude/use-form-table-rows branch October 26, 2026 14:41
@AlexSamBY AlexSamBY reopened this Oct 26, 2026
…d an empty row stays empty

Found in review of the first version. A `useForm` row's form starts from the whole row, and both merges took all
of it, so the row as it started came back over an edit another form had made of it: a field in the row's
expanded content, a table there and a period table there. `getFormData`, a submit, upload, Apply, a `showIf`
and the cross-row check read it. For a `null` row the `Data` view is handed the document's data, which the form
put at the row: `getFormData` and a submit had a copy of the document as the row.

Both merges now take from a row form what it changed of the row it started from (`changesOf`), and a row that
holds no object is not placed, so its form starts as it did in 0.35.0.
…hat the document reads, and a block in a computed row renders

Found in the second review, at 03f9bfd. final-form drops a value set to `undefined`, as a field with
`normalize: 'date'` sets it for an empty date, and `changesOf` saw no change in a value no longer held: the
document's copy showed the date again in `getFormData`, a submit and a `showIf`. `changesOf` now gives such a
value as removed, and an array made shorter by its length, and both merges remove and cut them.

A `Data` view rendered without a document instance, as a cell of a table's `extraItems` renders it, threw as it
read the instance's data. It reads that only for a `useForm` row now.
…row's fields, each at its name

Found in the third review, at 274593f. Both merges took from a row's form what it changed of the row it started
from (`changesOf`), which was wrong three ways for `getFormData`, a `showIf` and a submit:
- a field named `length` was taken for an array's length, and its edit never arrived;
- an object the row did not start with was written whole, over a field the expanded content had added to it;
- a cleared nested date took away the whole object it left empty in the row's form, with the field the expanded
  content held in it.

Both merges now read each field of a row's form in its values by the field's name and write it at that name over
the document's copy, as final-form writes a field (`writeField`). A value no longer held takes away that value,
and an object it leaves empty but the row. The fields are every one the form has registered, the ones a `showIf`
hid included, as final-form keeps their values (`fieldNamesOf` in `createBatchedForm`). A field that holds an
array, as a table's does, is written whole. `changesOf`, `applyChanges` and `keepsRows` are gone.

A name that comes to a form is counted on its own (`fieldNamesCount`), not as a change of what the forms hold: the
merge a `showIf` reads is kept where the new names' values are what it holds (`holdsFields`), and made again where
they are not. Counted as a change, it merged once more at every mount, which the budgets caught. The live merge's
cache compares each row form's names as well.
…ows is kept, and a submit sends a nested row's edit

Found in the fourth review, at 11424bc.

A `useForm` row's form gave every merge the value of every field it had registered, changed or not. So a field its
cells only show wrote the value the row started with over an edit of the same name that another form had made, such
as the row's expanded content: `getFormData`, a `showIf` and a submit read the old value, and the submit wrote it
back into the expanded content's input. The same for a read-only field there, and for one a `showIf` had shown and
hidden. 0.35.0 had it right. A row's form now gives only the fields whose value is not the one it started with
(`changedInside`), and the kept merges compare the same (`holdsFields`, and what the live merge's cache keys on).

A submit writes only the top arrays back, as the live merge gives them, and that merge left out a row with a form of
its own inside another table's row: the edit of a `useForm` line in a phase's expanded content reached `getFormData`
and not a submit. 0.35.0 did the same. The live merge now takes the fields of such a row too.
…at the document reads

Found in the fifth review, at 5ffe189. A submit writes the merged rows into the document's form, in place, before
final-form validates (`submit` in `rules.tsx`), so the document holds a row's edits from then on, whether the submit
goes through or not. A row's form gave the merges the fields it changed of its `initialValues`, so an edit set back
after a submit was no change, and gave nothing: the document's copy, holding the edit, came back in `getFormData`, a
`showIf` and the next submit, for a `useForm` line of a table in a row's expanded content too.

The submit now moves each row form's start, field by field, to the values of it that it wrote into the document's
form (`restartWrittenRows`, `engine/formData.ts`). The start is kept on the engine's side, for the `initialValues` the
form had, so that new ones start it anew, and the form's own state, pristine and touched, is left alone. The merges,
the live merge's cache and the check of a kept merge measure from it (`startOf`).
… edits in what the document reads

Found in the sixth review, at a4de519. A Save button in a `useForm` row's cells, or a field there with
`autoSubmit`, submits the row's own document, whose submit writes the merged table into the row's form, not the
document's (`submit` in `rules.tsx`). The rows' starts then moved to what that form held, so every edited row's edit
became its start and gave nothing, while the document's form held none of them: `getFormData` and the document's
submit dropped the edits of every row, though the cells showed them.

A row form's start now moves only to what the forms holding the document from its root hold (`restartWrittenRows`,
`documentValuesOf`), whichever form submitted: a submit that a row's own document makes moves none. What it writes
into the row's form is left as it was.
@AlexSamBY
AlexSamBY merged commit 5456ef0 into master Oct 27, 2026
8 checks passed
@AlexSamBY
AlexSamBY deleted the claude/use-form-table-rows branch October 27, 2026 14:04
AlexSamBY added a commit that referenced this pull request Oct 27, 2026
#254)

An edit made in a `useForm` block whose form starts from the whole
document is now in what the document reads: `getFormData`, a `showIf`,
and the rows a submit sends. The seventh review round of #251
(EPBDS-17018) found it. It has no ticket of its own; EPBDS-17018 has a
comment that points here.

## The defect

A `Data` view with `useForm` whose `name` or `data` selects no values
starts its form from the document's values (`docs/SUPPORTED-VIEWS.md`).
That form and the document's own form then both hold the whole document.
Measured on master (5456ef0), React 18.3, and the first two on 0.35.0
(8e76778) as well:

- An edit in such a block was missing from `getFormData`, and a `showIf`
that read it did not follow it. This holds for a field in the block, or
a cell of a table in it, whose value the document already had.
- A submit of the document sent the rows of a table in the block as they
started.
- A Select in such a block whose value is an option's index lost its
pick.
- Such a block, shown by a `showIf` after an edit of the document's own
form, took that edit back.

A field whose value the document did not have reached `getFormData`.

## The cause

`mergeData` merges the forms that hold the document over a master: the
first that holds other than one key at its root. Children register
first, so a block's form that holds the whole document was the master.
The document's form, which holds every value as it started, came over
it. Making the document's form the master only turns the loss round, as
a probe showed: the document's own edit, of a field beside the block,
then lost to the block's copy. Each form holds a copy, as it started, of
what the other edits.

## The fix

- **Which forms count.** A form that holds the whole document is now
marked when it registers (`wholeDocument` in `state/formRegistry.ts`).
  - The form layer marks a tree's root (`modules/form/utils.tsx`).
- `engine/Data.tsx` marks a `useForm` block whose `name` or `data`
selects no values, in a document that holds the whole document: a tree's
root, or the document of such a `Data` view inside one. It hands the
mark to the nested document, with or without `useForm`, so a block
inside a `Data` view that selects a section or a row is not marked.
- **`engine/formData.ts`, `mergeDocuments`.** Where more than one form
holds the whole document, each writes over `mergeData`'s merge the
fields it changed. This is what a `useForm` table row's form does since
#251. A changed field is one the form registered whose value is not the
one it started with, and its value is read in the form's copy as
`fromRoot` gave it.
- The form also writes what `fromRoot` changed at the root of its
values: a Select's options it put in the order the value picks, and the
value it gave up for that order.
- The forms write in the order `mergeData` merges them, so two forms
that changed one field come out as they did.
- Every other form merged from the root is merged as before: a block
that selects values of its own, a `useForm` view in a row's expanded
content, and a block in a `Data` view that selects a section or a row.
So is a document with one form that holds the whole of it.
- **`restartWrittenRows`.** Such a form's start moves, as a row form's
does, to what the other forms hold after a submit wrote into them. A
block's own submit writes into the block's form, which moves none of its
starts.
- **`getLiveMergedDataKindArray`.** Such forms write their changed
fields at the array before any row's own form. The form a validator runs
in writes every field it has there, read in the values final-form handed
it, without computing its state.
- **`engine/keptRawFormsData.ts`.** Names that come to such forms are
checked as a row form's are.

## Found in review

- **ac79d028 applied the rule to every `useForm` form merged from the
root.** That included a block that selects values of its own and a
`useForm` view in a row's expanded content. A field of theirs with a
name the document also has then replaced the document's value. 6ef47c6
fixed this with the `wholeDocument` mark.
- **6ef47c63 still marked one more kind of block.** A block that selects
no values inside a `Data` view without `useForm` that selects a section
or a row was marked, because `instance.data` there is that section or
row. The same names were replaced. 2960e67 hands the mark down only
from a document that holds the whole document.
- **The Select needed the reordered options too.** With three or more
keys at the document's root, the document's copy brought back the
options' order, so the pick was lost. 6ef47c6 writes what `fromRoot`
changed. 2960e67 compares only what it can change: a key taken away,
and an array.

The cost of `getFormData` after an edit, as review measured it (medians
of 15 calls in jsdom, commits interleaved), on a 300-row document with a
block:

| 5456ef0 | 6ef47c6 | 2960e67 |
|---|---|---|
| 2.8 ms | 5.1–5.7 ms | 3.6–3.7 ms |

- The rest of the cost is the per-field comparison each form that holds
the whole document makes.
- A 300-row array at the document's root is still compared, at 6.3–6.7
ms against 4.2–4.4 ms on master.
- A document without such a block costs the same as on master, and
keystrokes are unaffected.

## Not in this change

Three older cases stay as they were, on master too:

- An Add or a Remove in a table inside such a block does not reach a
submit, and a Remove does not reach `getFormData` either.
- Such a block, shown after a submit, takes back the edit of a `useForm`
table row that the submit sent.
- An index Select in one such form, whose options a table in another
edits: the table's edit is lost. With the Select in the block and three
or more keys at the document's root, the pick is lost instead.

A submit of the document sends its own form's values and the tables'
rows, and no other field of a `useForm` block, as `engine/Data.tsx`
designs it: "submission of the entire UI does not submit temporary
instance". Measured the same on master. `getFormData` returns those
fields, as before.

## Tests

All counts are on master's code (5456ef0).

- **`rules.block-form-values.test.js`**, 16 cases. 9 fail on master:
- a block's field and a cell of its table, in getFormData, a `showIf`
and a submit;
- an edit set back after the document's submit, and after the block's
own;
  - a field of the document's form beside such a block;
  - a block whose `name` selects nothing;
  - a block shown after an edit of the document's form;
  - the index Select, with two and with three root keys.

  The other 7 pass on master and guard what must not change:
  - `useForm` rows in such a block;
  - a block that selects values of its own;
  - a `useForm` view in a row's expanded content;
- a block in a `Data` view that selects a section or a row, by `name`,
by `data`, or as a row's expanded content.
- **`formData.test.js`**: 16 cases fail on master. They cover:
- the merge, in both reads, a field both forms changed, and a value
cleared;
  - the Select;
- the live merge: a cell, an added row, a nested document's form, and
the form a validator runs in;
  - the starts;
  - `holdsFields` with `''`.
- **`keptRawFormsData.test.js`**: a name that comes to such a form is
merged again only where its form changed the value. It fails on master.
- **Review scratch tests.** The scratch tests of #251's review rounds
and of this PR's pass, except the three R2 cases #251 documents and the
three cases above.

## Gates

On Node 24.14.0, on 2960e67, each exits 0:

- `lint:js`, `lint:css`, `typecheck`, `typecheck:contract`,
`docs:views:check`, `docs:props:check`, `docs:phrases:check`,
`css:fixture:check`, `build-css:check`, `sync-version -- --check`;
- `npm test`, `test:react16`, `test:react17` and `test:react19`: 255
suites and 3467 tests each, one suite skipped on 16 and 17;
- `test:coverage`: `formData.ts` 100% of statements and 99.53% of
branches, `keptRawFormsData.ts` 100%;
- `build-lib`, `test:css:built`, `test:types:consumer`,
`test:pack:budget`, `test:pack:consumer`, `test:pack:peers`,
`test:env-flags`, `build`;
- `CI=1 test:e2e` and `CI=1 test:e2e:react19`, 77 each;
`test:pack:browser` and `test:pack:browser:peers`.

Notes on these runs:

- The jest legs ran with `<rootDir>/\.claude/` ignored as well, for a
stray, gitignored probe in this checkout.
- Not run here: `test:e2e:engines`, `test:perf` and `audit:bundled`,
which CI runs as advisory or weekly jobs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
AlexSamBY added a commit that referenced this pull request Oct 27, 2026
…ment

Conflicts, each resolved by keeping both sides:
- `engine/formData.ts`: the imports, `FormState` from #254 and `isEqualJSON` from this branch.
- `formData.test.js`: both appended a `describe`; both are kept.
- `changelog.md`: #251's caveat about an Add and a Remove stays removed; this branch's
  bullet comes before #254's.

`documentValuesOf` is gone: `restartWrittenRows` reads the forms merged from the root
through `documentFormsOf`, their fields and starts too where more than one holds the whole
document (`mergeDocuments`). `settleWrittenRows` hands it those forms by #254's
`mergedFromTheRoot`, the one written replaced by its values right after the write, as
before; that copy has no fields and no start, so it writes no field over the others and its
start never moves, and the Add or the Remove starts the form written again anyway.
AlexSamBY added a commit that referenced this pull request Oct 28, 2026
…a `useForm` table row (#253)

An Add and a Remove keep what was typed in the cells of a table row that
`useForm` gives a form of its own. #251 named both limitations in its
*Not in this change*; this closes them as agreed there (option A): the
fields each such row's form changed are written into the document's form
before a row is added or removed. The branch merges master with #254,
whose forms that hold the whole document the change now meets: *After
#254* says what that changed here.

## The defect

`pushDataKindRow` and `removeDataKindRow` (`engine/dataKindPush.ts`)
rebuild the document's data from a copy of its form's values, which hold
none of what a `useForm` row's fields hold, and `WithForm` starts the
form of a row whose data that changes again from it, as it does for a
host's new data. Measured on master with #251, React 18.3; in each,
`getFormData` and a submit had what the fields showed:

- phases whose cells are a `title`, with lines below each: `Design 2`
typed in a title went back to `Design` when a line was removed or added;
with lines that have forms of their own as well, a line's edit went too;
- `typed` in a row's cell and `new` in a field below it, in the
document's form: an Add showed `a` in the cell again;
- rows `a`, `b`, `c` edited to `a-edited`, `b-edited`, `c-edited`:
removing the second showed `a-edited`, `c`;
- a row whose form starts from the whole document, an empty (`null`)
row's or one whose cells read the document (`relativeData: false`), lost
its edit on any Add or Remove: the document changes, and the form starts
again from it.

Rows that share the document's form keep their edits in all of them.
#251 measured the removal the same on 0.35.0.

## The fix

- `engine/formData.ts`, `changedRowsFor(form, forms)`: each row whose
own form holds what the document's form does not, as the document form's
copy of it with the fields the row's form changed of its start written
at their names (`changedInside`, `startOf`, `writeField`), as the merges
write them, shallowest first, an inner row written into the outer one.
It leaves out a row its array in the document's form does not hold (one
just removed, or a hole in a row form's sparse values), a row whose
place there has another shape, which final-form would throw for, and the
document's form itself.
- `engine/dataKindPush.ts`, `keepRowEdits`: writes those rows into the
document's form with final-form's `change`, in one `batch`, right before
the push or remove mutator, while each row is still where its form has
it. A removal moves an edit with its row, and the data rebuilt from the
form holds it; a row the operation moves or changes starts again from
that data with its edits, as the document's form does.
- `engine/formData.ts`, `settleWrittenRows`, once the row is added or
removed: puts back the rows the operation left as they were (below), and
moves the start of every other row written to what the document's form
held of it once written, before the row was added or removed
(`restartWrittenRows`, as #251's submit does, handed the document's form
as it was then).

Nothing is written in place: final-form makes new values and tells the
form's subscribers, and `createBatchedForm` counts the change for the
merge a `showIf` reads.

## After #254

- `restartWrittenRows` reads the forms merged from the root of the
document through `documentFormsOf` now, and where more than one holds
the whole document, as a `useForm` block's that selects no values does
beside the document's own, their fields and starts too, and it moves
their starts (`mergeDocuments`). `settleWrittenRows` hands it those
forms by #254's `mergedFromTheRoot`, the one written replaced, as
before, by its values right after the write. That copy has no fields and
no start: it writes no field over the others, and its start never moves.
The Add or the Remove starts that form again from the data it rebuilt
anyway.
- Beside such a block, measured with the new tests: the rows' edits go
through an Add and a Remove, and the start of a row kept written moves,
read through `mergeDocuments`.
- In a table inside such a block, an Add and a Remove now keep the rows'
edits in their fields, which a moved row did not before. What
`getFormData` and a submit read after an Add or a Remove there is as
#254 left it (its *Not in this change*), the same with this change and
without it.
- Conflicts, each resolved by keeping both sides: the imports of
`formData.ts`; the `describe`s both appended to `formData.test.js`; the
changelog, where #251's caveat about an Add and a Remove stays removed
and this change's bullet comes before #254's.

## What it does to the rows' forms

Written into the rebuilt data, the edits of a row the Add or the Remove
leaves where it was, and as it was, would start its form again too,
which nothing did before. Measured with that first version, in the
commonest case, an edit and then an Add in the same table: the row's
form was no longer dirty (`canSave` false), and a `Required` one of its
fields showed was gone from the field and from what
`getValidationErrors` told the host, until the field was touched again.
The document's submit does not touch a row form's fields, so it would
not have come back there either.

So such a row goes back into the rebuilt data as its form's
`initialValues` hold it: its form is handed what it started from, and
keeps its edits, its touched fields and its errors, as before this
change, and its start, since the data holds none of its edits. A row
goes back only where all of these hold:

- the rebuilt data holds, at its place, exactly the row that was
written: the operation neither moved it (a removal, or the compaction a
removal does) nor changed it (a line added or removed in its table);
- the document's form held the row just as the row's form started from
it, an object: no edit of the document's form is in it;
- the row's form holds just that row at its place, as a `useForm` row's
does (`engine/Data.tsx`): a form that starts from the whole document
starts again whatever happens to its row, so its edits must be in the
data;
- every row written inside it can go back as well. Found in review: put
back, an outer row took away the value typed in a `null` line inside it.

Every other row written keeps its edits in the data, and its form's
start moves to them (`restartWrittenRows`). Most such forms start again
from the data anyway. One handed a moved row that reads as the one it
started from does not: rows `x`, `a`, `a`, the second edited to `a1`,
the first removed. Without the move it wrote its edit over the row that
came to its place: measured, `getFormData` had `a1`, `a1`; with it,
`a1`, `a`. Moving the start of a row put back as well lost its edit from
the data, measured: its form gave nothing more, and the data held its
start.

Checked as well:

- rows that share the document's form: unchanged, their values are in
its form already; every data test below runs on both kinds;
- a host's new data still resets the rows, after an Add that put the
edits into the data;
- a table's add row (`renderExtraItem`) is not data (`dataFormsOf`), and
is not written.

## Tests

- `rules.row-form-values.test.js`, each on rows with forms of their own
and on rows that share the document's form: a line removed and added
below a phase whose title was edited; an Add after an edit in a row and
below it; an Add after an edit below a row of a field its cells only
show; a removal of the middle of three edited rows; a removal that moves
a row reading as the edited one into its place; a host's new data after
an Add. Lines with forms of their own below phases with forms of their
own: the row's and the line's edits through a removal.
- `rules.row-form-values.test.js`, each on both kinds of row, in a table
beside a `useForm` block that holds the whole document: a row's edit
through an Add and then a Remove of the row before it; a removal that
moves a row reading as the edited one into its place.
- `rules.row-form-values.test.js`, a row whose form starts from the
whole document: a value typed in a `null` line of a phase that goes
back, as a line is added below another phase; an edit in a row whose
cells read the document, as a row is added.
- `rules.row-form-values.test.js`, a `useForm` row an Add or a Remove
leaves where it was: its edit, in the field and in `getFormData`, its
form still dirty, a `Required` it shows and the host told of it, through
an Add, and the edit set back after it; its form still dirty through a
Remove of a later row.
- `formData.test.js`, `changedRowsFor`: the fields the row's form
changed written over the document's copy, which it leaves as it was; a
row whose edits the document's form holds already, a row past its array,
at a hole, at a place of another shape, and the form itself, left out;
`initial` only for an object row that the form holds just so and whose
form holds just it; none for a row with a written row inside it that has
none; an inner row after the outer one, in it, whichever registered
first.
- `formData.test.js`, `settleWrittenRows`: a row left as it was put
back, its form's edit still given; a moved row left, its form's start
moved; another form that holds the document from its root read as it is,
and no start moved for a row not written; an outer row put back with the
rows inside it, whose starts stay.

Measured on this branch, with master merged: without the edits written
(`changedRowsFor` giving no row, as on master), eleven of the data tests
on rows with forms of their own fail, the two beside a block among them,
and the rest pass. Without the move of the starts, the two removals that
move a row reading as the edited one into its place fail, in a table
alone and beside a block. With the starts of the rows put back moved as
well, four fail: the two of a row left where it was, the removal of the
middle of three edited rows, whose first row goes back, and the Add and
then Remove beside a block, whose edited row the Add puts back. No
budget test drives an Add or a Remove, and no budget number moves.

## Gates

On Node 24.14.0, at a5e1a06, master merged, each exits 0:

- `lint:js`, `lint:css`, `typecheck`, `typecheck:contract`,
`docs:views:check`, `docs:props:check`, `docs:phrases:check`,
`css:fixture:check`, `build-css:check`, `sync-version -- --check`;
- `npm test` and `test:react16`, `test:react17`, `test:react19` (255
suites, 3503 tests each; one suite skipped on 16 and 17, as designed),
and `test:coverage` with every threshold held (`formData.ts`
100/99.59/100/100, its one branch left the Select reordering's, as on
master; `dataKindPush.ts` 100%);
- `build-lib`, `test:css:built`, `test:types:consumer`,
`test:pack:budget`, `test:pack:consumer`, `test:pack:peers`,
`test:env-flags`, `build`;
- `CI=1 test:e2e` and `CI=1 test:e2e:react19` (77 each),
`test:pack:browser` and `test:pack:browser:peers`.

`npm test` and `test:react16` failed their first run in
`later-mount-budget.test.js`, which drives no Add or Remove: one test
ran past jest's 5 s timeout while the machine's load average stood at
126 beside a virtual machine, and on React 18 the next test failed in
its wake (overlapping `act` calls). Rerun, both exited 0, and the suite
alone takes 3.5 s. Every other leg, coverage's run on React 18 among
them, passed it the first time. The jest legs ran with
`<rootDir>/\.claude/` ignored as well, for a stray, gitignored probe in
this checkout. Not run here: `test:e2e:engines`, `test:perf` and
`audit:bundled`, which CI runs as advisory or weekly jobs; this change
adds no dependency.

## Not in this change

- A Remove still compacts the document's top-level tables
(`compactDataKindArrays`): a row with no content goes, wherever the
Remove was. A `useForm` row whose every value was emptied now has that
in the data, so a Remove of another row takes it out of its table, as it
takes such a row of the document's form. Measured, rows `a`, `b`, `c`
with `b` emptied and `c` removed: one row left, where master with #251
kept the emptied one and sent it as `{ kind: '' }`. Rows that share the
document's form left one row before and after.
- A row's form handed a row that reads as the one it started from is not
started again (`WithForm` compares by value), so after a removal that
moves such a row into its place, it still shows its own edit, as on
master with #251; the data now holds the moved row as it is.
- A `useForm` block that holds the whole document (#254) starts again
from the data an Add or a Remove in a table beside it rebuilds, and
loses its own edits: measured, `U` typed in its `title` went back to
`T`, in the field, in `getFormData` and in a submit, on rows of either
kind, the same with this change and without it. Written into the
document's form as a row's are, its changed fields would stay; not done
here.

`CLAUDE.md` gets one sentence on the new step, beside #251's paragraph
on `useForm` rows.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@AlexSamBY AlexSamBY mentioned this pull request Oct 28, 2026
AlexSamBY added a commit that referenced this pull request Oct 28, 2026
Release 0.35.1: the version, and the changelog's Unreleased section as
`v0.35.1`.

## What changes

- `package.json` and `package-lock.json` read 0.35.1, set by `npm
version 0.35.1 --no-git-tag-version`. Its `version` script set the
`data-version` of `src/library/AppWrapper.tsx` and `public/index.html`.
- `src/demo/markdowns/changelog.md`: `### Unreleased` is `### v0.35.1`,
with a paragraph on what the release is.

## What the release holds

The three changes merged since 0.35.0 (8e76778), each with its own
changelog entries:

- #251 (EPBDS-17018): a table row that `useForm` gives a form of its own
starts with its values, and a submit sends every row with its edits. A
`showIf` that reads the form merges the document's forms once, not once
per node.
- #254: an edit in a `useForm` block that holds the whole document is in
what the document reads.
- #253 (EPBDS-17018): an Add or a Remove keeps what was typed in the
cells of such a row.

None of them changes what a host builds against:
`src/library/contract.ts`, `src/library/main.tsx`, the peer range and
the locked dependencies are as in 0.35.0. The build bundles the same 19
packages, at the same versions, as 0.35.0's (`dist/sbom.cdx.json`), and
`audit:bundled` finds nothing in them.

## How the changelog was audited

Each of the three commits since 0.35.0 touched the changelog (`git
diff-tree --name-only`), and nothing in the section reads wrong once
released. The section states no package sizes.

## Checked

On this branch's commit df8734c, over master's 1895624, with Node
24.14.0, 27 gates exit 0:

- `lint:js`, `lint:css`, `typecheck`, `typecheck:contract`, the three
docs checks, `css:fixture:check`, `build-css:check`, `sync-version
--check`;
- `npm test`, `test:react16`, `test:react17` and `test:react19`: 255
suites and 3503 tests each, one suite skipped on 16 and 17; coverage
with its thresholds;
- `build-lib`, `test:css:built`, `test:types:consumer`,
`test:pack:budget`, `test:pack:consumer`, `test:pack:peers`,
`test:env-flags`, the demo build;
- `CI=1 test:e2e` and `CI=1 test:e2e:react19`, 77 each;
`test:pack:browser` and `test:pack:browser:peers`.

Notes on these runs:

- The first run of `test:react17` lost a jest worker to a SIGSEGV, in
`rules.popup-content.test.js`. Its crash report has the stack of the V8
bug `nodejs/node#62393`, which Node 24.14.0 has. The rerun exited 0.
- The jest legs ran with `<rootDir>/\.claude/` ignored as well, for a
stray, gitignored probe in this checkout.
- Not run here: `test:e2e:engines` and `test:perf`, which CI runs as
advisory jobs.

`npm pack --dry-run` on this branch: 26 files, 3.8 MB unpacked, 1.0 MB
packed, `dist/index.js` 338.5 KB (0.35.0: 332 KB), shasum
`5c1e5655763232d79a83f2ffb7c1e014e88d7fea`.

## After it merges

What publishes the release, in this order:
1. Tag the squash commit `v0.35.1`, and push the tag.
2. `npm publish`. Its `prepack` checks the version sites and the
generated docs, and builds the library.
3. `npm run build && npm run deploy`. The changelog reaches hosts
through the demo site, so the deploy is part of the release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant